@vicacha-devs/strapi-plugin-inbox 1.1.0 → 1.1.1

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.
@@ -43,15 +43,122 @@ const DEFAULT_INBOX_SETTINGS = {
43
43
  defaultNotifyEmails: [],
44
44
  outboundWebhookUrl: ""
45
45
  };
46
+ const SUPPORTED_INTL_CALENDARS = Object.freeze([
47
+ { id: "gregory", intlCalendar: "gregory", defaultLabel: "Gregorian" },
48
+ { id: "iso8601", intlCalendar: "iso8601", defaultLabel: "ISO 8601" },
49
+ {
50
+ id: "islamic-umalqura",
51
+ intlCalendar: "islamic-umalqura",
52
+ defaultLabel: "Hijri (Umm al-Qura)"
53
+ },
54
+ {
55
+ id: "islamic-civil",
56
+ intlCalendar: "islamic-civil",
57
+ defaultLabel: "Hijri (Civil)"
58
+ },
59
+ {
60
+ id: "islamic-tbla",
61
+ intlCalendar: "islamic-tbla",
62
+ defaultLabel: "Hijri (Tabular)"
63
+ },
64
+ { id: "hebrew", intlCalendar: "hebrew", defaultLabel: "Hebrew" },
65
+ {
66
+ id: "persian",
67
+ intlCalendar: "persian",
68
+ defaultLabel: "Persian (Solar Hijri)"
69
+ },
70
+ { id: "buddhist", intlCalendar: "buddhist", defaultLabel: "Buddhist" },
71
+ { id: "japanese", intlCalendar: "japanese", defaultLabel: "Japanese" },
72
+ { id: "chinese", intlCalendar: "chinese", defaultLabel: "Chinese Lunar" },
73
+ { id: "indian", intlCalendar: "indian", defaultLabel: "Indian National" },
74
+ { id: "coptic", intlCalendar: "coptic", defaultLabel: "Coptic" },
75
+ { id: "ethiopic", intlCalendar: "ethiopic", defaultLabel: "Ethiopic" }
76
+ ]);
77
+ Object.freeze([
78
+ "none",
79
+ ...SUPPORTED_INTL_CALENDARS.map((item) => item.id)
80
+ ]);
81
+ const BASE_BCP47_REGEX = /^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8})*$/;
82
+ function sanitizeCalendarBaseLocale(locale, fallbackLocale = "es") {
83
+ if (typeof locale !== "string" || !locale.trim()) {
84
+ return fallbackLocale;
85
+ }
86
+ const trimmed = locale.trim();
87
+ const uIdx = trimmed.toLowerCase().indexOf("-u-");
88
+ let stripped = uIdx >= 0 ? trimmed.slice(0, uIdx) : trimmed;
89
+ while (stripped.endsWith("-")) {
90
+ stripped = stripped.slice(0, -1);
91
+ }
92
+ if (!stripped || stripped.length > 35 || !BASE_BCP47_REGEX.test(stripped)) {
93
+ return fallbackLocale;
94
+ }
95
+ return stripped;
96
+ }
97
+ function formatSecondaryDate(isoDateString, calendar = "islamic-umalqura", locale = "es") {
98
+ if (!isoDateString || calendar === "none") {
99
+ return { calendar, formatted: "" };
100
+ }
101
+ const date = new Date(isoDateString);
102
+ if (Number.isNaN(date.getTime())) {
103
+ return { calendar, formatted: isoDateString };
104
+ }
105
+ try {
106
+ const baseLocale = sanitizeCalendarBaseLocale(locale);
107
+ const formatter = new Intl.DateTimeFormat(`${baseLocale}-u-ca-${calendar}`, {
108
+ year: "numeric",
109
+ month: "short",
110
+ day: "numeric"
111
+ });
112
+ const parts = formatter.formatToParts(date);
113
+ const year = Number(parts.find((p) => p.type === "year")?.value) || void 0;
114
+ const month = Number(parts.find((p) => p.type === "month")?.value) || void 0;
115
+ const day = Number(parts.find((p) => p.type === "day")?.value) || void 0;
116
+ return {
117
+ calendar,
118
+ formatted: formatter.format(date),
119
+ year,
120
+ month,
121
+ day
122
+ };
123
+ } catch {
124
+ return {
125
+ calendar,
126
+ formatted: date.toISOString().slice(0, 10)
127
+ };
128
+ }
129
+ }
46
130
  function isValidEmail(value) {
47
- if (/\s/.test(value))
131
+ if (typeof value !== "string" || value.length < 3 || value.length > 254) {
132
+ return false;
133
+ }
134
+ if (/[\u0000-\u0020\u007f]/.test(value) || /\s/.test(value))
48
135
  return false;
49
136
  const at = value.indexOf("@");
50
- if (at < 1 || at !== value.lastIndexOf("@"))
137
+ if (at < 1 || at > 64 || at !== value.lastIndexOf("@"))
138
+ return false;
139
+ const local = value.slice(0, at);
140
+ if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) {
51
141
  return false;
142
+ }
52
143
  const domain = value.slice(at + 1);
