create-ailk 0.13.0 → 0.15.0

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 (188) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +250 -36
  3. package/component-catalog.md +10 -6
  4. package/dist/add-module.d.ts +7 -0
  5. package/dist/add-module.js +34 -2
  6. package/dist/add-route.js +18 -2
  7. package/dist/apply-design.d.ts +32 -2
  8. package/dist/apply-design.js +155 -22
  9. package/dist/cli.js +24 -3
  10. package/dist/derive-architecture.d.ts +15 -2
  11. package/dist/derive-architecture.js +34 -6
  12. package/dist/design-compile.d.ts +70 -2
  13. package/dist/design-compile.js +97 -1
  14. package/dist/design-home.d.ts +74 -0
  15. package/dist/design-home.js +256 -0
  16. package/dist/design-package.d.ts +39 -2
  17. package/dist/design-package.js +224 -9
  18. package/dist/design-presets.d.ts +17 -1
  19. package/dist/design-presets.js +17 -5
  20. package/dist/design-scaffold.d.ts +6 -25
  21. package/dist/design-scaffold.js +26 -74
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1 -1
  24. package/dist/lib/chrome-readers.d.ts +21 -0
  25. package/dist/lib/chrome-readers.js +100 -0
  26. package/dist/lib/design-checks.d.ts +12 -1
  27. package/dist/lib/design-checks.js +38 -2
  28. package/dist/lib/extract-module-bundle.d.ts +11 -0
  29. package/dist/lib/extract-module-bundle.js +36 -6
  30. package/dist/lib/license-key-files.d.ts +6 -1
  31. package/dist/lib/license-key-files.js +9 -4
  32. package/dist/lib/module-license-gate.d.ts +9 -0
  33. package/dist/lib/module-license-gate.js +18 -0
  34. package/dist/module-architecture.d.ts +9 -0
  35. package/dist/module-architecture.js +6 -0
  36. package/dist/parse-args.d.ts +22 -0
  37. package/dist/parse-args.js +73 -0
  38. package/dist/programmatic.d.ts +20 -0
  39. package/dist/programmatic.js +180 -35
  40. package/dist/site-chrome.d.ts +42 -0
  41. package/dist/site-chrome.js +140 -2
  42. package/dist/site-fonts.d.ts +21 -0
  43. package/dist/site-fonts.js +54 -0
  44. package/dist/site-manifest.d.ts +12 -5
  45. package/dist/site-manifest.js +25 -11
  46. package/dist/site-roster.d.ts +16 -0
  47. package/dist/site-roster.js +81 -0
  48. package/dist/starter-grid.js +15 -0
  49. package/dist/surfaces.js +12 -0
  50. package/dist/sync-routes.js +11 -2
  51. package/dist/team-profiles.d.ts +29 -0
  52. package/dist/team-profiles.js +43 -0
  53. package/dist/topology.d.ts +131 -0
  54. package/dist/topology.js +533 -0
  55. package/package.json +3 -3
  56. package/release-scope.json +1 -1
  57. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  58. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  59. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  60. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  61. package/site-templates/designs/minimalism/DESIGN.md +166 -36
  62. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  63. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  64. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  65. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  68. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  70. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  71. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  72. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  73. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  74. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  75. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  76. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  77. package/templates/README.md +2 -0
  78. package/templates/apps/api/CLAUDE.md +6 -1
  79. package/templates/apps/api/package.json +1 -0
  80. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  81. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  82. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  83. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  84. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  85. package/templates/apps/api/src/lib/site.ts +37 -0
  86. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  87. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  88. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  89. package/templates/apps/api/src/middleware/auth.ts +3 -0
  90. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  91. package/templates/apps/api/src/openapi/spec.ts +38 -2
  92. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  93. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  94. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  95. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  96. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  97. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  98. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  99. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  100. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  101. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  102. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  103. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  104. package/templates/apps/mcp/package.json +1 -1
  105. package/templates/apps/mcp/src/index.ts +6 -4
  106. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  107. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  108. package/templates/apps/web/CLAUDE.md +12 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  112. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  113. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  115. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  116. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  117. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  118. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
  119. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
  120. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  121. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  122. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  123. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  124. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  125. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  126. package/templates/apps/web/app/[locale]/layout.tsx +212 -57
  127. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  128. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  129. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  132. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  134. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  135. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  137. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  138. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  139. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  141. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  142. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  143. package/templates/apps/web/app/not-found.tsx +47 -0
  144. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  145. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  146. package/templates/apps/web/instrumentation.ts +7 -4
  147. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  148. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  149. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  150. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  151. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  152. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  153. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  154. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  155. package/templates/apps/web/lib/relay.ts +206 -2
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +15 -0
  158. package/templates/apps/web/lib/site-fonts.ts +15 -0
  159. package/templates/apps/web/lib/site-logo.tsx +39 -1
  160. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  161. package/templates/apps/web/lib/site-theme.ts +13 -1
  162. package/templates/apps/web/lib/topology.generated.ts +23 -0
  163. package/templates/apps/web/messages/en.json +7 -1
  164. package/templates/apps/web/package.json +1 -1
  165. package/templates/database/CHANGELOG.md +32 -0
  166. package/templates/database/__tests__/agents.db.test.ts +65 -0
  167. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  168. package/templates/database/auth/schema.prisma +23 -0
  169. package/templates/database/inbox/schema.prisma +13 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/package.json +1 -1
  175. package/versions.json +35 -34
  176. package/site-templates/designs/minimalism/sample/README.md +0 -48
  177. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  178. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  179. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  180. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  181. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  182. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  183. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  184. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  185. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  186. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  187. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  188. package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
