strapi-plugin-hubspot 0.11.1 → 0.13.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 (35) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +18 -0
  3. package/dist/_chunks/{Forms-DadTbe9Q.mjs → Forms-CHB-sKem.mjs} +218 -121
  4. package/dist/_chunks/{Forms-Dn2W6OS4.js → Forms-lrzuWzoB.js} +217 -120
  5. package/dist/_chunks/{HubspotFormInput-S_Z2t_DN.js → HubspotFormInput-Bb6it0nP.js} +1 -1
  6. package/dist/_chunks/{HubspotFormInput-NO59AtM_.mjs → HubspotFormInput-YI09keTC.mjs} +1 -1
  7. package/dist/_chunks/{HubspotObjectInput-DAo3FAMz.mjs → HubspotObjectInput-CzLKT9oj.mjs} +3 -3
  8. package/dist/_chunks/{HubspotObjectInput-BIgIa_Dm.js → HubspotObjectInput-D3A6xzb3.js} +3 -3
  9. package/dist/_chunks/{HubspotPropertyInput-KX1Bhcsl.mjs → HubspotPropertyInput-DicskLcN.mjs} +3 -3
  10. package/dist/_chunks/{HubspotPropertyInput-B-piLcIy.js → HubspotPropertyInput-Dss3eUlL.js} +3 -3
  11. package/dist/_chunks/{Settings-Dz0PEFOv.mjs → Settings-4CbX3SPK.mjs} +2 -2
  12. package/dist/_chunks/{Settings-DMu_QThB.js → Settings-Ch8tPZAV.js} +2 -2
  13. package/dist/_chunks/{en-CH5LSPkL.mjs → en-D8DJiSxd.mjs} +3 -1
  14. package/dist/_chunks/{en-BDGqW_yS.js → en-PYFibnDc.js} +3 -1
  15. package/dist/_chunks/{fr-65-RaB3w.mjs → fr-BMjixfXn.mjs} +3 -1
  16. package/dist/_chunks/{fr-C82LMqUM.js → fr-yHk4sSEl.js} +3 -1
  17. package/dist/_chunks/{index-B7Ru1fu5.js → index-BAvedd2m.js} +6 -6
  18. package/dist/_chunks/{index-DA31TY2n.mjs → index-BE2ribUL.mjs} +6 -6
  19. package/dist/_chunks/{objectLabels-FTh984_h.js → objectLabels-CycoU0zY.js} +1 -1
  20. package/dist/_chunks/{objectLabels-Bl7sO5pb.mjs → objectLabels-DGJxYjKz.mjs} +1 -1
  21. package/dist/_chunks/{useHubspotSchema-C2_PuXEm.js → useHubspotSchema-DRfaI6mF.js} +1 -1
  22. package/dist/_chunks/{useHubspotSchema-BlDilCHc.mjs → useHubspotSchema-e31UsYb2.mjs} +1 -1
  23. package/dist/admin/index.js +1 -1
  24. package/dist/admin/index.mjs +1 -1
  25. package/dist/admin/src/builder/Sortable.d.ts +25 -0
  26. package/dist/admin/src/builder/__tests__/CompanyMapEditor.test.d.ts +1 -0
  27. package/dist/admin/src/builder/__tests__/ConditionEditor.test.d.ts +1 -0
  28. package/dist/admin/src/builder/__tests__/testUtils.d.ts +4 -0
  29. package/dist/server/index.js +101 -42
  30. package/dist/server/index.mjs +101 -42
  31. package/dist/server/src/__tests__/contract.test.d.ts +1 -0
  32. package/dist/server/src/__tests__/rateLimit.test.d.ts +1 -0
  33. package/dist/server/src/index.d.ts +12 -3
  34. package/dist/server/src/rateLimit.d.ts +18 -0
  35. package/package.json +11 -2
@@ -25,7 +25,7 @@ const index = {
25
25
  icon: PaperPlane,
26
26
  intlLabel: { id: `${PLUGIN_ID}.menu.label`, defaultMessage: "HubSpot Forms" },
27
27
  permissions: [{ action: `plugin::${PLUGIN_ID}.forms`, subject: null }],
28
- Component: async () => (await import("./Forms-DadTbe9Q.mjs")).default
28
+ Component: async () => (await import("./Forms-CHB-sKem.mjs")).default
29
29
  });
