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
@@ -34,11 +34,12 @@ import { createHash } from "node:crypto";
34
34
 
35
35
  import { getClock } from "@working-theory/common";
36
36
 
37
- /** The three fixed message kinds the relay will send. */
37
+ /** The four fixed message kinds the relay will send. */
38
38
  export type AuthEmailKind =
39
39
  | "password-reset"
40
40
  | "email-verification"
41
- | "password-changed";
41
+ | "password-changed"
42
+ | "magic-link";
42
43
 
43
44
  /** Sends allowed per (recipient, kind) per hour. */
44
45
  export const RECIPIENT_HOURLY_MAX = 3;
@@ -13,12 +13,15 @@
13
13
  * Spec: docs/internal/specs/draft/ailk-mcp-mvp-pb-b5-rate-limit-spec.md
14
14
  */
15
15
 
16
- import Fastify from "fastify";
16
+ import Fastify, { type FastifyInstance } from "fastify";
17
17
  import {
18
18
  rateLimitMiddleware,
19
19
  __resetBuckets,
20
20
  __bucketCount,
21
+ buildTrustedKeyDigests,
21
22
  DEFAULT_PER_IP_PER_MINUTE_STRICT,
23
+ resolveRateLimitIdentity,
24
+ type RateLimitOptions,
22
25
  } from "../rate-limit.js";
23
26
 
24
27
  function buildApp(
@@ -527,3 +530,528 @@ describe("rate-limit warn log — no query-string credential (#5970)", () => {
527
530
  }
528
531
  });
529
532
  });