@@ -11,6 +11,7 @@
11
11
  // Spec: docs/specs/7644-the-browser-talks-only-to-spec.md §5.1 AC-1 to AC-4.
12
12
 
13
13
  import { handleRelay, RELAY_ROUTES } from "../relay";
14
+ import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
14
15
 
15
16
  const HUB = "https://hub.example";
16
17
  const SITE = "https://spoke.example";
@@ -124,6 +125,7 @@ function entryRequest(
124
125
  let errorSpy: jest.SpyInstance;
125
126
 
126
127
  beforeEach(() => {
128
+ __resetRelayRateLimit();
127
129
  fetchMock.mockClear();
128
130
  fetchMock.mockImplementation(async () => hubAnswer());
129
131
  global.fetch = fetchMock as jest.Mock;
@@ -513,3 +515,291 @@ describe("hardening — what the site serves, and a missing hub address", () =>
513
515
  });
514
516
  });
515
517
  });
518
+
519
+ // The relay's own per-visitor rate limit (#7707, spec
520
+ // docs/specs/7707-relay-rate-limit-spec.md §5.1 AC-1 to AC-9).
521
+ describe("the per-visitor rate limit (#7707)", () => {
522
+ const VISITOR = { "x-forwarded-for": "203.0.113.7" };
523
+
524
+ /** Spends the first visitor's 8-call visitor budget, then clears fetchMock. */
525
+ async function spendVisitorBudget(
526
+ headers: Record<string, string | null> = VISITOR,
527
+ ): Promise<void> {
528
+ for (let i = 0; i < 8; i++) {
529
+ await handleRelay(relayRequest("/v1/leads/submit", { headers }));
530
+ }
531
+ fetchMock.mockClear();
532
+ }
533
+
534
+ describe("AC-1 — 8 forwarded, the 9th refused, on each visitor route", () => {
535
+ it.each(VISITOR_ENTRIES)(
536
+ "$method $path: first 8 calls forwarded, the 9th refused",
537
+ async (entry) => {
538
+ for (let i = 0; i < 8; i++) {
539
+ const res = await handleRelay(entryRequest(entry, VISITOR));
540
+ expect(res.status).not.toBe(429);
541
+ }
542
+ fetchMock.mockClear();
543
+
544
+ const res = await handleRelay(entryRequest(entry, VISITOR));
545
+ expect(res.status).toBe(429);
546
+ expect(await errorCode(res)).toBe("rate_limited");
547
+ expect(res.headers.get("retry-after")).toBe("600");
548
+ expect(res.headers.get("cache-control")).toBe("no-store");
549
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
550
+ expect(fetchMock).not.toHaveBeenCalled();
551
+ },
552
+ );
553
+ });
554
+
555
+ describe("AC-2 — the budget is shared across the five visitor routes", () => {
556
+ it("2 calls each to submit/subscribe/callback/availability, then book is refused", async () => {
557
+ const shared = VISITOR_ENTRIES.slice(0, 4);
558
+ for (const entry of shared) {
559
+ for (let i = 0; i < 2; i++) {
560
+ const res = await handleRelay(entryRequest(entry, VISITOR));
561
+ expect(res.status).not.toBe(429);
562
+ }
563
+ }
564
+ const book = VISITOR_ENTRIES[4];
565
+ if (!book) throw new Error("expected a fifth visitor entry");
566
+ const res = await handleRelay(entryRequest(book, VISITOR));
567
+ expect(res.status).toBe(429);
568
+ });
569
+ });
570
+
571
+ describe("AC-3 — the budget applies with and without AILK_VISITOR_FORWARD_KEY", () => {
572
+ const FORWARD_KEY_32 = "f".repeat(32);
573
+
574
+ it.each([
575
+ ["unset", undefined],
576
+ ["set", FORWARD_KEY_32],
577
+ ])("forward key %s: still 8 forwarded then refused", async (_label, key) => {
578
+ if (key) process.env.AILK_VISITOR_FORWARD_KEY = key;
579
+ else delete process.env.AILK_VISITOR_FORWARD_KEY;
580
+
581
+ await spendVisitorBudget();
582
+ const res = await handleRelay(relayRequest("/v1/leads/submit", { headers: VISITOR }));
583
+ expect(res.status).toBe(429);
584
+ expect(fetchMock).not.toHaveBeenCalled();
585
+ });
586
+ });
587
+
588
+ describe("AC-4 — identity", () => {
589
+ it("a different visitor IP is forwarded", async () => {
590
+ await spendVisitorBudget();
591
+ const res = await handleRelay(
592
+ relayRequest("/v1/leads/submit", {
593
+ headers: { "x-forwarded-for": "198.51.100.9" },
594
+ }),
595
+ );
596
+ expect(res.status).not.toBe(429);
597
+ });
598
+
599
+ it("the first value of a multi-hop x-forwarded-for is the identity", async () => {
600
+ await spendVisitorBudget();
601
+ const res = await handleRelay(
602
+ relayRequest("/v1/leads/submit", {
603
+ headers: { "x-forwarded-for": "203.0.113.7, 10.0.0.1" },
604
+ }),
605
+ );
606
+ expect(res.status).toBe(429);
607
+ });
608
+
609
+ it("an IPv4-mapped IPv6 address maps to its IPv4 identity", async () => {
610
+ await spendVisitorBudget();
611
+ const res = await handleRelay(
612
+ relayRequest("/v1/leads/submit", {
613
+ headers: { "x-forwarded-for": "::ffff:203.0.113.7" },
614
+ }),
615
+ );
616
+ expect(res.status).toBe(429);
617
+ });
618
+
619
+ it("x-real-ip alone resolves to the same identity as x-forwarded-for", async () => {
620
+ await spendVisitorBudget();
621
+ const res = await handleRelay(
622
+ relayRequest("/v1/leads/submit", {
623
+ headers: { "x-forwarded-for": null, "x-real-ip": "203.0.113.7" },
624
+ }),
625
+ );
626
+ expect(res.status).toBe(429);
627
+ });
628
+
629
+ it("an IPv6 /64 prefix is one identity", async () => {
630
+ await spendVisitorBudget({ "x-forwarded-for": "2001:db8:0:0::1" });
631
+
632
+ const blocked = await handleRelay(
633
+ relayRequest("/v1/leads/submit", {
634
+ headers: { "x-forwarded-for": "2001:db8::2" },
635
+ }),
636
+ );
637
+ expect(blocked.status).toBe(429);
638
+
639
+ const forwarded = await handleRelay(
640
+ relayRequest("/v1/leads/submit", {
641
+ headers: { "x-forwarded-for": "2001:db8:0:1::1" },
642
+ }),
643
+ );
644
+ expect(forwarded.status).not.toBe(429);
645
+ });
646
+
647
+ it("an unparseable address shares the unknown identity with no address at all", async () => {
648
+ await spendVisitorBudget({ "x-forwarded-for": null, "x-real-ip": null });
649
+ const res = await handleRelay(
650
+ relayRequest("/v1/leads/submit", {
651
+ headers: { "x-forwarded-for": "not-an-ip" },
652
+ }),
653
+ );
654
+ expect(res.status).toBe(429);
655
+ });
656
+ });
657
+
658
+ describe("AC-5 — the window resets after windowMs", () => {
659
+ it("refused just before the window ends, forwarded once it does", async () => {
660
+ const T0 = Date.now();
661
+ const nowSpy = jest.spyOn(Date, "now").mockReturnValue(T0);
662
+
663
+ await spendVisitorBudget();
664
+ const stillBlocked = await handleRelay(
665
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
666
+ );
667
+ expect(stillBlocked.status).toBe(429);
668
+
669
+ nowSpy.mockReturnValue(T0 + 599_000);
670
+ const almostThere = await handleRelay(
671
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
672
+ );
673
+ expect(almostThere.status).toBe(429);
674
+ expect(almostThere.headers.get("retry-after")).toBe("1");
675
+
676
+ nowSpy.mockReturnValue(T0 + 600_000);
677
+ const freshWindow = await handleRelay(
678
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
679
+ );
680
+ expect(freshWindow.status).not.toBe(429);
681
+
682
+ nowSpy.mockRestore();
683
+ });
684
+ });
685
+
686
+ describe("AC-6 — refusals before the budget check spend nothing", () => {
687
+ it("403s, 404s and 508s before the budget check don't consume it", async () => {
688
+ for (let i = 0; i < 12; i++) {
689
+ const res = await handleRelay(
690
+ relayRequest("/v1/leads/submit", {
691
+ headers: { ...VISITOR, "sec-fetch-site": "cross-site" },
692
+ }),
693
+ );
694
+ expect(res.status).toBe(403);
695
+ }
696
+ for (let i = 0; i < 12; i++) {
697
+ const res = await handleRelay(
698
+ relayRequest("/v1/workspaces", { method: "POST", headers: VISITOR }),
699
+ );
700
+ expect(res.status).toBe(404);
701
+ }
702
+ for (let i = 0; i < 12; i++) {
703
+ const res = await handleRelay(
704
+ relayRequest("/v1/leads/submit", {
705
+ headers: { ...VISITOR, "x-ailk-relayed": "1" },
706
+ }),
707
+ );
708
+ expect(res.status).toBe(508);
709
+ }
710
+
711
+ fetchMock.mockClear();
712
+ for (let i = 0; i < 8; i++) {
713
+ const res = await handleRelay(
714
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
715
+ );
716
+ expect(res.status).not.toBe(429);
717
+ }
718
+ expect(fetchMock).toHaveBeenCalledTimes(8);
719
+ });
720
+ });
721
+
722
+ describe("AC-7 — the budget check precedes content-type and body-size checks", () => {
723
+ it("a 9th call with a bad content-type gets 429, not 415", async () => {
724
+ await spendVisitorBudget();
725
+ const res = await handleRelay(
726
+ relayRequest("/v1/leads/submit", {
727
+ headers: { ...VISITOR, "content-type": "text/plain" },
728
+ }),
729
+ );
730
+ expect(res.status).toBe(429);
731
+ });
732
+
733
+ it("a 9th call with an oversized body gets 429, not 413", async () => {
734
+ await spendVisitorBudget();
735
+ const res = await handleRelay(
736
+ relayRequest("/v1/leads/submit", {
737
+ headers: VISITOR,
738
+ body: "x".repeat(1_048_577),
739
+ }),
740
+ );
741
+ expect(res.status).toBe(429);
742
+ });
743
+ });
744
+
745
+ describe("AC-8 — the session route has its own, wider, independent budget", () => {
746
+ it("30 forwarded, the 31st refused; the visitor budget is untouched", async () => {
747
+ for (let i = 0; i < 30; i++) {
748
+ const res = await handleRelay(
749
+ relayRequest("/v1/commerce/invoices", { headers: VISITOR }),
750
+ );
751
+ expect(res.status).not.toBe(429);
752
+ }
753
+ fetchMock.mockClear();
754
+
755
+ const refused = await handleRelay(
756
+ relayRequest("/v1/commerce/invoices", { headers: VISITOR }),
757
+ );
758
+ expect(refused.status).toBe(429);
759
+ expect(refused.headers.get("retry-after")).toBe("600");
760
+ expect(fetchMock).not.toHaveBeenCalled();
761
+
762
+ const stillVisitor = await handleRelay(
763
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
764
+ );
765
+ expect(stillVisitor.status).not.toBe(429);
766
+ });
767
+
768
+ it("a fresh visitor's visitor budget doesn't touch the session budget", async () => {
769
+ const OTHER = { "x-forwarded-for": "198.51.100.42" };
770
+ for (let i = 0; i < 8; i++) {
771
+ await handleRelay(relayRequest("/v1/leads/submit", { headers: OTHER }));
772
+ }
773
+ const res = await handleRelay(
774
+ relayRequest("/v1/commerce/invoices", { headers: OTHER }),
775
+ );
776
+ expect(res.status).not.toBe(429);
777
+ });
778
+ });
779
+
780
+ describe("AC-9 — the store is capped and evicts the oldest entry first", () => {
781
+ it(
782
+ "the first visitor's bucket is evicted at capacity, so its next call opens a fresh window",
783
+ async () => {
784
+ await spendVisitorBudget();
785
+ for (let i = 0; i < 10_000; i++) {
786
+ const ip = `10.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}`;
787
+ await handleRelay(
788
+ relayRequest("/v1/leads/submit", {
789
+ headers: { "x-forwarded-for": ip },
790
+ }),
791
+ );
792
+ }
793
+ expect(__relayRateLimitBucketCount()).toBe(10_000);
794
+
795
+ fetchMock.mockClear();
796
+ const res = await handleRelay(
797
+ relayRequest("/v1/leads/submit", { headers: VISITOR }),
798
+ );
799
+ expect(res.status).not.toBe(429);
800
+ expect(fetchMock).toHaveBeenCalledTimes(1);
801
+ },
802
+ 30_000,
803
+ );
804
+ });
805
+ });
@@ -118,6 +118,58 @@ describe("SITE_NAME / SITE_BASE_URL (#4157)", () => {
118
118
  });