53
- const dot = domain.indexOf(".");
54
- return dot > 0 && dot < domain.length - 1;
144
+ if (domain.startsWith(".") || domain.endsWith(".") || domain.includes("..")) {
145
+ return false;
146
+ }
147
+ const labels = domain.split(".");
148
+ if (labels.length < 2)
149
+ return false;
150
+ return labels.every((label) => label.length > 0 && label.length <= 63 && !label.startsWith("-") && !label.endsWith("-"));
151
+ }
152
+ const CSV_FORMULA_PREFIX_RE = /^[\s\u0085\u2028\u2029]*[=+\-@|]|^[\t\r\n]/;
153
+ function escapeCsvCell(value) {
154
+ if (value === null || value === void 0)
155
+ return "";
156
+ const raw = typeof value === "object" ? JSON.stringify(value) : String(value);
157
+ const sanitized = raw.length > 0 && CSV_FORMULA_PREFIX_RE.test(raw) ? `'${raw}` : raw;
158
+ if (sanitized.includes('"') || sanitized.includes(",") || sanitized.includes("\n") || sanitized.includes("\r")) {
159
+ return `"${sanitized.replace(/"/g, '""')}"`;
160
+ }
161
+ return sanitized;
55
162
  }
56
163
  function freezeOptions(items) {
57
164
  return Object.freeze(items.map((item) => Object.freeze({ ...item })));
@@ -83,6 +190,54 @@ freezeOptions([
83
190
  { value: "persian", label: "persian (Solar Hijri)" },
84
191
  { value: "none", label: "none (Gregorian Only)" }
85
192
  ]);
193
+ class SlidingWindowRateLimiter {
194
+ hits = /* @__PURE__ */ new Map();
195
+ maxTrackedKeys;
196
+ defaultWindowMs;
197
+ constructor(options2) {
198
+ this.maxTrackedKeys = options2?.maxTrackedKeys ?? 1e4;
199
+ this.defaultWindowMs = options2?.defaultWindowMs ?? 60 * 60 * 1e3;
200
+ }
201
+ check(ip, bucketKey, limitPerWindow, nowMs = Date.now(), windowMs = this.defaultWindowMs) {
202
+ if (limitPerWindow <= 0) {
203
+ return { allowed: true, remaining: 999, retryAfterSeconds: 0 };
204
+ }
205
+ if (this.hits.size > this.maxTrackedKeys) {
206
+ const oldestKey = this.hits.keys().next().value;
207
+ if (oldestKey !== void 0) {
208
+ this.hits.delete(oldestKey);
209
+ }
210
+ }
211
+ const key = `${bucketKey}::${ip || "unknown"}`;
212
+ const cutoff = nowMs - windowMs;
213
+ const previous = (this.hits.get(key) || []).filter((ts) => ts > cutoff);
214
+ if (previous.length >= limitPerWindow) {
215
+ const oldest = previous[0] ?? nowMs;
216
+ const retryAfterSeconds = Math.max(1, Math.ceil((oldest + windowMs - nowMs) / 1e3));
217
+ this.hits.set(key, previous);
218
+ return {
219
+ allowed: false,
220
+ remaining: 0,
221
+ retryAfterSeconds
222
+ };
223
+ }
224
+ previous.push(nowMs);
225
+ this.hits.set(key, previous);
226
+ return {
227
+ allowed: true,
228
+ remaining: Math.max(0, limitPerWindow - previous.length),
229
+ retryAfterSeconds: 0
230
+ };
231
+ }
232
+ reset() {
233
+ this.hits.clear();
234
+ }
235
+ }
236
+ const FORBIDDEN_OBJECT_KEYS = /* @__PURE__ */ new Set([
237
+ "__proto__",
238
+ "constructor",
239
+ "prototype"
240
+ ]);
86
241
  const LOCALIZED_REQUIRED_FALLBACK = {
87
242
  es: (label) => `${label} es obligatorio`,
88
243
  en: (label) => `${label} is required`,
@@ -180,12 +335,23 @@ function checkNumericBoundRule(rule, limit, rawValue, fieldLabel, locale) {
180
335
  }
181
336
  return null;
182
337
  }
338
+ function hasNestedQuantifier(pattern) {
339
+ return /[+*?}]\s*\)\s*[+*?{]/.test(pattern.replaceAll(/\\./g, ""));
340
+ }
183
341
  function checkLengthOrNumberRule(rule, rawValue, strVal, fieldLabel, locale) {
184
- if (typeof rule.value !== "number") return null;
185
- return checkLengthRule(rule, rule.value, strVal, fieldLabel, locale) ?? checkNumericBoundRule(rule, rule.value, rawValue, fieldLabel, locale);
342
+ const numRuleVal = typeof rule.value === "number" ? rule.value : typeof rule.value === "string" && rule.value.trim() !== "" ? Number(rule.value) : Number.NaN;
343
+ if (!Number.isFinite(numRuleVal)) return null;
344
+ return checkLengthRule(rule, numRuleVal, strVal, fieldLabel, locale) ?? checkNumericBoundRule(rule, numRuleVal, rawValue, fieldLabel, locale);
186
345
  }