30
30
  app.customFields.register({
31
31
  name: "property",
@@ -40,7 +40,7 @@ const index = {
40
40
  defaultMessage: "Picked from the portal's real properties"
41
41
  },
42
42
  components: {
43
- Input: async () => import("./HubspotPropertyInput-KX1Bhcsl.mjs")
43
+ Input: async () => import("./HubspotPropertyInput-DicskLcN.mjs")
44
44
  },
45
45
  // Exposed in the Content-Type Builder. Without this, `objectField` could
46
46
  // only be set by hand-editing schema.json — which is not how anyone
@@ -103,7 +103,7 @@ const index = {
103
103
  defaultMessage: "Picked from the portal's configured objects"
104
104
  },
105
105
  components: {
106
- Input: async () => import("./HubspotObjectInput-DAo3FAMz.mjs")
106
+ Input: async () => import("./HubspotObjectInput-CzLKT9oj.mjs")
107
107
  }
108
108
  });
109
109
  app.customFields.register({
@@ -119,7 +119,7 @@ const index = {
119
119
  defaultMessage: "A form built in the HubSpot form builder, stored by slug"
120
120
  },
121
121
  components: {
122
- Input: async () => import("./HubspotFormInput-NO59AtM_.mjs")
122
+ Input: async () => import("./HubspotFormInput-YI09keTC.mjs")
123
123
  }
124
124
  });
125
125
  app.createSettingSection(
@@ -135,7 +135,7 @@ const index = {
135
135
  // Mirrors the RBAC action registered server-side: a role without it
136
136
  // doesn't see the link at all — the routes refuse it anyway.
137
137
  permissions: [{ action: `plugin::${PLUGIN_ID}.settings`, subject: null }],
138
- Component: async () => (await import("./Settings-Dz0PEFOv.mjs")).default
138
+ Component: async () => (await import("./Settings-4CbX3SPK.mjs")).default
139
139
  }
140
140
  ]
141
141
  );
