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
@@ -49,7 +49,7 @@
49
49
  "importPath": "@working-theory/templates/shells",
50
50
  "rootBarrel": "@working-theory/templates",
51
51
  "sourceRoot": "packages/templates/src/shells",
52
- "count": 5
52
+ "count": 6
53
53
  },
54
54
  {
55
55
  "name": "docs-blog-blocks",
@@ -3015,6 +3015,17 @@
3015
3015
  "descriptionGrade": "weak",
3016
3016
  "sectionType": null
3017
3017
  },
3018
+ {
3019
+ "name": "SignedInShell",
3020
+ "tier": "shells",
3021
+ "sourcePath": "packages/templates/src/shells/SignedInShell.tsx",
3022
+ "importPath": "@working-theory/templates/shells",
3023
+ "exported": true,
3024
+ "description": "Every AILK signed-in area runs on this frame: the admin app's workspace (AppFrame supplies its switcher, Library and gating as content) and a site's own /account area.",
3025
+ "descriptionSource": "doc-comment",
3026
+ "descriptionGrade": "strong",
3027
+ "sectionType": null
3028
+ },
3018
3029
  {
3019
3030
  "name": "DocsActionBar",
3020
3031
  "tier": "docs-blog-blocks",
@@ -15,13 +15,13 @@ The component inventory of `@working-theory/ui` and the shells of `@working-theo
15
15
  3. Check the Primitives, Panels and Shells tables too — a low-level control, a collapsible layout container, or a route-level frame lives there, not under Sections or Blocks.
16
16
  4. Only then build. If you do build, you are declaring the capability is genuinely absent.
17
17
 
18
- Inventory: **303 component files** across 16 tiers — 55 sections, 99 blocks, 18 chrome, 79 primitives, 6 panels, 5 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 5 scheduling-blocks, 3 signal-collectors-sections, 8 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 300 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
18
+ Inventory: **304 component files** across 16 tiers — 55 sections, 99 blocks, 18 chrome, 79 primitives, 6 panels, 6 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 5 scheduling-blocks, 3 signal-collectors-sections, 8 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 301 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
19
19
 
20
- Description coverage: **299/303** components carry a capability description derived from source — 44 from the section registry's hand-authored when-to-use text, 224 from the component's own doc-comment header, 31 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
20
+ Description coverage: **300/304** components carry a capability description derived from source — 44 from the section registry's hand-authored when-to-use text, 225 from the component's own doc-comment header, 31 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
21
21
 
22
- Of those, **268** are *strong* — a capability sentence you could match a requirement against — and **31** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
22
+ Of those, **269** are *strong* — a capability sentence you could match a requirement against — and **31** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
23
23
 
24
- Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 4/5 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 5/5 · **Signal-Collectors Sections** 3/3 · **Signal-Collectors Blocks** 7/8 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
24
+ Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 5/6 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 5/5 · **Signal-Collectors Sections** 3/3 · **Signal-Collectors Blocks** 7/8 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
25
25
 
26
26
  That grade is **mechanical**. It counts what it can measure — how much a description adds beyond the component's own name, how much of it is spent on wiring, whether the extraction came out damaged — and it cannot tell a fluent paragraph about the wrong subject from a useful one. A person reading these tables will find more rows unusable than the weak count admits. Treat it as a floor on the problem, not a measurement of it, and read the source before concluding a capability is absent.
27
27
 
@@ -365,7 +365,7 @@ Import: `import { X } from "@working-theory/ui/panels";` (or from the `@working-
365
365
 
366
366
  ---
367
367
 
368
- ## Shells (5)
368
+ ## Shells (6)
369
369
 
370
370
  Route-level slot-frames — the outermost layout a page is placed into. They supply geometry and slots (nav / sidebar / main / footer), not content: you inject the real Nav, Footer and sidebar. A different package from every tier above: `@working-theory/templates`.
371
371
 
@@ -378,6 +378,7 @@ Import: `import { X } from "@working-theory/templates/shells";` (or from the `@w
378
378
  | `AuthShell` | The frame the five auth pages (/sign-in, /sign-up, /forgot-password, /reset-password, /verify-email) share: a brand mark top-start, one centered content column, and a slim footer. No nav — an auth page has nothing to navigate to, and the marketing Nav is exactly what this shell exists to replace on those routes. | `import { AuthShell } from "@working-theory/templates/shells"` |
379
379
  | `DeckHeroObserver` | Internal client island for MarketingShell's `deckHero` option. | internal — compose from `packages/templates/src/shells/DeckHeroObserver.tsx` |
380
380
  | `MarketingShell` | Public-facing marketing site **slot-frame**. Server Component (RSC default). A shell is a slot-defining frame: it carves out *where chrome goes* (top + bottom) and *where sections go* (the body), owning **geometry only**. Chrome (Nav/Footer) and sections **fill** the slots — nothing is baked into the component. See (first instance). *(weak)* | `import { MarketingShell } from "@working-theory/templates/shells"` |
381
+ | `SignedInShell` | Every AILK signed-in area runs on this frame: the admin app's workspace (AppFrame supplies its switcher, Library and gating as content) and a site's own /account area. | `import { SignedInShell } from "@working-theory/templates/shells"` |
381
382
 
382
383
  ---
383
384
 
@@ -162,11 +162,10 @@ export declare function surfaceForRouteDir(routeDir: string): string | undefined
162
162
  * file only when the target does not already have it — it never overwrites,
163
163
  * the same rule routes themselves follow.
164
164
  *
165
- * `(authed)/account` depending on two files INSIDE `(authed)/waitlist`
166
- * (`ForbiddenState.tsx`, `format.ts`) is real, found by reading the account
167
- * page's own imports, not guessed from the issue text: the account page
168
- * renders a shared "forbidden" empty state and shared count/date formatters
169
- * that the waitlist dashboard also uses, rather than duplicating them.
165
+ * Every list is read from the route's own imports, not guessed from issue
166
+ * text. `(authed)/account` no longer reaches into `(authed)/waitlist` (#7430):
167
+ * its layout renders the site's mark through `siteLogo()` on SignedInShell,
168
+ * so `site-logo.tsx` and the `site-brand.tsx` it reads travel with it.
170
169
  */
171
170
  export declare const ROUTE_DEPENDENCIES: Readonly<Record<string, readonly string[]>>;
172
171
  /**
package/dist/surfaces.js CHANGED
@@ -609,6 +609,13 @@ export const ROUTE_SINCE = {
609
609
  // choice #5048's "projects/apply" entry already made for the same reason.
610
610
  "(authed)": "0.18.0",
611
611
  "(authed)/account": "0.18.0",
612
+ // #7430 — the base account area's three new route directories (spec 7423
613
+ // U4): item detail, User settings and Support. Genuinely new, so they
614
+ // attach at today's fixed-group version, the same rule #5977, #5678, #5479,
615
+ // #5371 and #5048 applied.
616
+ "(authed)/account/inbox/[kind]/[id]": "0.30.0",
617
+ "(authed)/account/settings": "0.30.0",
618
+ "(authed)/account/support": "0.30.0",
612
619
  // The leads inbox (#5678). The ROUTE ships in every scaffold, Free
613
620
  // included — lead capture is a never-gate surface, so reading a lead back
614
621
  // is one too. Whether it shows a ROW is a separate question with two
@@ -691,11 +698,10 @@ export function surfaceForRouteDir(routeDir) {
691
698
  * file only when the target does not already have it — it never overwrites,
692
699
  * the same rule routes themselves follow.
693
700
  *
694
- * `(authed)/account` depending on two files INSIDE `(authed)/waitlist`
695
- * (`ForbiddenState.tsx`, `format.ts`) is real, found by reading the account
696
- * page's own imports, not guessed from the issue text: the account page
697
- * renders a shared "forbidden" empty state and shared count/date formatters
698
- * that the waitlist dashboard also uses, rather than duplicating them.
701
+ * Every list is read from the route's own imports, not guessed from issue
702
+ * text. `(authed)/account` no longer reaches into `(authed)/waitlist` (#7430):
703
+ * its layout renders the site's mark through `siteLogo()` on SignedInShell,
704
+ * so `site-logo.tsx` and the `site-brand.tsx` it reads travel with it.
699
705
  */
700
706
  export const ROUTE_DEPENDENCIES = {
701
707
  // #5963 — the (auth) group's layout renders the brand mark through
@@ -715,12 +721,45 @@ export const ROUTE_DEPENDENCIES = {
715
721
  "(auth)/sign-up": ["apps/web/lib/auth-env.ts"],
716
722
  "(auth)/forgot-password": ["apps/web/lib/auth-env.ts"],
717
723
  "(auth)/reset-password": ["apps/web/lib/auth-env.ts"],
718
- "(authed)": ["apps/web/auth.ts", "apps/web/lib/auth-env.ts"],
724
+ // #7430 — the group's layout reads `isAccountPath()` to give the account
725
+ // area its member-facing signed-out gate.
726
+ "(authed)": [
727
+ "apps/web/auth.ts",
728
+ "apps/web/lib/auth-env.ts",
729
+ "apps/web/lib/auth-routes.ts",
730
+ ],
719
731
  "(authed)/account": [
720
732
  "apps/web/lib/account-config.ts",
721
733
  "apps/web/lib/server-api.ts",
722
- `${ROUTE_ROOT}/(authed)/waitlist/components/ForbiddenState.tsx`,
723
- `${ROUTE_ROOT}/(authed)/waitlist/format.ts`,
734
+ "apps/web/lib/site-brand.tsx",
735
+ "apps/web/lib/site-logo.tsx",
736
+ ],
737
+ // #7430 — the three child routes also import files in their PARENT's
738
+ // directory. A scaffold whose `(authed)/account` predates #7430 keeps its
739
+ // own copy of that route (sync-routes never overwrites), so the files the
740
+ // children need travel with them: each is new in #7430, never a changed
741
+ // file sync-routes could not update.
742
+ "(authed)/account/inbox/[kind]/[id]": [
743
+ "apps/web/lib/account-config.ts",
744
+ "apps/web/lib/server-api.ts",
745
+ `${ROUTE_ROOT}/(authed)/account/account-data.ts`,
746
+ `${ROUTE_ROOT}/(authed)/account/error-row.tsx`,
747
+ `${ROUTE_ROOT}/(authed)/account/retry-button.tsx`,
748
+ ],
749
+ "(authed)/account/settings": [
750
+ "apps/web/lib/account-config.ts",
751
+ "apps/web/lib/auth-env.ts",
752
+ "apps/web/lib/server-api.ts",
753
+ "apps/web/lib/site-brand.tsx",
754
+ "apps/web/lib/site-theme.ts",
755
+ `${ROUTE_ROOT}/(authed)/account/account-data.ts`,
756
+ `${ROUTE_ROOT}/(authed)/account/support-actions.ts`,
757
+ ],
758
+ "(authed)/account/support": [
759
+ "apps/web/lib/account-config.ts",
760
+ "apps/web/lib/server-api.ts",
761
+ `${ROUTE_ROOT}/(authed)/account/account-data.ts`,
762
+ `${ROUTE_ROOT}/(authed)/account/support-actions.ts`,
724
763
  ],
725
764
  "(authed)/admin": [
726
765
  "apps/web/lib/server-api.ts",
@@ -773,6 +812,9 @@ export const ROUTE_MESSAGE_NAMESPACES = {
773
812
  "(auth)/reset-password": ["auth"],
774
813
  "(auth)/verify-email": ["auth"],
775
814
  "(authed)/account": ["account"],
815
+ "(authed)/account/inbox/[kind]/[id]": ["account"],
816
+ "(authed)/account/settings": ["account"],
817
+ "(authed)/account/support": ["account"],
776
818
  "(authed)/admin": ["admin"],
777
819
  "(authed)/leads": ["leadsInbox"],
778
820
  // #5977 — every string on both editor routes comes from the `edit`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,8 +27,8 @@
27
27
  "tar": "^7.5.21"
28
28
  },
29
29
  "devDependencies": {
30
- "@working-theory/schema": "0.30.0",
31
- "@working-theory/templates": "0.30.0"
30
+ "@working-theory/schema": "0.32.0",
31
+ "@working-theory/templates": "0.32.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -449,6 +449,35 @@ CONTENT_ADAPTER=mdx
449
449
  # API is not on localhost.
450
450
  # AILK_API_URL=http://localhost:3001
451
451
  #
452
+ # Hub and spokes — per-visitor lead limits (#7394). When one apps/api (the
453
+ # hub) is the lead sink for several apps/web sites (spokes) that call it
454
+ # server-to-server through AILK_API_URL, every visitor of a spoke arrives at
455
+ # the hub from the spoke's one egress IP. A spoke holding a hub-issued key
456
+ # forwards the visitor's IP on its three lead writes (contact, newsletter,
457
+ # callback), and the hub counts each visitor separately. Runbook:
458
+ # docs/runbooks/site-configuration.md § Hub and spokes: per-visitor lead
459
+ # limits. All four unset = the behaviour before #7394.
460
+ #
461
+ # HUB (apps/api). One key per spoke, comma-separated; each at least 32
462
+ # characters (mint with `openssl rand -hex 32`). A shorter entry is ignored
463
+ # with a warning naming its position. A key's position identifies its spoke
464
+ # in the rate-limit log: to revoke one, BLANK its entry (`k1,,k3`) rather
465
+ # than deleting it, so no other key changes position.
466
+ # AILK_VISITOR_FORWARD_KEYS=
467
+ #
468
+ # HUB (apps/api). The leads scope's per-visitor limit per minute, and each
469
+ # key's own ceiling per minute across all of its visitors. Whole numbers from
470
+ # 1 to 999999; an invalid value is ignored with a warning. Defaults: 10 and
471
+ # 300.
472
+ # AILK_RATE_LIMIT_LEADS_PER_MINUTE=10
473
+ # AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE=300
474
+ #
475
+ # SPOKE (apps/web, server-only — never NEXT_PUBLIC_). This spoke's key: one
476
+ # entry of the hub's AILK_VISITOR_FORWARD_KEYS. Sent only when AILK_API_URL
477
+ # is https (or loopback). Issue a key only to a spoke whose platform
478
+ # overwrites x-forwarded-for / x-real-ip with the real client address.
479
+ # AILK_VISITOR_FORWARD_KEY=
480
+ #
452
481
  # NEXT_PUBLIC_ENABLED_LOCALES — do NOT set by hand. Baked at build time by
453
482
  # apps/web/next.config.mjs from project.yaml `product.locales` (falls back to
454
483
  # "en" when project.yaml is absent) and read by middleware.ts, the sitemap
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @file leads-forwarded-rate-limit.test.ts
3
+ * @description #7394 AC-7 — a keyed spoke's lead writes are limited per
4
+ * visitor end to end, through the real `build()` and the REAL
5
+ * `@fastify/rate-limit` global ceiling.
6
+ *
7
+ * `server.test.ts` (and ten other `apps/api` files) replace
8
+ * `@fastify/rate-limit` with a no-op for the whole file, so none of them can
9
+ * observe the global ceiling. This file does not mock it. It mocks only the
10
+ * two boundaries `server.test.ts` mocks for infrastructure reasons:
11
+ * `@fastify/cors` and `@working-theory/email` (an ESM-only transitive dep).
12
+ *
13
+ * `DATABASE_URL` is unset for the file's duration, so a lead write takes the
14
+ * zero-config path, where the route refuses the empty body `{}` with a 400
15
+ * before any write. With the setup file's placeholder `DATABASE_URL`, the
16
+ * route would first try to resolve the site's workspace against a database
17
+ * this test does not have. The limiter runs on `onRequest`, before either.
18
+ *
19
+ * Every request comes from one `x-forwarded-for` — the spoke host. Each
20
+ * sub-case gets a fresh `build()` after `__resetBuckets()`, so neither the
21
+ * in-house buckets nor the plugin's own in-memory store carry over.
22
+ *
23
+ * Spec: docs/specs/7394-the-leads-rate-limit-scope-spec.md §5.1 AC-7.
24
+ */
25
+
26
+ import { __resetBuckets } from "../middleware/rate-limit";
27
+ import { build } from "../server";
28
+
29
+ jest.mock("@fastify/cors", () => {
30
+ return jest.fn().mockResolvedValue(undefined);
31
+ });
32
+
33
+ jest.mock("@working-theory/email", () => ({
34
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
35
+ }));
36
+
37
+ const KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
38
+ const SPOKE_HOST = "198.51.100.7";
39
+
40
+ /** A distinct, valid visitor IP per index. */
41
+ function visitor(i: number): string {
42
+ return `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`;
43
+ }
44
+
45
+ function headersFor(
46
+ visitorIp: string,
47
+ withKey: boolean,
48
+ ): Record<string, string> {
49
+ return {
50
+ "x-forwarded-for": SPOKE_HOST,
51
+ "x-ailk-visitor-ip": visitorIp,
52
+ ...(withKey && { "x-ailk-visitor-forward-key": KEY }),
53
+ };
54
+ }
55
+
56
+ describe("leads rate limit for a keyed spoke, against the real global ceiling (#7394 AC-7)", () => {
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
58
+ let app: any;
59
+ const prior = process.env.AILK_VISITOR_FORWARD_KEYS;
60
+ const priorDatabaseUrl = process.env.DATABASE_URL;
61
+
62
+ async function statuses(
63
+ count: number,
64
+ request: (i: number) => {
65
+ method: string;
66
+ url: string;
67
+ headers: Record<string, string>;
68
+ payload?: unknown;
69
+ },
70
+ ): Promise<number[]> {
71
+ const codes: number[] = [];
72
+ for (let i = 0; i < count; i++) {
73
+ const res = await app.inject(request(i));
74
+ codes.push(res.statusCode);
75
+ }
76
+ return codes;
77
+ }
78
+
79
+ function leadWrite(visitorIp: string, withKey: boolean) {
80
+ return {
81
+ method: "POST",
82
+ url: "/v1/leads/submit",
83
+ headers: headersFor(visitorIp, withKey),
84
+ payload: {},
85
+ };
86
+ }
87
+
88
+ beforeEach(async () => {
89
+ __resetBuckets();
90
+ process.env.AILK_VISITOR_FORWARD_KEYS = KEY;
91
+ delete process.env.DATABASE_URL;
92
+ app = await build();
93
+ await app.ready();
94
+ });
95
+
96
+ afterEach(async () => {
97
+ if (app) await app.close();
98
+ app = undefined;
99
+ });
100
+
101
+ afterAll(() => {
102
+ if (prior === undefined) delete process.env.AILK_VISITOR_FORWARD_KEYS;
103
+ else process.env.AILK_VISITOR_FORWARD_KEYS = prior;
104
+ if (priorDatabaseUrl !== undefined)
105
+ process.env.DATABASE_URL = priorDatabaseUrl;
106
+ });
107
+
108
+ it("(a) 61 keyed lead writes from distinct visitors behind one spoke host: none is 429", async () => {
109
+ const codes = await statuses(61, (i) => leadWrite(visitor(i), true));
110
+ // The route refuses `{}` before any write, so every answer is that 400.
111
+ expect(codes.every((code) => code === 400)).toBe(true);
112
+ });
113
+
114
+ it("(b) 11 keyed lead writes from one visitor: the eleventh is 429", async () => {
115
+ const codes = await statuses(11, () => leadWrite("203.0.113.1", true));
116
+ expect(codes.slice(0, 10).every((code) => code === 400)).toBe(true);
117
+ expect(codes[10]).toBe(429);
118
+ });
119
+
120
+ it("(c) 11 unkeyed lead writes from distinct visitors: the eleventh is 429", async () => {
121
+ const codes = await statuses(11, (i) => leadWrite(visitor(i), false));
122
+ expect(codes.slice(0, 10).every((code) => code === 400)).toBe(true);
123
+ expect(codes[10]).toBe(429);
124
+ });
125
+
126
+ it("(d) 61 keyed GET /health from distinct visitors: the global ceiling still keys on the spoke host", async () => {
127
+ const codes = await statuses(61, (i) => ({
128
+ method: "GET",
129
+ url: "/health",
130
+ headers: headersFor(visitor(i), true),
131
+ }));
132
+ expect(codes[59]).toBe(200);
133
+ expect(codes[60]).toBe(429);
134
+ });
135
+ });
@@ -15,6 +15,11 @@
15
15
  * string) instead of response.rawPayload corrupts any non-UTF-8 response
16
16
  * by substituting U+FFFD for every invalid byte.
17
17
  *
18
+ * #7440 extends the request side: the adapter must forward the RAW bytes the
19
+ * client sent, not the parsed `req.body` — a signed webhook (Stripe, Slack,
20
+ * Cal.com) is verified over the exact bytes, and a re-serialization of the
21
+ * parse never matches a pretty-printed original.
22
+ *
18
23
  * The two request-side tests below drive a REAL minimal Fastify instance
19
24
  * through the handler — the defect lives inside Fastify's own
20
25
  * content-length check, so a mocked inject() cannot fail the way production
@@ -29,6 +34,9 @@
29
34
  * by every later test regardless of what that test wants build() to return.
30
35
  */
31
36
 
37
+ import { createHmac } from 'node:crypto';
38
+ import { PassThrough } from 'node:stream';
39
+
32
40
  import type { VercelRequest, VercelResponse } from '@vercel/node';
33
41
  import Fastify from 'fastify';
34
42
 
@@ -60,6 +68,47 @@ function asRequest(stub: MockVercelRequest): VercelRequest {
60
68
  return stub;
61
69
  }
62
70
 
71
+ /**
72
+ * A body-carrying request the way the Vercel Node runtime hands it over: the
73
+ * runtime's helpers drain the stream to a Buffer, expose a lazily PARSED
74
+ * `req.body`, then replay the very same bytes through a PassThrough that only
75
+ * `on`/`addListener` for `data`/`end` reach (`restoreBody` in @vercel/node's
76
+ * serverless-functions helpers). The stub reproduces exactly that: an
77
+ * already-ended original stream with the replay patched over it, and `body`
78
+ * set to what the runtime's parser produced (`parsed`, default the JSON parse).
79
+ */
80
+ function bodyRequest(fields: {
81
+ method?: string;
82
+ url: string;
83
+ headers: Record<string, string>;
84
+ raw: string;
85
+ parsed?: unknown;
86
+ }): VercelRequest {
87
+ const original = new PassThrough();
88
+ original.end();
89
+ original.resume();
90
+ const replay = new PassThrough();
91
+ replay.end(Buffer.from(fields.raw));
92
+ const originalOn = original.on.bind(original);
93
+ const replayedOn = (name: string, cb: (...args: unknown[]) => void) =>
94
+ name === 'data' || name === 'end' ? replay.on(name, cb) : originalOn(name, cb);
95
+ const stub = Object.assign(original, {
96
+ read: replay.read.bind(replay),
97
+ on: replayedOn,
98
+ addListener: replayedOn,
99
+ method: fields.method ?? 'POST',
100
+ url: fields.url,
101
+ headers: fields.headers,
102
+ body: 'parsed' in fields ? fields.parsed : fields.raw ? JSON.parse(fields.raw) : {},
103
+ query: {},
104
+ });
105
+ // @ts-expect-error: a Readable carrying the fields above satisfies the
106
+ // subset of VercelRequest the handler reads (stream + method/url/headers/
107
+ // body/query); a full VercelRequest (extends http.IncomingMessage) cannot
108
+ // be constructed without a live connection.
109
+ return stub;
110
+ }
111
+
63
112
  interface MockRes {
64
113
  headersSent: boolean;
65
114
  _status?: number;
@@ -134,19 +183,17 @@ describe('vercel-handler — request side: stale content-length (#5168)', () =>
134
183
 
135
184
  const body = { hello: 'world', nested: { a: 1 } };
136
185
  const pretty = JSON.stringify(body, null, 2);
137
- const req: MockVercelRequest = {
138
- method: 'POST',
186
+ const req = bodyRequest({
139
187
  url: '/echo',
140
188
  headers: {
141
189
  'content-type': 'application/json',
142
190
  'content-length': String(Buffer.byteLength(pretty)),
143
191
  },
144
- body,
145
- query: {},
146
- };
192
+ raw: pretty,
193
+ });
147
194
  const res = makeRes();
148
195
 
149
- await handler(asRequest(req), asResponse(res));
196
+ await handler(req, asResponse(res));
150
197
 
151
198
  expect(res._status).toBe(200);
152
199
  expect(JSON.parse(res._sent as string)).toEqual(body);
@@ -158,25 +205,199 @@ describe('vercel-handler — request side: stale content-length (#5168)', () =>
158
205
 
159
206
  const body = { hello: 'world' };
160
207
  const compactPlusNewline = JSON.stringify(body) + '\n';
161
- const req: MockVercelRequest = {
162
- method: 'POST',
208
+ const req = bodyRequest({
163
209
  url: '/echo',
164
210
  headers: {
165
211
  'content-type': 'application/json',
166
212
  'content-length': String(Buffer.byteLength(compactPlusNewline)),
167
213
  },
168
- body,
169
- query: {},
170
- };
214
+ raw: compactPlusNewline,
215
+ });
171
216
  const res = makeRes();
172
217
 
173
- await handler(asRequest(req), asResponse(res));
218
+ await handler(req, asResponse(res));
174
219
 
175
220
  expect(res._status).toBe(200);
176
221
  expect(JSON.parse(res._sent as string)).toEqual(body);
177
222
  });
178
223
  });
179
224
 
225
+ // ── #7440: the signed bytes reach the verifier ──────────────────────────────
226
+ //
227
+ // Stripe signs the ORIGINAL bytes of the event — pretty-printed JSON — and the
228
+ // adapter used to forward the runtime's PARSED `req.body`, which inject()
229
+ // re-serialized compactly, so verifyWebhookSignature could never match on a
230
+ // Vercel deployment. These tests mount the REAL Stripe receiver behind the
231
+ // same raw-buffer parser server.ts scopes to the webhooks sub-router, and sign
232
+ // with the REAL verifier's scheme — only the licence (commerce-claimed), the
233
+ // analytics sink and Prisma are faked, none of which the verify path reads.
234
+
235
+ jest.mock('@working-theory/license', () => ({
236
+ checkLicense: jest.fn().mockResolvedValue({
237
+ tier: 'pro',
238
+ plan: 'pro-monthly',
239
+ features: ['commerce'],
240
+ expiresAt: null,
241
+ revoked: false,
242
+ }),
243
+ readStoredJwtState: jest.fn(),
244
+ }));
245
+ jest.mock('@working-theory/analytics/server', () => ({
246
+ track: jest.fn().mockResolvedValue(undefined),
247
+ }));
248
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
249
+ jest.mock('../lib/prisma.js');
250
+
251
+ /** Stripe's v1 scheme: `t=<ts>,v1=<hex HMAC-SHA256(secret, "<ts>.<payload>")>`. */
252
+ function stripeSignature(payload: string, secret: string): string {
253
+ const timestamp = Math.floor(Date.now() / 1000);
254
+ const v1 = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
255
+ return `t=${timestamp},v1=${v1}`;
256
+ }
257
+
258
+ describe('vercel-handler — raw bytes reach webhook verification (#7440)', () => {
259
+ let app: ReturnType<typeof Fastify>;
260
+
261
+ beforeEach(async () => {
262
+ jest.resetModules();
263
+ const { stripeWebhookHandler } = require('../routes/webhooks/stripe');
264
+ app = Fastify();
265
+ await app.register(async (webhooksScope) => {
266
+ webhooksScope.addContentTypeParser(
267
+ 'application/json',
268
+ { parseAs: 'buffer' },
269
+ (_req, body, done) => {
270
+ done(null, body);
271
+ },
272
+ );
273
+ webhooksScope.addContentTypeParser(
274
+ 'application/x-www-form-urlencoded',
275
+ { parseAs: 'buffer' },
276
+ (_req, body, done) => {
277
+ done(null, body);
278
+ },
279
+ );
280
+ webhooksScope.post('/v1/webhooks/stripe', stripeWebhookHandler);
281
+ webhooksScope.post('/v1/form-echo', async (req) => ({
282
+ raw: Buffer.isBuffer(req.body) ? req.body.toString('utf8') : null,
283
+ }));
284
+ });
285
+ app.post('/echo', async (req) => req.body);
286
+ await app.ready();
287
+ jest.doMock('../server', () => ({ build: jest.fn().mockResolvedValue(app) }));
288
+ });
289
+
290
+ afterEach(async () => {
291
+ await app.close();
292
+ });
293
+
294
+ function webhookSecret(): string {
295
+ const { config } = require('../config') as { config: { STRIPE_WEBHOOK_SECRET?: string } };
296
+ if (!config.STRIPE_WEBHOOK_SECRET) throw new Error('test setup sets STRIPE_WEBHOOK_SECRET');
297
+ return config.STRIPE_WEBHOOK_SECRET;
298
+ }
299
+
300
+ it('verifies a Stripe-signed, pretty-printed event and returns 200', async () => {
301
+ const handler = loadHandler();
302
+ // Stripe's own delivery format: indent=2, as every webhook body arrives.
303
+ const event = JSON.stringify(
304
+ {
305
+ id: 'evt_7440_pretty_printed',
306
+ object: 'event',
307
+ type: 'test.helper.unhandled',
308
+ created: 1_759_000_000,
309
+ data: { object: { id: 'obj_7440' } },
310
+ },
311
+ null,
312
+ 2,
313
+ );
314
+ const req = bodyRequest({
315
+ url: '/v1/webhooks/stripe',
316
+ headers: {
317
+ 'content-type': 'application/json; charset=utf-8',
318
+ 'content-length': String(Buffer.byteLength(event)),
319
+ 'stripe-signature': stripeSignature(event, webhookSecret()),
320
+ },
321
+ raw: event,
322
+ });
323
+ const res = makeRes();
324
+
325
+ await handler(req, asResponse(res));
326
+
327
+ expect(res._status).toBe(200);
328
+ expect(JSON.parse(String(res._sent))).toEqual({ received: true });
329
+ });
330
+
331
+ it('still rejects a body that differs from the signed bytes', async () => {
332
+ const handler = loadHandler();
333
+ const signed = JSON.stringify({ id: 'evt_7440_tampered', type: 'test.helper' }, null, 2);
334
+ const sent = signed.replace('evt_7440_tampered', 'evt_7440_tampereD');
335
+ const req = bodyRequest({
336
+ url: '/v1/webhooks/stripe',
337
+ headers: {
338
+ 'content-type': 'application/json',
339
+ 'stripe-signature': stripeSignature(signed, webhookSecret()),
340
+ },
341
+ raw: sent,
342
+ });
343
+ const res = makeRes();
344
+
345
+ await handler(req, asResponse(res));
346
+
347
+ expect(res._status).toBe(400);
348
+ expect(JSON.parse(String(res._sent))).toEqual({ error: 'signature-invalid' });
349
+ });
350
+
351
+ it('forwards a urlencoded body byte-for-byte to the raw-buffer scope (Slack)', async () => {
352
+ const handler = loadHandler();
353
+ const form = 'payload=%7B%22type%22%3A%22block_actions%22%7D&token=abc';
354
+ const req = bodyRequest({
355
+ url: '/v1/form-echo',
356
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
357
+ raw: form,
358
+ parsed: { payload: '{"type":"block_actions"}', token: 'abc' },
359
+ });
360
+ const res = makeRes();
361
+
362
+ await handler(req, asResponse(res));
363
+
364
+ expect(res._status).toBe(200);
365
+ expect(JSON.parse(String(res._sent))).toEqual({ raw: form });
366
+ });
367
+
368
+ // The fallback re-serializes `{}` (2 bytes) against a declared length of 0,
369
+ // so this also guards the content-length strip (#5168).
370
+ it('keeps an empty JSON POST answering as it did (the runtime parse, `{}`)', async () => {
371
+ const handler = loadHandler();
372
+ const req = bodyRequest({
373
+ url: '/echo',
374
+ headers: { 'content-type': 'application/json', 'content-length': '0' },
375
+ raw: '',
376
+ });
377
+ const res = makeRes();
378
+
379
+ await handler(req, asResponse(res));
380
+
381
+ expect(res._status).toBe(200);
382
+ expect(JSON.parse(String(res._sent))).toEqual({});
383
+ });
384
+
385
+ it('keeps a POST with no content-type bodiless, as the runtime forwarded it', async () => {
386
+ const handler = loadHandler();
387
+ const req = bodyRequest({
388
+ url: '/echo',
389
+ headers: { 'content-length': '5' },
390
+ raw: 'hello',
391
+ parsed: undefined,
392
+ });
393
+ const res = makeRes();
394
+
395
+ await handler(req, asResponse(res));
396
+
397
+ expect(res._status).toBe(200);
398
+ });
399
+ });
400
+
180
401
  describe('vercel-handler — response side: binary round-trip (#5168)', () => {
181
402
  beforeEach(() => {
182
403
  jest.resetModules();