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