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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- 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/server.ts +28 -0
- 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 +372 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
- 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 +169 -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 +89 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
- 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 +41 -28
- 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 +29 -5
- 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 +148 -21
- package/templates/database/CHANGELOG.md +14 -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
package/component-catalog.json
CHANGED
|
@@ -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":
|
|
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",
|
package/component-catalog.md
CHANGED
|
@@ -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: **
|
|
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: **
|
|
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, **
|
|
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**
|
|
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 (
|
|
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
|
|
package/dist/surfaces.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
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.
|
|
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.
|
|
31
|
-
"@working-theory/templates": "0.
|
|
30
|
+
"@working-theory/schema": "0.32.0",
|
|
31
|
+
"@working-theory/templates": "0.32.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/templates/.env.example
CHANGED
|
@@ -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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
192
|
+
raw: pretty,
|
|
193
|
+
});
|
|
147
194
|
const res = makeRes();
|
|
148
195
|
|
|
149
|
-
await handler(
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
};
|
|
214
|
+
raw: compactPlusNewline,
|
|
215
|
+
});
|
|
171
216
|
const res = makeRes();
|
|
172
217
|
|
|
173
|
-
await handler(
|
|
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();
|