119
119
  });
120
120
 
121
+ /**
122
+ * `COPYRIGHT_HOLDER` — the footer's holder (#7796): `siteIdentity.legalName`
123
+ * when declared, else the site name, exactly as before.
124
+ */
125
+ describe("COPYRIGHT_HOLDER (#7796)", () => {
126
+ function writeIdentity(identityLines: string): void {
127
+ writeFileSync(
128
+ join(tmpRoot, "_site.mdx"),
129
+ `---\nsiteName: Wendi Henry\nsiteIdentity:\n kind: Person\n name: Wendi Henry\n${identityLines}---\n`,
130
+ );
131
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
132
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
133
+ }
134
+
135
+ it("is siteIdentity.legalName when the site declares one", async () => {
136
+ writeIdentity(" legalName: SalesSmyth LLC\n");
137
+ const { COPYRIGHT_HOLDER, SITE_NAME } = await loadSiteBrand();
138
+
139
+ expect(COPYRIGHT_HOLDER).toBe("SalesSmyth LLC");
140
+ expect(SITE_NAME).toBe("Wendi Henry");
141
+ });
142
+
143
+ it("falls back to SITE_NAME when no legalName is declared", async () => {
144
+ writeIdentity("");
145
+ const { COPYRIGHT_HOLDER, SITE_NAME } = await loadSiteBrand();
146
+
147
+ expect(COPYRIGHT_HOLDER).toBe(SITE_NAME);
148
+ });
149
+
150
+ it("falls back to SITE_NAME when legalName is blank or not a string", async () => {
151
+ writeIdentity(' legalName: " "\n');
152
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Wendi Henry");
153
+
154
+ writeIdentity(" legalName: 42\n");
155
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Wendi Henry");
156
+ });
157
+
158
+ it("follows the NEXT_PUBLIC_SITE_NAME override when no legalName is declared", async () => {
159
+ writeIdentity("");
160
+ process.env["NEXT_PUBLIC_SITE_NAME"] = "Env Override Co";
161
+ expect((await loadSiteBrand()).COPYRIGHT_HOLDER).toBe("Env Override Co");
162
+ });
163
+
164
+ it("falls back to the neutral default when _site.mdx is absent", async () => {
165
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
166
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
167
+ const { COPYRIGHT_HOLDER, FALLBACK_SITE_NAME } = await loadSiteBrand();
168
+
169
+ expect(COPYRIGHT_HOLDER).toBe(FALLBACK_SITE_NAME);
170
+ });
171
+ });
172
+
121
173
  /**
122
174
  * `siteLogo()` — the brand triad's logomark (#6820 D5). No `brand:` renders
123
175
  * exactly today's element; a declared mark is the one rendered, with its dark
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `siteThemeHead` (#7742 D6 / AC-4) — the one helper behind the locale
3
+ * layout's `<style data-ailk="theme-vars">` and `<html>` class and the minimal
4
+ * 404's. Pinned against the expressions the layout computed inline before the
5
+ * extraction, so moving them into the helper changed no byte of the `<style>`
6
+ * text; `layout.test.tsx` and `site-fonts.test.tsx` pin the layout's own
7
+ * emitted text over it.
8
+ */
9
+ import { applyDesignFonts, themeToCssVarsBothModes } from "@working-theory/theme";
10
+
11
+ import type { SiteFonts } from "../site-fonts";
12
+
13
+ const mockSiteFonts: SiteFonts = { className: "", families: {} };
14
+ // eslint-disable-next-line no-restricted-syntax -- #507: `site-fonts.ts` is a generated data module with no external boundary to mock instead; this is the seam the helper reads.
15
+ jest.mock("../site-fonts", () => ({
16
+ __esModule: true,
17
+ siteFonts: mockSiteFonts,
18
+ }));
19
+
20
+ const mockLoadSiteBrandTokens = jest.fn();
21
+ const mockLoadSiteShapeCss = jest.fn();
22
+ // eslint-disable-next-line no-restricted-syntax -- #507: the helper's one seam onto `_site.mdx`; the overlay is the test's to set.
23
+ jest.mock("../site-theme", () => ({
24
+ __esModule: true,
25
+ loadSiteBrandTokens: (...args: unknown[]) => mockLoadSiteBrandTokens(...args),
26
+ loadSiteShapeCss: (...args: unknown[]) => mockLoadSiteShapeCss(...args),
27
+ }));
28
+
29
+ import { siteThemeHead } from "../site-theme-head";
30
+
31
+ /** The locale layout's pre-#7742 inline computation, verbatim. */
32
+ function layoutStyleText(): { css: string; className: string | undefined } {
33
+ const designFonts = applyDesignFonts(mockLoadSiteBrandTokens(), mockSiteFonts);
34
+ return {
35
+ css:
36
+ themeToCssVarsBothModes(designFonts.tokens ? { brand: designFonts.tokens } : {}) +
37
+ mockLoadSiteShapeCss(),
38
+ className: designFonts.className || undefined,
39
+ };
40
+ }
41
+
42
+ beforeEach(() => {
43
+ Object.assign(mockSiteFonts, { className: "", families: {} });
44
+ mockLoadSiteBrandTokens.mockReset().mockReturnValue(undefined);
45
+ mockLoadSiteShapeCss.mockReset().mockReturnValue("");
46
+ });
47
+
48
+ describe("siteThemeHead (#7742 D6)", () => {
49
+ it("no theme and no faces: today's CSS and no class", () => {
50
+ const head = siteThemeHead();
51
+ expect(head.css).toBe(themeToCssVarsBothModes({}));
52
+ expect(head.css).toBe(layoutStyleText().css);
53
+ expect(head.fontClassName).toBeUndefined();
54
+ });
55
+
56
+ it("a brand overlay, shape CSS and design faces: the layout's exact text and class", () => {
57
+ mockLoadSiteBrandTokens.mockReturnValue({ font: { body: "Brand" } });
58
+ mockLoadSiteShapeCss.mockReturnValue(":root{--radius-control:0}");
59
+ Object.assign(mockSiteFonts, {
60
+ className: "f1 f2",
61
+ families: { display: "var(--font-design-display), Georgia, serif" },
62
+ });
63
+ const head = siteThemeHead();
64
+ const expected = layoutStyleText();
65
+ expect(head.css).toBe(expected.css);
66
+ expect(head.css).toContain("--font-body: Brand;");
67
+ expect(head.css.endsWith(":root{--radius-control:0}")).toBe(true);
68
+ expect(head.fontClassName).toBe(expected.className);
69
+ });
70
+ });
@@ -29,9 +29,11 @@ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
29
29
  import { PartialColorPaletteSchema, SiteThemeSchema } from "@working-theory/site-config/schema";