187
346
  function checkPatternOrEmailRule(rule, fieldType, strVal, fieldLabel, locale) {
188
347
  if (rule.type === "pattern" && typeof rule.value === "string") {
348
+ if (strVal.length > 4096 || rule.value.length > 512 || hasNestedQuantifier(rule.value)) {
349
+ return resolveRuleMessage(
350
+ rule,
351
+ locale,
352
+ `${fieldLabel} format is invalid`
353
+ );
354
+ }
189
355
  try {
190
356
  const regex = new RegExp(rule.value);
191
357
  if (!regex.test(strVal)) {
@@ -205,6 +371,13 @@ function checkPatternOrEmailRule(rule, fieldType, strVal, fieldLabel, locale) {
205
371
  `${fieldLabel} must be a valid email address`
206
372
  );
207
373
  }
374
+ if (rule.type === "url" && fieldType !== "url" && !isValidHttpUrl(strVal)) {
375
+ return resolveRuleMessage(
376
+ rule,
377
+ locale,
378
+ `${fieldLabel} must be a valid URL`
379
+ );
380
+ }
208
381
  return null;
209
382
  }
210
383
  function evaluateCustomRules(field, rawValue, strVal, fieldLabel, locale, rules) {
@@ -239,7 +412,7 @@ function buildRequiredError(rules, locale, fieldLabel) {
239
412
  return resolveRuleMessage(reqRule, locale, fallbackFn(fieldLabel));
240
413
  }
241
414
  function validateSingleField(field, data, locale) {
242
- const rawValue = data[field.name] ?? field.defaultValue;
415
+ const rawValue = Object.hasOwn(data, field.name) ? data[field.name] : field.defaultValue;
243
416
  const fieldLabel = resolveFieldLabel(field, locale);
244
417
  const rules = field.validation || [];
245
418
  if (field.required && isValueEmpty(rawValue)) {
@@ -272,7 +445,9 @@ function validateFormSubmission(fields, data, locale = "en") {
272
445
  const errors = {};
273
446
  const cleanedData = {};
274
447
  for (const field of fields) {
275
- if (DECORATIVE_FIELD_TYPES.includes(field.type)) continue;
448
+ if (DECORATIVE_FIELD_TYPES.includes(field.type) || FORBIDDEN_OBJECT_KEYS.has(field.name)) {
449
+ continue;
450
+ }
276
451
  const { fieldErrors, cleanedValue } = validateSingleField(
277
452
  field,
278
453
  data,
@@ -291,45 +466,10 @@ function validateFormSubmission(fields, data, locale = "en") {
291
466
  };
292
467
  }
293
468
  function isHoneypotTriggered(payload, honeypotField = "_website_url") {
294
- if (!honeypotField) return false;
469
+ if (!honeypotField || !Object.hasOwn(payload, honeypotField)) return false;
295
470
  const val = payload[honeypotField];
296
471
  return typeof val === "string" && val.trim().length > 0;
297
472
  }
298
- class SlidingWindowRateLimiter {
299
- hits = /* @__PURE__ */ new Map();
300
- check(ip, bucketKey, limitPerHour, nowMs = Date.now()) {
301
- if (limitPerHour <= 0) {
302
- return { allowed: true, remaining: 999, retryAfterSeconds: 0 };
303
- }
304
- const windowMs = 60 * 60 * 1e3;
305
- const key = `${bucketKey}::${ip || "unknown"}`;
306
- const cutoff = nowMs - windowMs;
307
- const previous = (this.hits.get(key) || []).filter((ts) => ts > cutoff);
308
- if (previous.length >= limitPerHour) {
309
- const oldest = previous[0] ?? nowMs;
310
- const retryAfterSeconds = Math.max(
311
- 1,
312
- Math.ceil((oldest + windowMs - nowMs) / 1e3)
313
- );
314
- this.hits.set(key, previous);
315
- return {
316
- allowed: false,
317
- remaining: 0,
318
- retryAfterSeconds
319
- };
320
- }
321
- previous.push(nowMs);
322
- this.hits.set(key, previous);
323
- return {
324
- allowed: true,
325
- remaining: Math.max(0, limitPerHour - previous.length),
326
- retryAfterSeconds: 0
327
- };
328
- }
329
- reset() {
330
- this.hits.clear();
331
- }
332
- }
333
473
  const defaultRateLimiter = new SlidingWindowRateLimiter();
334
474
  function resolveFetchRunner(fetchImpl) {
335
475
  return fetchImpl ?? globalThis.fetch;
@@ -688,16 +828,6 @@ function localizeFormForLocale(form, locale) {
688
828
  }
689
829
  };
690
830
  }
691
- const FORMULA_PREFIXES = ["=", "+", "-", "@", " ", "\r"];
692
- function escapeCsvCell(value) {
693
- if (value === null || value === void 0) return "";
694
- const raw = typeof value === "object" ? JSON.stringify(value) : String(value);
695
- const sanitized = raw.length > 0 && FORMULA_PREFIXES.includes(raw[0]) ? `'${raw}` : raw;
696
- if (sanitized.includes('"') || sanitized.includes(",") || sanitized.includes("\n") || sanitized.includes("\r")) {
697
- return `"${sanitized.replace(/"/g, '""')}"`;
698
- }
699
- return sanitized;
700
- }
701
831
  function collectDataFieldNames(submissions, fields) {
702
832
  const dataFieldNames = [];
703
833
  const seen = /* @__PURE__ */ new Set();
@@ -761,36 +891,7 @@ function submissionsToCsv(submissions, fields = []) {
761
891
  return rows.join("\n");
762
892
  }
763
893
  function convertIsoDateToCalendar(isoDateString, calendar = "islamic-umalqura", locale = "en") {
764
- if (!isoDateString) {
765
- return { calendar, formatted: "" };
766
- }
767
- const date = new Date(isoDateString);
768
- if (Number.isNaN(date.getTime())) {
769
- return { calendar, formatted: isoDateString };
770
- }
771
- try {
772
- const formatter = new Intl.DateTimeFormat(`${locale}-u-ca-${calendar}`, {
773
- year: "numeric",
774
- month: "short",
775
- day: "numeric"
776
- });
777
- const parts = formatter.formatToParts(date);
778
- const year = Number(parts.find((p) => p.type === "year")?.value) || void 0;
779
- const month = Number(parts.find((p) => p.type === "month")?.value) || void 0;
780
- const day = Number(parts.find((p) => p.type === "day")?.value) || void 0;
781
- return {
782
- calendar,
783
- formatted: formatter.format(date),
784
- year,
785
- month,
786
- day
787
- };
788
- } catch {
789
- return {
790
- calendar,
791
- formatted: date.toISOString().slice(0, 10)
792
- };
793
- }
894
+ return formatSecondaryDate(isoDateString, calendar, locale);
794
895
  }
795
896
  let mirroringToFormBuilderCms = false;
796
897
  function generateReferenceCode(now = /* @__PURE__ */ new Date()) {
@@ -1263,8 +1364,14 @@ function extractLifecycleFormId(event) {
1263
1364
  const fromResult = resultForm !== void 0 ? resolveCmsSubmissionFormRef({ form: resultForm }).formId : null;
1264
1365
  return fromResult ?? void 0;
1265
1366
  }
1367
+ const subscribedStrapiInstances = /* @__PURE__ */ new WeakSet();
1368
+ function clearSubscribedStrapiInstance(strapi) {
1369
+ subscribedStrapiInstances.delete(strapi);
1370
+ }
1266
1371
  function subscribeFormBuilderLifecycles(strapi, formService2, submissionService2) {
1267
1372
  if (!strapi.db?.lifecycles?.subscribe) return;
1373
+ if (subscribedStrapiInstances.has(strapi)) return;
1374
+ subscribedStrapiInstances.add(strapi);
1268
1375
  if (strapi.contentTypes?.[FORM_BUILDER_CMS_FORM_UID]) {
1269
1376
  strapi.db.lifecycles.subscribe({
1270
1377
  models: [FORM_BUILDER_CMS_FORM_UID],
@@ -1503,8 +1610,16 @@ function normalizeSubmitPayload(rawBody) {
1503
1610
  return nestedData ? { ...rawBody, ...nestedData } : rawBody;
1504
1611
  }
1505
1612
  function resolveClientIp(ctx) {
1613
+ const directIp = ctx.ip || ctx.request.ip;
1614
+ if (directIp && directIp.trim()) return directIp.trim();
1506
1615
  const forwarded = ctx.request.headers?.["x-forwarded-for"]?.split(",")[0]?.trim();
1507
- return forwarded || ctx.request.ip || "127.0.0.1";
1616
+ return forwarded || "127.0.0.1";
1617
+ }
1618
+ function isAuthenticatedAdminContext(ctx) {
1619
+ const state = ctx.state;
1620
+ return Boolean(
1621
+ state?.user || state?.isAuthenticatedAdmin || state?.route?.info?.type === "admin"
1622
+ );
1508
1623
  }
1509
1624
  const ERROR_STATUS_MAP = {
1510
1625
  NotFoundError: 404,
@@ -1539,11 +1654,12 @@ const createFormHandlers = (strapi) => ({
1539
1654
  },
1540
1655
  async listForms(ctx) {
1541
1656
  const svc = strapi.plugin(PLUGIN_ID).service("formService");
1657
+ const isAdmin = isAuthenticatedAdminContext(ctx);
1542
1658
  const data = await svc.listForms({
1543
1659
  category: ctx.query?.category,
1544
- activeOnly: ctx.query?.activeOnly !== "false",
1660
+ activeOnly: isAdmin ? ctx.query?.activeOnly !== "false" : true,
1545
1661
  locale: ctx.query?.locale,
1546
- publicSafe: ctx.query?.admin !== "true"
1662
+ publicSafe: isAdmin ? ctx.query?.admin !== "true" : true
1547
1663
  });
1548
1664
  ctx.body = { data };
1549
1665
  },
@@ -1585,8 +1701,14 @@ const createFormHandlers = (strapi) => ({
1585
1701
  locale: rawBody.locale || ctx.query?.locale,
1586
1702
  captchaToken: rawBody.captchaToken || payload.captchaToken
1587
1703
  });
1704
+ const {
1705
+ ipAddress: _ipAddress,
1706
+ userAgent: _userAgent,
1707
+ staffNotes: _staffNotes,
1708
+ ...publicData
1709
+ } = data;
1588
1710
  ctx.status = 201;
1589
- ctx.body = { data };
1711
+ ctx.body = { data: publicData };
1590
1712
  } catch (err) {
1591
1713
  const error = err;
1592
1714
  ctx.status = ERROR_STATUS_MAP[error.name] ?? 400;
@@ -1606,6 +1728,7 @@ const controllers = {
1606
1728
  inboxController
1607
1729
  };
1608
1730
  const destroy = ({ strapi }) => {
1731
+ clearSubscribedStrapiInstance(strapi);
1609
1732
  strapi.log.debug("[@vicacha-devs/strapi-plugin-inbox] Destroyed");
1610
1733
  };
1611
1734
  const register = ({ strapi }) => {
@@ -1613,6 +1736,17 @@ const register = ({ strapi }) => {
1613
1736
  "[@vicacha-devs/strapi-plugin-inbox] Registered inbox & form builder plugin"
1614
1737
  );
1615
1738
  };
1739
+ const withPerm = (action) => [
1740
+ "admin::isAuthenticatedAdmin",
1741
+ {
1742
+ name: "admin::hasPermissions",
1743
+ config: { actions: [`plugin::inbox.${action}`] }
1744
+ }
1745
+ ];
1746
+ const FORMS_POLICIES = withPerm("forms.manage");
1747
+ const SUBMISSIONS_POLICIES = withPerm("submissions.manage");
1748
+ const SUBSCRIBERS_POLICIES = withPerm("subscribers.manage");
1749
+ const SETTINGS_POLICIES = withPerm("settings.manage");
1616
1750
  const admin = {
1617
1751
  type: "admin",
1618
1752
  routes: [
@@ -1620,103 +1754,103 @@ const admin = {
1620
1754
  method: "GET",
1621
1755
  path: "/settings",
1622
1756
  handler: "inboxController.getSettings",
1623
- config: { policies: [] }
1757
+ config: { policies: SETTINGS_POLICIES }
1624
1758
  },
1625
1759
  {
1626
1760
  method: "PUT",
1627
1761
  path: "/settings",
1628
1762
  handler: "inboxController.updateSettings",
1629
- config: { policies: [] }
1763
+ config: { policies: SETTINGS_POLICIES }
1630
1764
  },
1631
1765
  {
1632
1766
  method: "GET",
1633
1767
  path: "/templates",
1634
1768
  handler: "inboxController.listTemplates",
1635
- config: { policies: [] }
1769
+ config: { policies: FORMS_POLICIES }
1636
1770
  },
1637
1771
  {
1638
1772
  method: "GET",
1639
1773
  path: "/form-builder-forms",
1640
1774
  handler: "inboxController.listFormBuilderForms",
1641
- config: { policies: [] }
1775
+ config: { policies: FORMS_POLICIES }
1642
1776
  },
1643
1777
  {
1644
1778
  method: "POST",
1645
1779
  path: "/forms/sync-builder",
1646
1780
  handler: "inboxController.syncFormBuilder",
1647
- config: { policies: [] }
1781
+ config: { policies: FORMS_POLICIES }
1648
1782
  },
1649
1783
  {
1650
1784
  method: "GET",
1651
1785
  path: "/forms",
1652
1786
  handler: "inboxController.listForms",
1653
- config: { policies: [] }
1787
+ config: { policies: FORMS_POLICIES }
1654
1788
  },
1655
1789
  {
1656
1790
  method: "POST",
1657
1791
  path: "/forms",
1658
1792
  handler: "inboxController.saveForm",
1659
- config: { policies: [] }
1793
+ config: { policies: FORMS_POLICIES }
1660
1794
  },
1661
1795
  {
1662
1796
  method: "POST",
1663
1797
  path: "/forms/from-template",
1664
1798
  handler: "inboxController.createFromTemplate",
1665
- config: { policies: [] }
1799
+ config: { policies: FORMS_POLICIES }
1666
1800
  },
1667
1801
  {
1668
1802
  method: "GET",
1669
1803
  path: "/submissions",
1670
1804
  handler: "inboxController.listSubmissions",
1671
- config: { policies: [] }
1805
+ config: { policies: SUBMISSIONS_POLICIES }
1672
1806
  },
1673
1807
  {
1674
1808
  method: "PUT",
1675
1809
  path: "/submissions/:documentId/casework",
1676
1810
  handler: "inboxController.updateCasework",
1677
- config: { policies: [] }
1811
+ config: { policies: SUBMISSIONS_POLICIES }
1678
1812
  },
1679
1813
  {
1680
1814
  method: "POST",
1681
1815
  path: "/submissions/:documentId/schedule-booking",
1682
1816
  handler: "inboxController.scheduleBooking",
1683
- config: { policies: [] }
1817
+ config: { policies: SUBMISSIONS_POLICIES }
1684
1818
  },
1685
1819
  {
1686
1820
  method: "POST",
1687
1821
  path: "/submissions/:documentId/issue-certificate",
1688
1822
  handler: "inboxController.issueCertificate",
1689
- config: { policies: [] }
1823
+ config: { policies: SUBMISSIONS_POLICIES }
1690
1824
  },
1691
1825
  {
1692
1826
  method: "GET",
1693
1827
  path: "/submissions/export-csv",
1694
1828
  handler: "inboxController.exportCsv",
1695
- config: { policies: [] }
1829
+ config: { policies: SUBMISSIONS_POLICIES }
1696
1830
  },
1697
1831
  {
1698
1832
  method: "POST",
1699
1833
  path: "/captcha/test",
1700
1834
  handler: "inboxController.testCaptcha",
1701
- config: { policies: [] }
1835
+ config: { policies: SETTINGS_POLICIES }
1702
1836
  },
1703
1837
  {
1704
1838
  method: "GET",
1705
1839
  path: "/subscribers",
1706
1840
  handler: "inboxController.listSubscribers",
1707
- config: { policies: [] }
1841
+ config: { policies: SUBSCRIBERS_POLICIES }
1708
1842
  },
1709
1843
  {
1710
1844
  method: "GET",
1711
1845
  path: "/alerts",
1712
1846
  handler: "inboxController.getActiveAlerts",
1713
- config: { policies: [] }
1847
+ config: { policies: SUBSCRIBERS_POLICIES }
1714
1848
  },
1715
1849
  {
1716
1850
  method: "POST",
1717
1851
  path: "/alerts",
1718
1852
  handler: "inboxController.upsertAlertBanner",
1719
- config: { policies: [] }
1853
+ config: { policies: SUBSCRIBERS_POLICIES }
1720
1854
  }
1721
1855
  ]
1722
1856
  };
@@ -1777,6 +1911,15 @@ const contentApi = {
1777
1911
  auth: false
1778
1912
  }
1779
1913
  },
1914
+ {
1915
+ method: "GET",
1916
+ path: "/public/unsubscribe",
1917
+ handler: "inboxController.unsubscribe",
1918
+ config: {
1919
+ policies: [],
1920
+ auth: false
1921
+ }
1922
+ },
1780
1923
  {
1781
1924
  method: "GET",
1782
1925
  path: "/alerts",
@@ -1795,6 +1938,15 @@ const routes = {
1795
1938
  function getDocuments(strapi, uid) {
1796
1939
  return strapi.documents(uid);
1797
1940
  }
1941
+ function sanitizeSafeCtaUrl(raw) {
1942
+ if (typeof raw !== "string") return "";
1943
+ const trimmed = raw.trim();
1944
+ if (!trimmed) return "";
1945
+ if (trimmed.startsWith("https://") || trimmed.startsWith("http://") || trimmed.startsWith("/") && !trimmed.startsWith("//")) {
1946
+ return trimmed;
1947
+ }
1948
+ return "";
1949
+ }
1798
1950
  function buildAlertBannerPayload(input) {
1799
1951
  return {
1800
1952
  code: input.code,
@@ -1802,7 +1954,7 @@ function buildAlertBannerPayload(input) {
1802
1954
  message: input.message,
1803
1955
  severity: input.severity ?? "info",
1804
1956
  ctaLabel: input.ctaLabel ?? "",
1805
- ctaUrl: input.ctaUrl ?? "",
1957
+ ctaUrl: sanitizeSafeCtaUrl(input.ctaUrl),
1806
1958
  dismissible: input.dismissible ?? true,
1807
1959
  active: input.active ?? true,
1808
1960
  priority: input.priority ?? 10,
@@ -1816,7 +1968,8 @@ const alertService = ({ strapi }) => ({
1816
1968
  const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
1817
1969
  const settings2 = await settingsService.getSettings();
1818
1970
  const locale = params?.locale || settings2.defaultLocale || "es";
1819
- const nowMs = new Date(params?.nowIso || Date.now()).getTime();
1971
+ const parsedNowMs = new Date(params?.nowIso || Date.now()).getTime();
1972
+ const nowMs = Number.isNaN(parsedNowMs) ? Date.now() : parsedNowMs;
1820
1973
  const docs = await getDocuments(strapi, ALERT_BANNER_UID).findMany({
1821
1974
  filters: { active: true },
1822
1975
  limit: 50
@@ -1848,7 +2001,7 @@ const alertService = ({ strapi }) => ({
1848
2001
  title: tr?.title || banner.title,
1849
2002
  message: tr?.message || banner.message,
1850
2003
  ctaLabel: tr?.ctaLabel || banner.ctaLabel,
1851
- ctaUrl: tr?.ctaUrl || banner.ctaUrl,
2004
+ ctaUrl: sanitizeSafeCtaUrl(tr?.ctaUrl || banner.ctaUrl),
1852
2005
  secondaryDateLabel: secondaryDate.formatted
1853
2006
  };
1854
2007
  });
@@ -1862,17 +2015,23 @@ const alertService = ({ strapi }) => ({
1862
2015
  );
1863
2016
  const existing = existingDocs[0] || null;
1864
2017
  const payload = buildAlertBannerPayload(input);
2018
+ let saved;
1865
2019
  if (existing?.documentId) {
1866
2020
  const updated = await getDocuments(strapi, ALERT_BANNER_UID).update({
1867
2021
  documentId: existing.documentId,
1868
2022
  data: payload
1869
2023
  });
1870
- return updated;
2024
+ saved = updated;
2025
+ } else {
2026
+ const created = await getDocuments(strapi, ALERT_BANNER_UID).create({
2027
+ data: payload
2028
+ });
2029
+ saved = created;
1871
2030
  }
1872
- const created = await getDocuments(strapi, ALERT_BANNER_UID).create({
1873
- data: payload
1874
- });
1875
- return created;
2031
+ if (saved.active) {
2032
+ strapi.eventHub?.emit("inbox.alert.published", { entry: saved });
2033
+ }
2034
+ return saved;
1876
2035
  }
1877
2036
  });
1878
2037
  function sanitizePublicForm(form, locale) {
@@ -2671,18 +2830,20 @@ function assertRequiredChecklistVerified(existing, requireVerified) {
2671
2830
  async function tryIssuePluginCertificate(strapi, existing, certInput, fallbackCertNumber) {
2672
2831
  try {
2673
2832
  const certsPlugin = strapi.plugin("certificates");
2674
- const certSvc = certsPlugin?.service("certificateService");
2833
+ const certSvc = certsPlugin?.service("issuance") ?? certsPlugin?.service("certificateService");
2675
2834
  if (!certSvc?.issueCertificate) return fallbackCertNumber;
2835
+ const recipientFullName = certInput?.recipientName || existing.applicantName || "Applicant";
2676
2836
  const issued = await certSvc.issueCertificate({
2677
2837
  templateSlug: certInput?.templateSlug || "official-service-certificate",
2678
- recipientName: certInput?.recipientName || existing.applicantName || "Applicant",
2838
+ recipientName: recipientFullName,
2839
+ recipientFullName,
2679
2840
  recipientEmail: existing.applicantEmail,
2680
2841
  metadata: {
2681
2842
  submissionReference: existing.referenceCode,
2682
2843
  formSlug: existing.formSlug
2683
2844
  }
2684
2845
  });
2685
- return issued?.certificateNumber || fallbackCertNumber;
2846
+ return issued?.serialNumber || issued?.certificateNumber || fallbackCertNumber;
2686
2847
  } catch {
2687
2848
  return fallbackCertNumber;
2688
2849
  }
@@ -2963,7 +3124,8 @@ function pickSubscriberStr(...values) {
2963
3124
  function buildSubscriberPayload(email, input, existing, settings2) {
2964
3125
  const doubleOptIn = settings2.enableDoubleOptIn !== false;
2965
3126
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
2966
- const status = doubleOptIn ? "pending" : "confirmed";
3127
+ const alreadyConfirmed = existing?.status === "confirmed";
3128
+ const status = alreadyConfirmed || !doubleOptIn ? "confirmed" : "pending";
2967
3129
  return {
2968
3130
  email,
2969
3131
  fullName: pickSubscriberStr(input.fullName, existing?.fullName),
@@ -2971,12 +3133,12 @@ function buildSubscriberPayload(email, input, existing, settings2) {
2971
3133
  locale: pickSubscriberStr(input.locale, settings2.defaultLocale, "es"),
2972
3134
  topics: normalizeSubscriberTopics(input.topics),
2973
3135
  status,
2974
- confirmToken: doubleOptIn ? randomBytes(20).toString("hex") : null,
3136
+ confirmToken: alreadyConfirmed || !doubleOptIn ? null : randomBytes(20).toString("hex"),
2975
3137
  unsubscribeToken: pickSubscriberStr(
2976
3138
  existing?.unsubscribeToken,
2977
3139
  randomBytes(20).toString("hex")
2978
3140
  ),
2979
- confirmedAt: doubleOptIn ? null : nowIso,
3141
+ confirmedAt: alreadyConfirmed ? existing.confirmedAt || nowIso : doubleOptIn ? null : nowIso,
2980
3142
  unsubscribedAt: null,
2981
3143
  sourceFormSlug: pickSubscriberStr(
2982
3144
  input.sourceFormSlug,
@@ -1,4 +1,5 @@
1
1
  import { Core } from '@strapi/strapi';
2
+ export declare function clearSubscribedStrapiInstance(strapi: Core.Strapi): void;
2
3
  declare const bootstrap: ({ strapi }: {
3
4
  strapi: Core.Strapi;
4
5
  }) => Promise<void>;