533
+
534
+ // #7394 — per-visitor leads limit for a server-to-server spoke
535
+ // (docs/specs/7394-the-leads-rate-limit-scope-spec.md). Every route below is
536
+ // `POST /v1/leads/submit` unless a case says otherwise, so it is in
537
+ // `FORWARDED_VISITOR_ROUTES`. "The key" is one 64-character key passed
538
+ // through `buildTrustedKeyDigests`.
539
+ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394)", () => {
540
+ const KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
541
+ const SPOKE_HOST = "198.51.100.7";
542
+ const LEADS_SUBMIT = "/v1/leads/submit";
543
+ const ENV_KEYS = [
544
+ "AILK_RATE_LIMIT_LEADS_PER_MINUTE",
545
+ "AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE",
546
+ "AILK_RATE_LIMIT_PER_IP_PER_MINUTE",
547
+ ] as const;
548
+ const priorEnv = new Map<string, string | undefined>(
549
+ ENV_KEYS.map((name) => [name, process.env[name]]),
550
+ );
551
+
552
+ const noWarn = () => undefined;
553
+ const digests = () => buildTrustedKeyDigests(KEY, noWarn);
554
+
555
+ let app: FastifyInstance | undefined;
556
+ let logLines: Array<Record<string, unknown>>;
557
+
558
+ function forwardApp(
559
+ opts: RateLimitOptions,
560
+ routes: readonly string[] = [LEADS_SUBMIT],
561
+ ): FastifyInstance {
562
+ const instance = Fastify({
563
+ trustProxy: true,
564
+ logger: {
565
+ level: "warn",
566
+ stream: {
567
+ write(msg: string) {
568
+ logLines.push(JSON.parse(msg) as Record<string, unknown>);
569
+ },
570
+ },
571
+ },
572
+ });
573
+ instance.register(rateLimitMiddleware, opts);
574
+ for (const url of routes) instance.post(url, async () => ({ ok: true }));
575
+ return instance;
576
+ }
577
+
578
+ function keyed(visitorIp: string, key: string = KEY): Record<string, string> {
579
+ return {
580
+ "x-forwarded-for": SPOKE_HOST,
581
+ "x-ailk-visitor-forward-key": key,
582
+ "x-ailk-visitor-ip": visitorIp,
583
+ };
584
+ }
585
+
586
+ async function post(
587
+ headers: Record<string, string>,
588
+ url: string = LEADS_SUBMIT,
589
+ ): Promise<number> {
590
+ if (!app) throw new Error("no app");
591
+ const res = await app.inject({ method: "POST", url, headers });
592
+ return res.statusCode;
593
+ }
594
+
595
+ function rateLimitedLines(): Array<Record<string, unknown>> {
596
+ return logLines.filter((line) => line["msg"] === "rate_limited");
597
+ }
598
+
599
+ beforeEach(() => {
600
+ __resetBuckets();
601
+ logLines = [];
602
+ for (const name of ENV_KEYS) delete process.env[name];
603
+ });
604
+
605
+ afterEach(async () => {
606
+ if (app) await app.close();
607
+ app = undefined;
608
+ });
609
+
610
+ afterAll(() => {
611
+ for (const [name, value] of priorEnv) {
612
+ if (value === undefined) delete process.env[name];
613
+ else process.env[name] = value;
614
+ }
615
+ });
616
+
617
+ describe("AC-1 — AILK_RATE_LIMIT_<SCOPE>_PER_MINUTE overrides the code value", () => {
618
+ async function firstRefused(): Promise<number> {
619
+ const headers = { "x-forwarded-for": "203.0.113.50" };
620
+ for (let i = 1; i <= 50; i++) {
621
+ if ((await post(headers)) === 429) return i;
622
+ }
623
+ return -1;
624
+ }
625
+
626
+ it("the env var wins over perIpPerMinute", async () => {
627
+ process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = "3";
628
+ app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
629
+ await app.ready();
630
+ expect(await firstRefused()).toBe(4);
631
+ });
632
+
633
+ it("with the env var unset, the code value applies", async () => {
634
+ app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
635
+ await app.ready();
636
+ expect(await firstRefused()).toBe(11);
637
+ });
638
+
639
+ it("an invalid env var is ignored, with one warning naming the variable", async () => {
640
+ process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = "abc";
641
+ app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
642
+ await app.ready();
643
+ expect(await firstRefused()).toBe(11);
644
+ const warnings = logLines.filter((line) =>
645
+ String(line["msg"]).includes("AILK_RATE_LIMIT_LEADS_PER_MINUTE"),
646
+ );
647
+ expect(warnings).toHaveLength(1);
648
+ });
649
+
650
+ it.each(["0", "-5", "2.5", "1000000"])(
651
+ "ignores the out-of-range value %s",
652
+ async (value) => {
653
+ process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = value;
654
+ app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
655
+ await app.ready();
656
+ expect(await firstRefused()).toBe(11);
657
+ },
658
+ );
659
+
660
+ it("does not read AILK_RATE_LIMIT_PER_IP_PER_MINUTE for a named scope with a code value", async () => {
661
+ process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE = "2";
662
+ app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
663
+ await app.ready();
664
+ expect(await firstRefused()).toBe(11);
665
+ });
666
+ });
667
+
668
+ describe("AC-2 — a named scope gets its own bucket namespace", () => {
669
+ function twoScopes(second: RateLimitOptions): FastifyInstance {
670
+ const instance = Fastify({ trustProxy: true });
671
+ instance.register(async (child) => {
672
+ child.register(rateLimitMiddleware, { perIpPerMinute: 10 });
673
+ child.post("/a", async () => ({ ok: true }));
674
+ });
675
+ instance.register(async (child) => {
676
+ child.register(rateLimitMiddleware, second);
677
+ child.post("/b", async () => ({ ok: true }));
678
+ });
679
+ return instance;
680
+ }
681
+ const headers = { "x-forwarded-for": "203.0.113.60" };
682
+
683
+ it("ten /a requests do not spend the leads scope's budget", async () => {
684
+ app = twoScopes({ scope: "leads", perIpPerMinute: 10 });
685
+ await app.ready();
686
+ for (let i = 0; i < 10; i++) expect(await post(headers, "/a")).toBe(200);
687
+ for (let i = 0; i < 10; i++) expect(await post(headers, "/b")).toBe(200);
688
+ expect(await post(headers, "/b")).toBe(429);
689
+ });
690
+
691
+ it("two unnamed registrations still share their count, as at the pin", async () => {
692
+ app = twoScopes({ perIpPerMinute: 10 });
693
+ await app.ready();
694
+ for (let i = 0; i < 10; i++) expect(await post(headers, "/a")).toBe(200);
695
+ expect(await post(headers, "/b")).toBe(429);
696
+ });
697
+ });
698
+
699
+ describe("AC-3 — a keyed lead write is counted per visitor", () => {
700
+ it("limits each forwarded visitor separately behind one spoke host", async () => {
701
+ app = forwardApp({
702
+ scope: "leads",
703
+ perIpPerMinute: 10,
704
+ perKeyPerMinute: 300,
705
+ trustedKeyDigests: digests(),
706
+ });
707
+ await app.ready();
708
+ for (let i = 0; i < 10; i++) expect(await post(keyed("203.0.113.1"))).toBe(200);
709
+ expect(await post(keyed("203.0.113.1"))).toBe(429);
710
+ expect(await post(keyed("203.0.113.2"))).toBe(200);
711
+ });
712
+ });
713
+
714
+ describe("AC-4 — the visitor header is ignored without the key, or off the lead writes", () => {
715
+ const WRONG_SAME_LENGTH = "f".repeat(64);
716
+ const ONE_CHAR_CHANGED = `${KEY.slice(0, -1)}0`;
717
+
718
+ async function elevenDistinctVisitors(
719
+ headersFor: (visitorIp: string) => Record<string, string>,
720
+ url: string = LEADS_SUBMIT,
721
+ ): Promise<number[]> {
722
+ const codes: number[] = [];
723
+ for (let i = 1; i <= 11; i++) codes.push(await post(headersFor(`203.0.113.${i}`), url));
724
+ return codes;
725
+ }
726
+
727
+ const base = {
728
+ scope: "leads",
729
+ perIpPerMinute: 10,
730
+ perKeyPerMinute: 300,
731
+ } as const;
732
+
733
+ it.each([
734
+ [
735
+ "(i) no key",
736
+ (ip: string) => ({ "x-forwarded-for": SPOKE_HOST, "x-ailk-visitor-ip": ip }),
737
+ ],
738
+ ["(ii) a wrong key of the same length", (ip: string) => keyed(ip, WRONG_SAME_LENGTH)],
739
+ ["(iii) the right key with one character changed", (ip: string) => keyed(ip, ONE_CHAR_CHANGED)],
740
+ ])("%s: the eleventh request is 429", async (_label, headersFor) => {
741
+ app = forwardApp({ ...base, trustedKeyDigests: digests() });
742
+ await app.ready();
743
+ const codes = await elevenDistinctVisitors(headersFor);
744
+ expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
745
+ expect(codes[10]).toBe(429);
746
+ });
747
+
748
+ it("(iv) the valid key, but no trustedKeyDigests passed: the eleventh request is 429", async () => {
749
+ app = forwardApp(base);
750
+ await app.ready();
751
+ const codes = await elevenDistinctVisitors((ip) => keyed(ip));
752
+ expect(codes[10]).toBe(429);
753
+ });
754
+
755
+ it("(v) the valid key on POST /v1/project-listings in the same scope: the eleventh request is 429", async () => {
756
+ app = forwardApp({ ...base, trustedKeyDigests: digests() }, [
757
+ LEADS_SUBMIT,
758
+ "/v1/project-listings",
759
+ ]);
760
+ await app.ready();
761
+ const codes = await elevenDistinctVisitors((ip) => keyed(ip), "/v1/project-listings");
762
+ expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
763
+ expect(codes[10]).toBe(429);
764
+ });
765
+ });
766
+
767
+ describe("AC-5 — visitor IP validation and normalisation", () => {
768
+ function identityApp(): FastifyInstance {
769
+ const instance = Fastify({ trustProxy: true });
770
+ const trusted = digests();
771
+ instance.post(LEADS_SUBMIT, async (req) => resolveRateLimitIdentity(req, trusted));
772
+ return instance;
773
+ }
774
+
775
+ async function identityFor(visitorIp: string): Promise<unknown> {
776
+ if (!app) throw new Error("no app");
777
+ const res = await app.inject({ method: "POST", url: LEADS_SUBMIT, headers: keyed(visitorIp) });
778
+ return res.json();
779
+ }
780
+
781
+ it.each(["not-an-ip", "203.0.113.1, 198.51.100.1", "a".repeat(46), "fe80::1%eth0", ""])(
782
+ "falls back to req.ip for %j",
783
+ async (visitorIp) => {
784
+ app = identityApp();
785
+ await app.ready();
786
+ expect(await identityFor(visitorIp)).toEqual({ identity: SPOKE_HOST, keyIndex: null });
787
+ },
788
+ );
789
+
790
+ it("maps an IPv4-mapped IPv6 address to its IPv4 form", async () => {
791
+ app = identityApp();
792
+ await app.ready();
793
+ expect(await identityFor("::ffff:203.0.113.9")).toEqual({
794
+ identity: "v0,203.0.113.9",
795
+ keyIndex: 0,
796
+ });
797
+ });
798
+
799
+ it.each(["2001:db8:1:2::1", "2001:DB8:1:2::ffff"])(
800
+ "groups the IPv6 address %s by its /64",
801
+ async (visitorIp) => {
802
+ app = identityApp();
803
+ await app.ready();
804
+ expect(await identityFor(visitorIp)).toEqual({
805
+ identity: "v0,2001:0db8:0001:0002::/64",
806
+ keyIndex: 0,
807
+ });
808
+ },
809
+ );
810
+
811
+ it("keeps a valid IPv4 address as it is", async () => {
812
+ app = identityApp();
813
+ await app.ready();
814
+ expect(await identityFor(" 203.0.113.10 ")).toEqual({
815
+ identity: "v0,203.0.113.10",
816
+ keyIndex: 0,
817
+ });
818
+ });
819
+
820
+ it("ignores the key on a method other than POST", async () => {
821
+ const trusted = digests();
822
+ app = Fastify({ trustProxy: true });
823
+ app.get(LEADS_SUBMIT, async (req) => resolveRateLimitIdentity(req, trusted));
824
+ await app.ready();
825
+ const res = await app.inject({ method: "GET", url: LEADS_SUBMIT, headers: keyed("203.0.113.1") });
826
+ expect(res.json()).toEqual({ identity: SPOKE_HOST, keyIndex: null });
827
+ });
828
+ });
829
+
830
+ describe("AC-6 — buildTrustedKeyDigests keeps positions and never logs an entry", () => {
831
+ it("drops a short entry with one warning, keeps empty slots, and digests the real key", () => {
832
+ const warn = jest.fn();
833
+ const slots = buildTrustedKeyDigests(`short, ,${KEY}`, warn);
834
+ expect(slots).toHaveLength(3);
835
+ expect(slots[0]).toBeNull();
836
+ expect(slots[1]).toBeNull();
837
+ expect(Buffer.isBuffer(slots[2])).toBe(true);
838
+ expect(warn).toHaveBeenCalledTimes(1);
839
+ const message = String(warn.mock.calls[0]?.[0]);
840
+ expect(message).toContain("AILK_VISITOR_FORWARD_KEYS");
841
+ expect(message).not.toContain("short");
842
+ });
843
+
844
+ it("returns [] and warns nothing when the variable is unset or empty", () => {
845
+ const warn = jest.fn();
846
+ expect(buildTrustedKeyDigests(undefined, warn)).toEqual([]);
847
+ expect(buildTrustedKeyDigests("", warn)).toEqual([]);
848
+ expect(warn).not.toHaveBeenCalled();
849
+ });
850
+
851
+ it("counts a request presenting the short entry under req.ip", async () => {
852
+ app = forwardApp({
853
+ scope: "leads",
854
+ perIpPerMinute: 1,
855
+ trustedKeyDigests: buildTrustedKeyDigests(`short, ,${KEY}`, noWarn),
856
+ });
857
+ await app.ready();
858
+ expect(await post(keyed("203.0.113.1", "short"))).toBe(200);
859
+ expect(await post(keyed("203.0.113.2", "short"))).toBe(429);
860
+ });
861
+
862
+ it("gives the third entry's visitors a v2, identity and their own buckets", async () => {
863
+ const trusted = buildTrustedKeyDigests(`short, ,${KEY}`, noWarn);
864
+ app = Fastify({ trustProxy: true });
865
+ app.register(async (child) => {
866
+ child.register(rateLimitMiddleware, {
867
+ scope: "leads",
868
+ perIpPerMinute: 10,
869
+ trustedKeyDigests: trusted,
870
+ });
871
+ child.post(LEADS_SUBMIT, async (req) => resolveRateLimitIdentity(req, trusted));
872
+ });
873
+ await app.ready();
874
+ const first = await app.inject({ method: "POST", url: LEADS_SUBMIT, headers: keyed("203.0.113.1") });
875
+ expect((first.json() as { identity: string }).identity).toBe("v2,203.0.113.1");
876
+ for (let i = 0; i < 9; i++) expect(await post(keyed("203.0.113.1"))).toBe(200);
877
+ expect(await post(keyed("203.0.113.1"))).toBe(429);
878
+ expect(await post(keyed("203.0.113.2"))).toBe(200);
879
+ });
880
+ });
881
+
882
+ describe("AC-10 — the 429 log line names the bucket and key position, never the key", () => {
883
+ it("logs the namespaced identity, bucket and keyIndex for a visitor-bucket 429", async () => {
884
+ app = forwardApp({
885
+ scope: "leads",
886
+ perIpPerMinute: 10,
887
+ perKeyPerMinute: 300,
888
+ trustedKeyDigests: digests(),
889
+ });
890
+ await app.ready();
891
+ for (let i = 0; i < 10; i++) await post(keyed("203.0.113.1"));
892
+ expect(await post(keyed("203.0.113.1"))).toBe(429);
893
+
894
+ const [line] = rateLimitedLines();
895
+ expect(line?.["ip"]).toBe("v0,203.0.113.1");
896
+ expect(line?.["bucket"]).toBe("visitor");
897
+ expect(line?.["keyIndex"]).toBe(0);
898
+ const text = JSON.stringify(logLines);
899
+ expect(text).not.toContain(KEY);
900
+ expect(text).not.toContain("forward-key");
901
+ });
902
+ });
903
+
904
+ describe("AC-11 — each key has its own ceiling", () => {
905
+ const options = (): RateLimitOptions => ({
906
+ scope: "leads",
907
+ perIpPerMinute: 10,
908
+ perKeyPerMinute: 5,
909
+ trustedKeyDigests: digests(),
910
+ });
911
+
912
+ it("refuses the sixth keyed request across distinct visitors, with bucket: key", async () => {
913
+ app = forwardApp(options());
914
+ await app.ready();
915
+ for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
916
+ const sixth = await app.inject({
917
+ method: "POST",
918
+ url: LEADS_SUBMIT,
919
+ headers: keyed("203.0.113.6"),
920
+ });
921
+ expect(sixth.statusCode).toBe(429);
922
+ const retryAfter = Number(sixth.headers["retry-after"]);
923
+ expect(retryAfter).toBeGreaterThanOrEqual(1);
924
+ expect(retryAfter).toBeLessThanOrEqual(60);
925
+ const [line] = rateLimitedLines();
926
+ expect(line?.["bucket"]).toBe("key");
927
+ expect(line?.["keyIndex"]).toBe(0);
928
+ expect(line?.["ip"]).toBe("v0,203.0.113.6");
929
+
930
+ // A request with no key, from a seventh visitor, is unaffected.
931
+ expect(await post({ "x-forwarded-for": "203.0.113.7" })).toBe(200);
932
+ });
933
+
934
+ it("reads AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE over the option", async () => {
935
+ process.env.AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE = "7";
936
+ app = forwardApp(options());
937
+ await app.ready();
938
+ for (let i = 1; i <= 7; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
939
+ expect(await post(keyed("203.0.113.8"))).toBe(429);
940
+ });
941
+
942
+ it("does not read the per-visitor variable for the per-key ceiling", async () => {
943
+ process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = "50";
944
+ app = forwardApp(options());
945
+ await app.ready();
946
+ for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
947
+ expect(await post(keyed("203.0.113.6"))).toBe(429);
948
+ });
949
+
950
+ it("visitor churn past maxBuckets cannot evict the key bucket and reset its ceiling", async () => {
951
+ app = forwardApp({ ...options(), maxBuckets: 3 });
952
+ await app.ready();
953
+ for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
954
+ // Each refused request still adds a visitor bucket; with maxBuckets 3
955
+ // the oldest entries are evicted over and over.
956
+ for (let i = 6; i <= 20; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(429);
957
+ });
958
+
959
+ it("a key over its ceiling opens no new visitor bucket, so it cannot evict another scope's counter (security review R-1)", async () => {
960
+ app = Fastify({ trustProxy: true });
961
+ app.register(async (child) => {
962
+ child.register(rateLimitMiddleware, { perIpPerMinute: 1, maxBuckets: 10 });
963
+ child.post("/v1/auth/email", async () => ({ ok: true }));
964
+ });
965
+ app.register(async (child) => {
966
+ child.register(rateLimitMiddleware, { ...options(), maxBuckets: 10 });
967
+ child.post(LEADS_SUBMIT, async () => ({ ok: true }));
968
+ });
969
+ await app.ready();
970
+ const victim = { "x-forwarded-for": "198.51.100.99" };
971
+ expect(await post(victim, "/v1/auth/email")).toBe(200);
972
+ expect(await post(victim, "/v1/auth/email")).toBe(429);
973
+
974
+ for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
975
+ for (let i = 6; i <= 60; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(429);
976
+
977
+ // The victim's exhausted counter survived the keyed churn.
978
+ expect(await post(victim, "/v1/auth/email")).toBe(429);
979
+ });
980
+
981
+ it("a key over its ceiling still counts a visitor whose bucket is already open", async () => {
982
+ app = forwardApp({ ...options(), perIpPerMinute: 2 });
983
+ await app.ready();
984
+ expect(await post(keyed("203.0.113.1"))).toBe(200);
985
+ for (let i = 2; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
986
+ // Key now at its ceiling of 5; visitor 1 has an open bucket at count 1.
987
+ expect(await post(keyed("203.0.113.1"))).toBe(429);
988
+ const [line] = rateLimitedLines();
989
+ expect(line?.["bucket"]).toBe("key");
990
+ // Visitor 1's own bucket was counted (now 2), so a third request trips
991
+ // the visitor bucket even once the key bucket is ignored.
992
+ expect(await post(keyed("203.0.113.1"))).toBe(429);
993
+ expect(rateLimitedLines()[1]?.["bucket"]).toBe("visitor");
994
+ });
995
+
996
+ it("defaults the per-key ceiling to 300 when no option is passed", async () => {
997
+ app = forwardApp({ scope: "leads", perIpPerMinute: 10, trustedKeyDigests: digests() });
998
+ await app.ready();
999
+ const codes: number[] = [];
1000
+ for (let i = 0; i < 301; i++) {
1001
+ codes.push(await post(keyed(`10.${Math.floor(i / 250)}.${i % 250}.1`)));
1002
+ }
1003
+ expect(codes.slice(0, 300).every((code) => code === 200)).toBe(true);
1004
+ expect(codes[300]).toBe(429);
1005
+ });
1006
+ });
1007
+
1008
+ describe("AC-12 — no unkeyed req.ip can reach a keyed bucket", () => {
1009
+ const options = (): RateLimitOptions => ({
1010
+ scope: "leads",
1011
+ perIpPerMinute: 10,
1012
+ perKeyPerMinute: 5,
1013
+ trustedKeyDigests: digests(),
1014
+ });
1015
+
1016
+ it("unkeyed traffic shaped like a keyed bucket key adds nothing to the per-key bucket", async () => {
1017
+ app = forwardApp(options());
1018
+ await app.ready();
1019
+ for (const forwarded of ["key:0", "key", "v0:203.0.113.1", "v0"]) {
1020
+ for (let i = 0; i < 6; i++) await post({ "x-forwarded-for": forwarded });
1021
+ }
1022
+ for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
1023
+ expect(await post(keyed("203.0.113.6"))).toBe(429);
1024
+ });
1025
+
1026
+ it("ten unkeyed requests from v0:203.0.113.1 do not stop that keyed visitor", async () => {
1027
+ app = forwardApp(options());
1028
+ await app.ready();
1029
+ for (let i = 0; i < 10; i++) await post({ "x-forwarded-for": "v0:203.0.113.1" });
1030
+ expect(await post(keyed("203.0.113.1"))).toBe(200);
1031
+ });
1032
+ });
1033
+
1034
+ describe("registration guards", () => {
1035
+ it.each(["Leads", "1leads", "le-ads", ""])("rejects the scope name %j", async (scope) => {
1036
+ app = forwardApp({ scope });
1037
+ await expect(app.ready()).rejects.toThrow(/scope/);
1038
+ });
1039
+
1040
+ it("rejects trustedKeyDigests without a scope", async () => {
1041
+ app = forwardApp({ trustedKeyDigests: digests() });
1042
+ await expect(app.ready()).rejects.toThrow(/scope/);
1043
+ });
1044
+
1045
+ it("lets a caller-supplied ipFromRequest win over the forwarded identity", async () => {
1046
+ app = forwardApp({
1047
+ scope: "leads",
1048
+ perIpPerMinute: 1,
1049
+ trustedKeyDigests: digests(),
1050
+ ipFromRequest: () => "fixed",
1051
+ });
1052
+ await app.ready();
1053
+ expect(await post(keyed("203.0.113.1"))).toBe(200);
1054
+ expect(await post(keyed("203.0.113.2"))).toBe(429);
1055
+ });
1056
+ });
1057
+ });