30
30
  import {
31
31
  siteColorTokenMap,
32
+ siteThemeScopedDarkCss,
32
33
  siteThemeShapeCss,
33
34
  siteThemeToBrandTokens,
34
35
  themeToCssVarsBothModes,
36
+ themeToScopedDarkCss,
35
37
  } from "@working-theory/theme";
36
38
 
37
39
  import { loadSiteBrandTokens, loadSiteShapeCss } from "../site-theme";
@@ -268,8 +270,11 @@ describe("loadSiteBrandTokens — the brand triad", () => {
268
270
 
269
271
  const theme = SiteThemeSchema.parse(parsed);
270
272
  const before = siteThemeToBrandTokens(theme);
273
+ // Mirrors loadSiteShapeCss: a `darkBands: site` theme (minimalism's, since #7739) also scopes
274
+ // its dark ink; without it `scoped` is "" and the golden is the pre-triad CSS unchanged.
275
+ const scoped = theme.darkBands === "site" && before ? siteThemeScopedDarkCss(theme, before) : "";
271
276
  expect(layoutCss()).toBe(
272
- themeToCssVarsBothModes(before ? { brand: before } : {}) + siteThemeShapeCss(theme),
277
+ themeToCssVarsBothModes(before ? { brand: before } : {}) + siteThemeShapeCss(theme) + scoped,
273
278
  );
274
279
  });