@@ -145,7 +145,7 @@ const index = {
145
145
  return Promise.all(
146
146
  locales.map(async (locale) => {
147
147
  try {
148
- const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-CH5LSPkL.mjs"), "./translations/fr.json": () => import("./fr-65-RaB3w.mjs") }), `./translations/${locale}.json`, 3);
148
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-D8DJiSxd.mjs"), "./translations/fr.json": () => import("./fr-BMjixfXn.mjs") }), `./translations/${locale}.json`, 3);
149
149
  return { data: prefixPluginTranslations(data), locale };
150
150
  } catch {
151
151
  return { data: {}, locale };
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- const index = require("./index-B7Ru1fu5.js");
2
+ const index = require("./index-BAvedd2m.js");
3
3
  const STANDARD = /* @__PURE__ */ new Set([
4
4
  "contact",
5
5
  "company",
@@ -1,4 +1,4 @@
1
- import { g as getTranslation } from "./index-DA31TY2n.mjs";
1
+ import { g as getTranslation } from "./index-BE2ribUL.mjs";
2
2
  const STANDARD = /* @__PURE__ */ new Set([
3
3
  "contact",
4
4
  "company",
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  const React = require("react");
3
3
  const admin = require("@strapi/strapi/admin");
4
- const index = require("./index-B7Ru1fu5.js");
4
+ const index = require("./index-BAvedd2m.js");
5
5
  function _interopNamespace(e) {
6
6
  if (e && e.__esModule) return e;
7
7
  const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { useFetchClient } from "@strapi/strapi/admin";
3
- import { P as PLUGIN_ID } from "./index-DA31TY2n.mjs";
3
+ import { P as PLUGIN_ID } from "./index-BE2ribUL.mjs";
4
4
  let cached = null;
5
5
  let inFlight = null;
6
6
  const listeners = /* @__PURE__ */ new Set();
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-B7Ru1fu5.js");
2
+ const index = require("../_chunks/index-BAvedd2m.js");
3
3
  require("@strapi/icons");
4
4
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-DA31TY2n.mjs";
1
+ import { i } from "../_chunks/index-BE2ribUL.mjs";
2
2
  import "@strapi/icons";
3
3
  export {
4
4
  i as default
@@ -0,0 +1,25 @@
1
+ import * as React from "react";
2
+ import { arrayMove } from "@dnd-kit/sortable";
3
+ export { arrayMove };
4
+ /** The drag-handle props to spread on the grip element. */
5
+ export interface HandleProps {
6
+ attributes: React.HTMLAttributes<HTMLElement>;
7
+ listeners: Record<string, unknown> | undefined;
8
+ }
9
+ /**
10
+ * One vertical sortable list. Each list gets its OWN DndContext: steps sort
11
+ * among steps, a step's fields sort among themselves — no cross-container
12
+ * ambiguity, and the arrow buttons keep covering cross-step moves (they also
13
+ * stay the accessible path).
14
+ */
15
+ export declare function SortableList({ ids, onReorder, children, }: {
16
+ ids: string[];
17
+ /** Called with the reordered ids after a completed drag. */
18
+ onReorder: (ids: string[]) => void;
19
+ children: React.ReactNode;
20
+ }): React.JSX.Element;
21
+ /** A sortable row: wraps the card, hands the grip props to its children. */
22
+ export declare function SortableItem({ id, children, }: {
23
+ id: string;
24
+ children: (handle: HandleProps) => React.ReactNode;
25
+ }): React.JSX.Element;
@@ -0,0 +1,4 @@
1
+ import * as React from "react";
2
+ import { type RenderResult } from "@testing-library/react";
3
+ /** Render under the providers every builder component assumes. */
4
+ export declare function render(ui: React.ReactElement): RenderResult;
@@ -1753,6 +1753,10 @@ function createFormsService(strapi, _opts = {}) {
1753
1753
  });
1754
1754
  }
1755
1755
  async function submit(form, rawValues, meta = {}) {
1756
+ const hp = rawValues.__hp;
1757
+ if (typeof hp === "string" && hp.trim() !== "") {
1758
+ return { ok: true, hubspotSynced: false };
1759
+ }
1756
1760
  const resolution = resolveSubmission(form.definition, rawValues);
1757
1761
  if (resolution.missingRequired.length) {
1758
1762
  return { ok: false, missingRequired: resolution.missingRequired };
@@ -2510,6 +2514,36 @@ function createFormsAdminController(strapi) {
2510
2514
  }
2511
2515
  };
2512
2516
  }
2517
+ function createLimiter({
2518
+ windowMs,
2519
+ max,
2520
+ now = Date.now
2521
+ }) {
2522
+ const hits = /* @__PURE__ */ new Map();
2523
+ let lastSweep = 0;
2524
+ return {
2525
+ allow(key) {
2526
+ if (max <= 0) return true;
2527
+ const at = now();
2528
+ const floor = at - windowMs;
2529
+ if (at - lastSweep >= windowMs) {
2530
+ lastSweep = at;
2531
+ for (const [k, stamps] of hits) {
2532
+ if (!stamps.some((t) => t > floor)) hits.delete(k);
2533
+ }
2534
+ }
2535
+ const recent = (hits.get(key) ?? []).filter((t) => t > floor);
2536
+ if (recent.length >= max) {
2537
+ hits.set(key, recent);
2538
+ return false;
2539
+ }
2540
+ recent.push(at);
2541
+ hits.set(key, recent);
2542
+ return true;
2543
+ },
2544
+ size: () => hits.size
2545
+ };
2546
+ }
2513
2547
  function fieldOrder(definition) {
2514
2548
  if (!definition) return [];
2515
2549
  return definition.steps.flatMap((step) => step.fields.map((field) => field.name));
@@ -2697,7 +2731,12 @@ const config = {
2697
2731
  // - companyFromDomain: a corporate email upserts the Company (deduped by
2698
2732
  // domain) and associates it to the contact;
2699
2733
  // - timelineNote: a note recaps the submission on the contact's timeline.
2700
- forms: { companyFromDomain: true, timelineNote: true }
2734
+ forms: {
2735
+ companyFromDomain: true,
2736
+ timelineNote: true,
2737
+ // Per-IP brakes on the public routes; 0 disables one.
2738
+ rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
2739
+ }
2701
2740
  },
2702
2741
  validator(cfg) {
2703
2742
  if (cfg.validate && !Array.isArray(cfg.validate)) {
@@ -2759,13 +2798,23 @@ const controllers = {
2759
2798
  }
2760
2799
  }),
2761
2800
  formsAdmin: ({ strapi }) => createFormsAdminController(strapi),
2762
- company: () => ({
2763
- /** SIRENE autocomplete for the company field — bounded, cached upstream. */
2764
- async search(ctx) {
2765
- const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
2766
- ctx.body = { companies: await searchCompanies(q) };
2767
- }
2768
- }),
2801
+ company: ({ strapi }) => {
2802
+ const limits = strapi.plugin("hubspot").config("forms", {});
2803
+ const limiter = createLimiter({
2804
+ windowMs: 6e4,
2805
+ max: limits.rateLimit?.searchPerMinute ?? 30
2806
+ });
2807
+ return {
2808
+ /** SIRENE autocomplete for the company field — bounded, cached upstream. */
2809
+ async search(ctx) {
2810
+ if (!limiter.allow(ctx.request.ip ?? "unknown")) {
2811
+ ctx.throw(429, "Too many searches — slow down.");
2812
+ }
2813
+ const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
2814
+ ctx.body = { companies: await searchCompanies(q) };
2815
+ }
2816
+ };
2817
+ },
2769
2818
  submissions: ({ strapi }) => ({
2770
2819
  /** Paged submissions, newest first, optionally narrowed to one form. */
2771
2820
  async list(ctx) {
@@ -2809,41 +2858,51 @@ const controllers = {
2809
2858
  };
2810
2859
  }
2811
2860
  }),
2812
- forms: ({ strapi }) => ({
2813
- /** Published form for the host frontend — CRM mapping stripped. */
2814
- async findOne(ctx) {
2815
- const entry = await strapi.documents(FORM_UID).findFirst({
2816
- filters: { slug: ctx.params.slug },
2817
- status: "published",
2818
- locale: ctx.query.locale
2819
- });
2820
- if (!entry?.definition) ctx.throw(404, "Form not found");
2821
- ctx.body = publicForm(entry);
2822
- },
2823
- async submit(ctx) {
2824
- const entry = await strapi.documents(FORM_UID).findFirst({
2825
- filters: { slug: ctx.params.slug },
2826
- status: "published",
2827
- locale: ctx.query.locale
2828
- });
2829
- if (!entry?.definition) ctx.throw(404, "Form not found");
2830
- const values = sanitizeRawValues(ctx.request.body?.values);
2831
- if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2832
- const rawMeta = ctx.request.body?.meta ?? {};
2833
- const meta = {};
2834
- for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
2835
- const v = rawMeta[key];
2836
- if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2837
- }
2838
- const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2839
- if (!outcome.ok) {
2840
- ctx.status = 422;
2841
- ctx.body = { ok: false, missingRequired: outcome.missingRequired ?? [] };
2842
- return;
2861
+ forms: ({ strapi }) => {
2862
+ const limits = strapi.plugin("hubspot").config("forms", {});
2863
+ const submitLimiter = createLimiter({
2864
+ windowMs: 6e4,
2865
+ max: limits.rateLimit?.submitPerMinute ?? 6
2866
+ });
2867
+ return {
2868
+ /** Published form for the host frontend — CRM mapping stripped. */
2869
+ async findOne(ctx) {
2870
+ const entry = await strapi.documents(FORM_UID).findFirst({
2871
+ filters: { slug: ctx.params.slug },
2872
+ status: "published",
2873
+ locale: ctx.query.locale
2874
+ });
2875
+ if (!entry?.definition) ctx.throw(404, "Form not found");
2876
+ ctx.body = publicForm(entry);
2877
+ },
2878
+ async submit(ctx) {
2879
+ const entry = await strapi.documents(FORM_UID).findFirst({
2880
+ filters: { slug: ctx.params.slug },
2881
+ status: "published",
2882
+ locale: ctx.query.locale
2883
+ });
2884
+ if (!entry?.definition) ctx.throw(404, "Form not found");
2885
+ if (!submitLimiter.allow(ctx.request.ip ?? "unknown")) {
2886
+ ctx.throw(429, "Too many submissions — try again in a minute.");
2887
+ }
2888
+ const values = sanitizeRawValues(ctx.request.body?.values);
2889
+ if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2890
+ const rawMeta = ctx.request.body?.meta ?? {};
2891
+ const meta = {};
2892
+ for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
2893
+ const v = rawMeta[key];
2894
+ if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2895
+ }
2896
+ const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2897
+ if (!outcome.ok) {
2898
+ ctx.status = 422;
2899
+ ctx.body = { ok: false, missingRequired: outcome.missingRequired ?? [] };
2900
+ return;
2901
+ }
2902
+ ctx.body = { ok: true, hubspotSynced: outcome.hubspotSynced };
2843
2903
  }
2844
- ctx.body = { ok: true, hubspotSynced: outcome.hubspotSynced };
2845
- }
2846
- }),
2904
+ };
2905
+ },
2847
2906
  settings: ({ strapi }) => ({
2848
2907
  async get(ctx) {
2849
2908
  ctx.body = await publicSettings(strapi);
@@ -1752,6 +1752,10 @@ function createFormsService(strapi, _opts = {}) {
1752
1752
  });
1753
1753
  }
1754
1754
  async function submit(form, rawValues, meta = {}) {
1755
+ const hp = rawValues.__hp;
1756
+ if (typeof hp === "string" && hp.trim() !== "") {
1757
+ return { ok: true, hubspotSynced: false };
1758
+ }
1755
1759
  const resolution = resolveSubmission(form.definition, rawValues);
1756
1760
  if (resolution.missingRequired.length) {
1757
1761
  return { ok: false, missingRequired: resolution.missingRequired };
@@ -2509,6 +2513,36 @@ function createFormsAdminController(strapi) {
2509
2513
  }
2510
2514
  };
2511
2515
  }
2516
+ function createLimiter({
2517
+ windowMs,
2518
+ max,
2519
+ now = Date.now
2520
+ }) {
2521
+ const hits = /* @__PURE__ */ new Map();
2522
+ let lastSweep = 0;
2523
+ return {
2524
+ allow(key) {
2525
+ if (max <= 0) return true;
2526
+ const at = now();
2527
+ const floor = at - windowMs;
2528
+ if (at - lastSweep >= windowMs) {
2529
+ lastSweep = at;
2530
+ for (const [k, stamps] of hits) {
2531
+ if (!stamps.some((t) => t > floor)) hits.delete(k);
2532
+ }
2533
+ }
2534
+ const recent = (hits.get(key) ?? []).filter((t) => t > floor);
2535
+ if (recent.length >= max) {
2536
+ hits.set(key, recent);
2537
+ return false;
2538
+ }
2539
+ recent.push(at);
2540
+ hits.set(key, recent);
2541
+ return true;
2542
+ },
2543
+ size: () => hits.size
2544
+ };
2545
+ }
2512
2546
  function fieldOrder(definition) {
2513
2547
  if (!definition) return [];
2514
2548
  return definition.steps.flatMap((step) => step.fields.map((field) => field.name));
@@ -2696,7 +2730,12 @@ const config = {
2696
2730
  // - companyFromDomain: a corporate email upserts the Company (deduped by
2697
2731
  // domain) and associates it to the contact;
2698
2732
  // - timelineNote: a note recaps the submission on the contact's timeline.
2699
- forms: { companyFromDomain: true, timelineNote: true }
2733
+ forms: {
2734
+ companyFromDomain: true,
2735
+ timelineNote: true,
2736
+ // Per-IP brakes on the public routes; 0 disables one.
2737
+ rateLimit: { submitPerMinute: 6, searchPerMinute: 30 }
2738
+ }
2700
2739
  },
2701
2740
  validator(cfg) {
2702
2741
  if (cfg.validate && !Array.isArray(cfg.validate)) {
@@ -2758,13 +2797,23 @@ const controllers = {
2758
2797
  }
2759
2798
  }),
2760
2799
  formsAdmin: ({ strapi }) => createFormsAdminController(strapi),
2761
- company: () => ({
2762
- /** SIRENE autocomplete for the company field — bounded, cached upstream. */
2763
- async search(ctx) {
2764
- const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
2765
- ctx.body = { companies: await searchCompanies(q) };
2766
- }
2767
- }),
2800
+ company: ({ strapi }) => {
2801
+ const limits = strapi.plugin("hubspot").config("forms", {});
2802
+ const limiter = createLimiter({
2803
+ windowMs: 6e4,
2804
+ max: limits.rateLimit?.searchPerMinute ?? 30
2805
+ });
2806
+ return {
2807
+ /** SIRENE autocomplete for the company field — bounded, cached upstream. */
2808
+ async search(ctx) {
2809
+ if (!limiter.allow(ctx.request.ip ?? "unknown")) {
2810
+ ctx.throw(429, "Too many searches — slow down.");
2811
+ }
2812
+ const q = typeof ctx.query.q === "string" ? ctx.query.q : "";
2813
+ ctx.body = { companies: await searchCompanies(q) };
2814
+ }
2815
+ };
2816
+ },
2768
2817
  submissions: ({ strapi }) => ({
2769
2818
  /** Paged submissions, newest first, optionally narrowed to one form. */
2770
2819
  async list(ctx) {
@@ -2808,41 +2857,51 @@ const controllers = {
2808
2857
  };
2809
2858
  }
2810
2859
  }),
2811
- forms: ({ strapi }) => ({
2812
- /** Published form for the host frontend — CRM mapping stripped. */
2813
- async findOne(ctx) {
2814
- const entry = await strapi.documents(FORM_UID).findFirst({
2815
- filters: { slug: ctx.params.slug },
2816
- status: "published",
2817
- locale: ctx.query.locale
2818
- });
2819
- if (!entry?.definition) ctx.throw(404, "Form not found");
2820
- ctx.body = publicForm(entry);
2821
- },
2822
- async submit(ctx) {
2823
- const entry = await strapi.documents(FORM_UID).findFirst({
2824
- filters: { slug: ctx.params.slug },
2825
- status: "published",
2826
- locale: ctx.query.locale
2827
- });
2828
- if (!entry?.definition) ctx.throw(404, "Form not found");
2829
- const values = sanitizeRawValues(ctx.request.body?.values);
2830
- if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2831
- const rawMeta = ctx.request.body?.meta ?? {};
2832
- const meta = {};
2833
- for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
2834
- const v = rawMeta[key];
2835
- if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2836
- }
2837
- const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2838
- if (!outcome.ok) {
2839
- ctx.status = 422;
2840
- ctx.body = { ok: false, missingRequired: outcome.missingRequired ?? [] };
2841
- return;
2860
+ forms: ({ strapi }) => {
2861
+ const limits = strapi.plugin("hubspot").config("forms", {});
2862
+ const submitLimiter = createLimiter({
2863
+ windowMs: 6e4,
2864
+ max: limits.rateLimit?.submitPerMinute ?? 6
2865
+ });
2866
+ return {
2867
+ /** Published form for the host frontend — CRM mapping stripped. */
2868
+ async findOne(ctx) {
2869
+ const entry = await strapi.documents(FORM_UID).findFirst({
2870
+ filters: { slug: ctx.params.slug },
2871
+ status: "published",
2872
+ locale: ctx.query.locale
2873
+ });
2874
+ if (!entry?.definition) ctx.throw(404, "Form not found");
2875
+ ctx.body = publicForm(entry);
2876
+ },
2877
+ async submit(ctx) {
2878
+ const entry = await strapi.documents(FORM_UID).findFirst({
2879
+ filters: { slug: ctx.params.slug },
2880
+ status: "published",
2881
+ locale: ctx.query.locale
2882
+ });
2883
+ if (!entry?.definition) ctx.throw(404, "Form not found");
2884
+ if (!submitLimiter.allow(ctx.request.ip ?? "unknown")) {
2885
+ ctx.throw(429, "Too many submissions — try again in a minute.");
2886
+ }
2887
+ const values = sanitizeRawValues(ctx.request.body?.values);
2888
+ if (!values || !Object.keys(values).length) ctx.throw(422, "Invalid submission");
2889
+ const rawMeta = ctx.request.body?.meta ?? {};
2890
+ const meta = {};
2891
+ for (const key of ["pagePath", "pageUrl", "originPath", "originLabel", "source"]) {
2892
+ const v = rawMeta[key];
2893
+ if (typeof v === "string" && v) meta[key] = v.slice(0, 500);
2894
+ }
2895
+ const outcome = await strapi.plugin("hubspot").service("forms").submit(entry, values, meta);
2896
+ if (!outcome.ok) {
2897
+ ctx.status = 422;
2898
+ ctx.body = { ok: false, missingRequired: outcome.missingRequired ?? [] };
2899
+ return;
2900
+ }
2901
+ ctx.body = { ok: true, hubspotSynced: outcome.hubspotSynced };
2842
2902
  }
2843
- ctx.body = { ok: true, hubspotSynced: outcome.hubspotSynced };
2844
- }
2845
- }),
2903
+ };
2904
+ },
2846
2905
  settings: ({ strapi }) => ({
2847
2906
  async get(ctx) {
2848
2907
  ctx.body = await publicSettings(strapi);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -10,6 +10,10 @@ declare const _default: {
10
10
  forms: {
11
11
  companyFromDomain: boolean;
12
12
  timelineNote: boolean;
13
+ rateLimit: {
14
+ submitPerMinute: number;
15
+ searchPerMinute: number;
16
+ };
13
17
  };
14
18
  };
15
19
  validator(cfg: {
@@ -27,7 +31,6 @@ declare const _default: {
27
31
  displayName: string;
28
32
  description: string;
29
33
  };
30
- /** RBAC action gating the settings screen and its routes. */
31
34
  options: {
32
35
  draftAndPublish: boolean;
33
36
  };
@@ -36,7 +39,7 @@ declare const _default: {
36
39
  visible: boolean;
37
40
  };
38
41
  "content-type-builder": {
39
- visible: boolean;
42
+ visible: boolean; /** RBAC action gating the form builder and its routes. */
40
43
  };
41
44
  i18n: {
42
45
  localized: boolean;
@@ -265,13 +268,19 @@ declare const _default: {
265
268
  duplicate(ctx: import("./formsAdmin").Ctx): Promise<void>;
266
269
  remove(ctx: import("./formsAdmin").Ctx): Promise<void>;
267
270
  };
268
- company: () => {
271
+ company: ({ strapi }: {
272
+ strapi: Core.Strapi;
273
+ }) => {
269
274
  /** SIRENE autocomplete for the company field — bounded, cached upstream. */
270
275
  search(ctx: {
271
276
  query: {
272
277
  q?: string;
273
278
  };
279
+ request: {
280
+ ip?: string;
281
+ };
274
282
  body: unknown;
283
+ throw: (s: number, m: string) => never;
275
284
  }): Promise<void>;
276
285
  };
277
286
  submissions: ({ strapi }: {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * In-memory sliding-window rate limiter for the public routes. Per-process on
3
+ * purpose: a form submission endpoint needs a cheap brake against a single
4
+ * abusive IP, not distributed accounting — and a multi-instance deployment
5
+ * simply multiplies the budget by the instance count, which is fine.
6
+ */
7
+ export interface Limiter {
8
+ /** True when the call fits the window; false = refuse (429). */
9
+ allow(key: string): boolean;
10
+ /** Number of tracked keys — a test hook for the sweep. */
11
+ size(): number;
12
+ }
13
+ export declare function createLimiter({ windowMs, max, now, }: {
14
+ windowMs: number;
15
+ /** 0 disables the limiter entirely. */
16
+ max: number;
17
+ now?: () => number;
18
+ }): Limiter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-plugin-hubspot",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "The form builder for HubSpot in Strapi — multi-step forms with conditional fields mapped to real CRM properties, a public submit pipeline with retries, and save-time mapping validation.",
5
5
  "keywords": [
6
6
  "strapi",
@@ -69,10 +69,14 @@
69
69
  "@strapi/strapi": "^5.0.0",
70
70
  "@strapi/typescript-utils": "^5.0.0",
71
71
  "@strapi/utils": "^5.0.0",
72
+ "@testing-library/jest-dom": "^7.0.1",
73
+ "@testing-library/react": "^16.3.3",
74
+ "@testing-library/user-event": "^14.6.6",
72
75
  "@types/react": "^18.3.3",
73
76
  "@types/react-dom": "^18.3.0",
74
77
  "eslint": "^10.9.1",
75
78
  "eslint-plugin-react-hooks": "^7.1.1",
79
+ "jsdom": "^29.1.1",
76
80
  "react": "^18.3.1",
77
81
  "react-dom": "^18.3.1",
78
82
  "react-router-dom": "^6.26.0",
@@ -99,5 +103,10 @@
99
103
  },
100
104
  "engines": {
101
105
  "node": ">=18.0.0"
106
+ },
107
+ "dependencies": {
108
+ "@dnd-kit/core": "^6.3.1",
109
+ "@dnd-kit/sortable": "^10.0.0",
110
+ "@dnd-kit/utilities": "^3.2.2"
102
111
  }
103
- }
112
+ }