275
280
 
@@ -318,3 +323,61 @@ describe("the two vocabularies agree on which slots exist", () => {
318
323
  );
319
324
  });
320
325
  });
326
+
327
+ // #7719 D6 — `darkBands: site` paints dark-scoped bands with the site's dark colours.
328
+ describe("loadSiteShapeCss — darkBands", () => {
329
+ const originalRoot = process.env.AILK_CONTENT_ROOT;
330
+ const tempRoots: string[] = [];
331
+
332
+ afterEach(() => {
333
+ if (originalRoot === undefined) delete process.env.AILK_CONTENT_ROOT;
334
+ else process.env.AILK_CONTENT_ROOT = originalRoot;
335
+ for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
336
+ });
337
+
338
+ function useSiteMdx(frontmatter: string): void {
339
+ const root = mkdtempSync(join(tmpdir(), "ailk-site-dark-bands-"));
340
+ tempRoots.push(root);
341
+ writeFileSync(join(root, "_site.mdx"), `---\nsiteName: Test\n${frontmatter}---\n`, "utf8");
342
+ process.env.AILK_CONTENT_ROOT = root;
343
+ }
344
+
345
+ const THEME = [
346
+ "theme:",
347
+ " corners: square",
348
+ " colors:",
349
+ " dark:",
350
+ ' bg: "#1c1917"',
351
+ ' text: "#fafaf9"',
352
+ "",
353
+ ].join("\n");
354
+
355
+ it("without darkBands, the shape CSS is today's", () => {
356
+ useSiteMdx(THEME);
357
+ const theme = SiteThemeSchema.parse({ corners: "square", colors: { dark: { bg: "#1c1917", text: "#fafaf9" } } });
358
+ expect(loadSiteShapeCss()).toBe(siteThemeShapeCss(theme));
359
+ });
360
+
361
+ it("with darkBands: site, ends with the scoped dark rule carrying the site's dark --color-bg", () => {
362
+ useSiteMdx(`${THEME} darkBands: site\n`);
363
+ const css = loadSiteShapeCss();
364
+ const brand = loadSiteBrandTokens();
365
+ expect(brand).toBeDefined();
366
+ const scoped = themeToScopedDarkCss({ brand: brand! });
367
+ expect(css.endsWith(scoped)).toBe(true);
368
+ expect(scoped).toContain("--color-bg: #1c1917;");
369
+ expect(css.startsWith(siteThemeShapeCss(SiteThemeSchema.parse({ corners: "square" })))).toBe(true);
370
+ });
371
+
372
+ it("with darkBands: site, the scoped rule leaves square corners to inherit from :root", () => {
373
+ useSiteMdx(`${THEME} darkBands: site\n`);
374
+ const scoped = loadSiteShapeCss().slice(loadSiteShapeCss().indexOf(":not(:root)"));
375
+ expect(scoped).not.toMatch(/--radius-/);
376
+ expect(scoped).not.toMatch(/--font-/);
377
+ });
378
+
379
+ it("darkBands: platform adds nothing", () => {
380
+ useSiteMdx(`${THEME} darkBands: platform\n`);
381
+ expect(loadSiteShapeCss()).not.toContain(':not(:root)');
382
+ });
383
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Whether a page ends on the closing footer (#7797).
3
+ *
4
+ * The `closingFooterPaths` chrome knob lists route paths as the site writes
5
+ * them ("/about"). The visitor's pathname arrives as the middleware forwards it, so
6
+ * it may carry a locale prefix ("/es/about") or a trailing slash ("/about/").
7
+ * Both sides are reduced to the same form before they are compared, so the
8
+ * list names a page once however a request spells it.
9
+ *
10
+ * No Next.js import — pure functions, asserted directly by their own suite,
11
+ * in the `profile-back-link.ts` idiom.
12
+ */
13
+
14
+ /** `path` without its query or fragment, any enabled-locale prefix, or trailing slashes; "/" for the root. */
15
+ export function normalizeRoutePath(
16
+ path: string,
17
+ locales: readonly string[],
18
+ ): string {
19
+ const bare = path.split(/[?#]/)[0] ?? "";
20
+ // Compare the first segment to the locale list rather than building a
21
+ // pattern from it, so a locale code is never read as regex syntax.
22
+ const segments = bare.split("/");
23
+ const first = segments[1];
24
+ const unprefixed =
25
+ first !== undefined && locales.includes(first)
26
+ ? ["", ...segments.slice(2)].join("/")
27
+ : bare;
28
+ const trimmed = unprefixed.replace(/\/+$/, "");
29
+ return trimmed === "" ? "/" : trimmed;
30
+ }
31
+
32
+ /**
33
+ * True when `pathname` is one of `paths` and is not the home.
34
+ *
35
+ * Fail-safe direction: an absent or empty list, or an absent pathname, answers
36
+ * `false`, so a site that sets no list renders exactly as before. The home is
37
+ * never an inner page, so listing "/" has no effect — the home's footer is its
38
+ * own knobs' (`home`, `chromeStyle`).
39
+ */
40
+ export function isClosingFooterPath(input: {
41
+ pathname: string | null | undefined;
42
+ paths: readonly string[] | undefined;
43
+ locales: readonly string[];
44
+ }): boolean {
45
+ const { pathname, paths, locales } = input;
46
+ if (!pathname || !paths || paths.length === 0) return false;
47
+ const current = normalizeRoutePath(pathname, locales);
48
+ if (current === "/") return false;
49
+ return paths.some((path) => normalizeRoutePath(path, locales) === current);
50
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The back arrow's href on a team profile page (#7744 D7).
3
+ *
4
+ * On a deck home, a profile at `/team/<slug>` returns the visitor to the
5
+ * home's people slide — `#slide-people`, the anchor #7740 gives the home
6
+ * section whose id is `people` — rather than to the top of the home
7
+ * (checklist D3). Every other inner page, the `/team` index included, and
8
+ * every page on a non-deck home goes home.
9
+ *
10
+ * No Next.js import — pure functions, asserted directly by their own suite,
11
+ * in the `auth-routes.ts` idiom.
12
+ */
13
+
14
+ /** The people slide's anchor: #7740's `slide-` prefix plus the section id `people`. */
15
+ export const PEOPLE_SLIDE_ANCHOR = "slide-people";
16
+
17
+ /**
18
+ * Whether `pathname` is a team profile: `/team/<slug>`, bare or under an
19
+ * enabled-locale prefix. Not `/team` (the index) and not a deeper path.
20
+ *
21
+ * Fail-safe direction: an absent or empty pathname answers `false`, so a
22
+ * missing `x-pathname` header leaves the back arrow pointing home.
23
+ */
24
+ export function isTeamProfilePath(
25
+ pathname: string | null | undefined,
26
+ locales: readonly string[],
27
+ ): boolean {
28
+ if (!pathname) return false;
29
+ const path = pathname.split(/[?#]/)[0] ?? "";
30
+ const bare =
31
+ locales.length > 0
32
+ ? path.replace(new RegExp(`^/(${locales.join("|")})(?=/|$)`), "")
33
+ : path;
34
+ const segments = bare.split("/").slice(1);
35
+ return (
36
+ segments.length === 2 && segments[0] === "team" && segments[1] !== ""
37
+ );
38
+ }
39
+
40
+ /** `${homePath}#slide-people` when the home is a deck and the path is a profile; otherwise `homePath`. */
41
+ export function backLinkHref(input: {
42
+ pathname: string | null | undefined;
43
+ homePath: string;
44
+ home: string;
45
+ locales: readonly string[];
46
+ }): string {
47
+ const { pathname, homePath, home, locales } = input;
48
+ return home === "deck" && isTeamProfilePath(pathname, locales)
49
+ ? `${homePath}#${PEOPLE_SLIDE_ANCHOR}`
50
+ : homePath;
51
+ }