@vicacha-devs/strapi-plugin-certificates 0.0.0-stage → 1.0.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 (70) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +17 -2
  3. package/dist/admin/CertificatesPage-CFDIEBN_.js +783 -0
  4. package/dist/admin/CertificatesPage-DeGmoX7h.cjs +783 -0
  5. package/dist/admin/SettingsPage-CsWFO1XB.js +447 -0
  6. package/dist/admin/SettingsPage-D7Ob1gV0.cjs +447 -0
  7. package/dist/admin/ar-Bv3T0_7C.cjs +258 -0
  8. package/dist/admin/ar-CDFHNnFd.js +258 -0
  9. package/dist/admin/de-BjMxn-Lt.js +258 -0
  10. package/dist/admin/de-DB5eW-yI.cjs +258 -0
  11. package/dist/admin/en-BI5Z3lPW.js +258 -0
  12. package/dist/admin/en-CsYV0VDf.cjs +258 -0
  13. package/dist/admin/es-B7uy9a-2.js +258 -0
  14. package/dist/admin/es-DEp3j3kD.cjs +258 -0
  15. package/dist/admin/fr-D7y038Pm.cjs +258 -0
  16. package/dist/admin/fr-DxbtdEIA.js +258 -0
  17. package/dist/admin/id-DfW4ARVm.js +258 -0
  18. package/dist/admin/id-Wuw9jl-Y.cjs +258 -0
  19. package/dist/admin/index-BQdS3gc3.cjs +707 -0
  20. package/dist/admin/index-BYO34da4.js +708 -0
  21. package/dist/admin/index.cjs +5 -0
  22. package/dist/admin/index.js +5 -0
  23. package/dist/admin/index.mjs +5 -0
  24. package/dist/admin/pt-C0QaikKS.js +258 -0
  25. package/dist/admin/pt-Dj2oc3Ga.cjs +258 -0
  26. package/dist/admin/src/components/IssueCertificateModal.d.ts +9 -0
  27. package/dist/admin/src/components/SettingsTabHelpers.d.ts +13 -0
  28. package/dist/admin/src/index.d.ts +11 -0
  29. package/dist/admin/src/pages/CertificatesPage.d.ts +3 -0
  30. package/dist/admin/src/pages/SettingsPage.d.ts +3 -0
  31. package/dist/admin/src/translations/cmLabels.d.ts +1 -0
  32. package/dist/admin/src/translations/cmPlaceholders.d.ts +1 -0
  33. package/dist/admin/tr-BbeagXRY.js +258 -0
  34. package/dist/admin/tr-DlGl_XMF.cjs +258 -0
  35. package/dist/admin/ur-Bwn4TrjF.js +258 -0
  36. package/dist/admin/ur-qIlUtgqb.cjs +258 -0
  37. package/dist/admin/zh-Cmg5mEvP.js +258 -0
  38. package/dist/admin/zh-D-3QDsuQ.cjs +258 -0
  39. package/dist/package.json +3 -0
  40. package/dist/server/index.cjs +2282 -0
  41. package/dist/server/index.js +2282 -0
  42. package/dist/server/index.mjs +2282 -0
  43. package/dist/server/src/bootstrap.d.ts +5 -0
  44. package/dist/server/src/content-types/certificate/schema.json.d.ts +165 -0
  45. package/dist/server/src/content-types/index.d.ts +277 -0
  46. package/dist/server/src/content-types/template/schema.json.d.ts +118 -0
  47. package/dist/server/src/controllers/admin.d.ts +31 -0
  48. package/dist/server/src/controllers/content-api.d.ts +28 -0
  49. package/dist/server/src/controllers/index.d.ts +30 -0
  50. package/dist/server/src/index.d.ts +413 -0
  51. package/dist/server/src/register.d.ts +5 -0
  52. package/dist/server/src/routes/admin.d.ts +12 -0
  53. package/dist/server/src/routes/content-api.d.ts +12 -0
  54. package/dist/server/src/routes/index.d.ts +25 -0
  55. package/dist/server/src/services/docusign.d.ts +76 -0
  56. package/dist/server/src/services/index.d.ts +76 -0
  57. package/dist/server/src/services/issuance.d.ts +54 -0
  58. package/dist/server/src/services/issuanceHelpers.d.ts +47 -0
  59. package/dist/server/src/services/settings.d.ts +17 -0
  60. package/dist/server/src/services/urlHelpers.d.ts +2 -0
  61. package/dist/shared/calendarSystems.d.ts +3 -0
  62. package/dist/shared/cryptoEngine.d.ts +27 -0
  63. package/dist/shared/documentRenderer.d.ts +12 -0
  64. package/dist/shared/index.cjs +636 -0
  65. package/dist/shared/index.d.ts +5 -0
  66. package/dist/shared/index.js +613 -0
  67. package/dist/shared/index.mjs +613 -0
  68. package/dist/shared/pluginId.d.ts +2 -0
  69. package/dist/shared/types.d.ts +194 -0
  70. package/package.json +112 -3
@@ -0,0 +1,2282 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const shared = require("@vicacha-devs/strapi-plugin-calendar/shared");
4
+ const node_crypto = require("node:crypto");
5
+ const PLUGIN_ID = "certificates";
6
+ function formatSecondaryCalendars(date, systems, locale = "en") {
7
+ const result = {};
8
+ const dateStr = date.toISOString().slice(0, 10);
9
+ for (const sys of systems) {
10
+ if (!sys || !sys.enabled || !sys.id) continue;
11
+ const formatted = shared.formatDateInCalendarSystem(dateStr, sys, locale, {
12
+ compact: false
13
+ });
14
+ const compact = shared.formatDateInCalendarSystem(dateStr, sys, locale, {
15
+ compact: true
16
+ });
17
+ const parts = shared.extractCalendarParts(
18
+ date,
19
+ sys.intlCalendar,
20
+ sys.dayOffset ?? 0
21
+ );
22
+ if (formatted) {
23
+ result[sys.id] = {
24
+ calendarId: sys.id,
25
+ label: sys.label || sys.id,
26
+ formatted,
27
+ compact: compact || formatted,
28
+ intlCalendar: sys.intlCalendar,
29
+ year: parts?.year,
30
+ month: parts?.month,
31
+ day: parts?.day,
32
+ isOverridden: false
33
+ };
34
+ }
35
+ }
36
+ return result;
37
+ }
38
+ function computePublicKeyFingerprint(publicKeyPem) {
39
+ const normalized = String(publicKeyPem || "").trim();
40
+ if (!normalized) return "";
41
+ const digest = node_crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
42
+ return `SHA256:${digest.slice(0, 32).toUpperCase()}`;
43
+ }
44
+ function generateEd25519KeyPair() {
45
+ const { publicKey, privateKey } = node_crypto.generateKeyPairSync("ed25519", {
46
+ publicKeyEncoding: { type: "spki", format: "pem" },
47
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
48
+ });
49
+ return {
50
+ publicKeyPem: publicKey,
51
+ privateKeyPem: privateKey,
52
+ fingerprint: computePublicKeyFingerprint(publicKey)
53
+ };
54
+ }
55
+ function sanitizeSerialPrefix(prefix) {
56
+ const cleaned = String(prefix || "CERT").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 6);
57
+ return cleaned || "CERT";
58
+ }
59
+ function generateSerialNumber(prefix = "CERT", year) {
60
+ const safePrefix = sanitizeSerialPrefix(prefix);
61
+ const targetYear = typeof year === "number" && Number.isFinite(year) ? year : (/* @__PURE__ */ new Date()).getUTCFullYear();
62
+ const entropy = node_crypto.randomBytes(4).toString("hex").toUpperCase();
63
+ return `${safePrefix}-${targetYear}-${entropy}`;
64
+ }
65
+ function generateVerificationToken() {
66
+ return node_crypto.randomBytes(24).toString("hex");
67
+ }
68
+ function normalizeNationalId(rawId) {
69
+ return String(rawId || "").toUpperCase().replace(/[^A-Z0-9]/g, "");
70
+ }
71
+ function maskNationalId(rawId) {
72
+ const normalized = normalizeNationalId(rawId);
73
+ if (!normalized) return "";
74
+ if (normalized.length <= 4) {
75
+ return `***-${normalized}`;
76
+ }
77
+ const tail = normalized.slice(-4);
78
+ return `***-***-${tail}`;
79
+ }
80
+ function hashNationalId(rawId, serialNumber) {
81
+ const normalized = normalizeNationalId(rawId);
82
+ if (!normalized) return "";
83
+ const cleanSerial = String(serialNumber || "").trim().toUpperCase();
84
+ return node_crypto.createHash("sha256").update(`${cleanSerial}:${normalized}`, "utf8").digest("hex");
85
+ }
86
+ function verifyNationalIdHash(rawId, serialNumber, expectedHash) {
87
+ if (!expectedHash || !rawId) return false;
88
+ const computed = hashNationalId(rawId, serialNumber);
89
+ if (!computed) return false;
90
+ return verifyTokenConstantTime(computed, expectedHash);
91
+ }
92
+ function maskRecipientName(fullName) {
93
+ const parts = String(fullName || "").trim().split(/\s+/).filter(Boolean);
94
+ if (parts.length === 0) return "***";
95
+ if (parts.length === 1) {
96
+ const word = parts[0];
97
+ return word.length <= 2 ? `${word[0]}*` : `${word.slice(0, 2)}***`;
98
+ }
99
+ const first = parts[0];
100
+ const initials = parts.slice(1).map((part) => `${part.charAt(0).toUpperCase()}.`).join(" ");
101
+ return `${first} ${initials}`;
102
+ }
103
+ function cleanStr(val) {
104
+ return String(val ?? "").trim();
105
+ }
106
+ function serializeCanonicalPayload(payload) {
107
+ const signatories = payload.signatories ?? [];
108
+ const witnesses = payload.witnesses ?? [];
109
+ const ordered = {
110
+ version: "1.0",
111
+ serialNumber: cleanStr(payload.serialNumber),
112
+ templateSlug: cleanStr(payload.templateSlug),
113
+ organizationName: cleanStr(payload.organizationName),
114
+ recipientFullName: cleanStr(payload.recipientFullName),
115
+ recipientChosenName: cleanStr(payload.recipientChosenName),
116
+ nationalIdHash: cleanStr(payload.nationalIdHash),
117
+ issuedAt: cleanStr(payload.issuedAt),
118
+ expiresAt: cleanStr(payload.expiresAt),
119
+ signatories: signatories.map((s) => ({
120
+ name: cleanStr(s.name),
121
+ title: cleanStr(s.title)
122
+ })),
123
+ witnesses: witnesses.map((w) => ({
124
+ fullName: cleanStr(w.fullName),
125
+ role: cleanStr(w.role)
126
+ }))
127
+ };
128
+ return JSON.stringify(ordered);
129
+ }
130
+ function signCanonicalPayload(payload, privateKeyPem) {
131
+ const canonicalJson = serializeCanonicalPayload(payload);
132
+ const payloadHash = node_crypto.createHash("sha256").update(canonicalJson, "utf8").digest("hex");
133
+ const dataBuffer = Buffer.from(canonicalJson, "utf8");
134
+ const signature = node_crypto.sign(null, dataBuffer, privateKeyPem).toString("base64url");
135
+ return {
136
+ canonicalJson,
137
+ payloadHash,
138
+ signature
139
+ };
140
+ }
141
+ function verifyCanonicalPayloadSignature(payload, signatureBase64Url, publicKeyPem) {
142
+ try {
143
+ if (!signatureBase64Url || !publicKeyPem) return false;
144
+ const canonicalJson = serializeCanonicalPayload(payload);
145
+ const dataBuffer = Buffer.from(canonicalJson, "utf8");
146
+ const sigBuffer = Buffer.from(signatureBase64Url, "base64url");
147
+ return node_crypto.verify(null, dataBuffer, publicKeyPem, sigBuffer);
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+ function verifyTokenConstantTime(providedToken, expectedToken) {
153
+ if (!providedToken || !expectedToken) return false;
154
+ const a = Buffer.from(String(providedToken), "utf8");
155
+ const b = Buffer.from(String(expectedToken), "utf8");
156
+ if (a.length !== b.length || a.length === 0) {
157
+ return false;
158
+ }
159
+ return node_crypto.timingSafeEqual(a, b);
160
+ }
161
+ function computeDocuSignWebhookHmac(rawPayload, webhookSecret) {
162
+ return node_crypto.createHmac("sha256", webhookSecret).update(rawPayload, "utf8").digest("base64");
163
+ }
164
+ function verifyDocuSignWebhookHmac(rawPayload, signatureHeaderBase64, webhookSecret) {
165
+ if (!rawPayload || !signatureHeaderBase64 || !webhookSecret) {
166
+ return false;
167
+ }
168
+ const computedBase64 = computeDocuSignWebhookHmac(rawPayload, webhookSecret);
169
+ return verifyTokenConstantTime(
170
+ String(signatureHeaderBase64).trim(),
171
+ computedBase64
172
+ );
173
+ }
174
+ function computeDeterministicHex(input) {
175
+ const out = [];
176
+ for (let pass = 0; pass < 8; pass++) {
177
+ let h = (2166136261 ^ pass) >>> 0;
178
+ for (let i = 0; i < input.length; i++) {
179
+ h ^= input.charCodeAt(i);
180
+ h = Math.imul(h, 16777619) >>> 0;
181
+ }
182
+ out.push(h.toString(16).padStart(8, "0"));
183
+ }
184
+ return out.join("");
185
+ }
186
+ function escapeHtml(input) {
187
+ return String(input ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
188
+ }
189
+ function sanitizeHexColor(input, fallback = "#14532d") {
190
+ const cleaned = String(input || "").trim();
191
+ if (/^#[0-9a-fA-F]{6}$/.test(cleaned)) {
192
+ return cleaned;
193
+ }
194
+ return fallback;
195
+ }
196
+ function interpolateBodyTemplate(templateText, variables) {
197
+ const safeTemplate = escapeHtml(templateText);
198
+ return safeTemplate.replace(
199
+ /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g,
200
+ (_match, key) => {
201
+ const rawVal = variables[key] ?? "";
202
+ return `<strong>${escapeHtml(rawVal)}</strong>`;
203
+ }
204
+ );
205
+ }
206
+ function renderVerificationMatrixSvg(seedText, accentColor = "#14532d") {
207
+ const safeColor = sanitizeHexColor(accentColor);
208
+ const hashHex = computeDeterministicHex(seedText);
209
+ const size = 9;
210
+ const cells = [];
211
+ for (let row = 0; row < size; row++) {
212
+ for (let col = 0; col < size; col++) {
213
+ const isCornerFinder = row < 3 && col < 3 || row < 3 && col >= size - 3 || row >= size - 3 && col < 3;
214
+ const hexIdx = (row * size + col) % hashHex.length;
215
+ const nibble = parseInt(hashHex[hexIdx], 16);
216
+ const filled = isCornerFinder || nibble % 2 === 0;
217
+ if (filled) {
218
+ const x = 6 + col * 8;
219
+ const y = 6 + row * 8;
220
+ cells.push(
221
+ `<rect x="${x}" y="${y}" width="6.5" height="6.5" rx="1" fill="${safeColor}" />`
222
+ );
223
+ }
224
+ }
225
+ }
226
+ return [
227
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84 84" width="84" height="84" role="img" aria-label="Cryptographic Verification Matrix">',
228
+ `<rect x="1" y="1" width="82" height="82" rx="6" fill="#ffffff" stroke="${safeColor}" stroke-width="2" />`,
229
+ ...cells,
230
+ "</svg>"
231
+ ].join("");
232
+ }
233
+ function buildTemplateVariables(certificate, template, issuedDateFormatted, secondaryFormattedList) {
234
+ return {
235
+ recipientFullName: certificate.recipientFullName,
236
+ recipientChosenName: certificate.recipientChosenName || certificate.recipientFullName,
237
+ chosenName: certificate.recipientChosenName || "",
238
+ nationalIdMasked: certificate.nationalIdMasked || "N/A",
239
+ recipientNationality: certificate.recipientNationality || "",
240
+ issuedDateFormatted,
241
+ secondaryDateFormatted: secondaryFormattedList || issuedDateFormatted,
242
+ serialNumber: certificate.serialNumber,
243
+ organizationName: template.organizationName,
244
+ ...certificate.customData
245
+ };
246
+ }
247
+ function renderSignatoriesHtml(signatories) {
248
+ return (signatories || []).map(
249
+ (sig, idx) => `
250
+ <div class="sig-block">
251
+ <div class="sig-line"><span class="esign-anchor">/sig_${idx + 1}/</span></div>
252
+ <div class="sig-name">${escapeHtml(sig.name)}</div>
253
+ <div class="sig-title">${escapeHtml(sig.title)}</div>
254
+ ${sig.role ? `<div class="sig-role">${escapeHtml(sig.role)}</div>` : ""}
255
+ </div>`
256
+ ).join("");
257
+ }
258
+ function renderWitnessesHtml(witnesses) {
259
+ const list = witnesses || [];
260
+ if (list.length === 0) return "";
261
+ const cards = list.map(
262
+ (w, idx) => `
263
+ <div class="witness-card">
264
+ <div class="sig-line"><span class="esign-anchor">/wit_${idx + 1}/</span></div>
265
+ <div class="witness-name">${escapeHtml(w.fullName)}</div>
266
+ <div class="witness-meta">
267
+ ${w.role ? `<span>${escapeHtml(w.role)}</span>` : ""}
268
+ ${w.documentIdMasked ? `<span>ID: ${escapeHtml(w.documentIdMasked)}</span>` : ""}
269
+ </div>
270
+ </div>`
271
+ ).join("");
272
+ return `
273
+ <div class="witness-section">
274
+ <div class="witness-heading">Official Witnesses</div>
275
+ <div class="witness-grid">${cards}
276
+ </div>
277
+ </div>`;
278
+ }
279
+ function renderOptionalHtmlFragments(certificate, template, secondaryFormattedList) {
280
+ return {
281
+ statusBanner: certificate.status !== "issued" ? `<div class="watermark-alert">STATUS: ${escapeHtml(certificate.status.toUpperCase())}</div>` : "",
282
+ headerHtml: template.headerText ? `<div class="header-Invocation">${escapeHtml(template.headerText)}</div>` : "",
283
+ subheaderHtml: template.organizationSubheader ? `<div class="org-subheader">${escapeHtml(template.organizationSubheader)}</div>` : "",
284
+ chosenNameHtml: certificate.recipientChosenName ? `<div class="recipient-chosen">Chosen Name / Title: ${escapeHtml(certificate.recipientChosenName)}</div>` : "",
285
+ secondaryDateHtml: secondaryFormattedList ? `<span><strong>Secondary Calendar:</strong> ${escapeHtml(secondaryFormattedList)}</span>` : "",
286
+ nationalIdHtml: certificate.nationalIdMasked ? `<span><strong>Document ID:</strong> ${escapeHtml(certificate.nationalIdMasked)}</span>` : "",
287
+ esignLineHtml: certificate.esignEnvelopeId ? `<div>E-Sign Envelope (${escapeHtml(certificate.esignProvider || "docusign")}): ${escapeHtml(certificate.esignEnvelopeId)} [${escapeHtml(certificate.esignEnvelopeStatus || "sent")}]</div>` : ""
288
+ };
289
+ }
290
+ function renderPrintableCertificateHtml(params) {
291
+ const { certificate, template, verificationUrl } = params;
292
+ const accent = sanitizeHexColor(template.accentColor, "#14532d");
293
+ const issuedDateFormatted = certificate.issuedAt.slice(0, 10);
294
+ const secondaryFormattedList = Object.values(certificate.secondaryDates || {}).map((d) => `${d.label}: ${d.formatted}`).join(" • ");
295
+ const variables = buildTemplateVariables(
296
+ certificate,
297
+ template,
298
+ issuedDateFormatted,
299
+ secondaryFormattedList
300
+ );
301
+ const renderedBody = interpolateBodyTemplate(
302
+ template.bodyTemplate,
303
+ variables
304
+ );
305
+ const matrixSvg = renderVerificationMatrixSvg(
306
+ `${certificate.serialNumber}:${certificate.cryptographicSignature}`,
307
+ accent
308
+ );
309
+ const signatoriesHtml = renderSignatoriesHtml(certificate.signatories);
310
+ const witnessesHtml = renderWitnessesHtml(certificate.witnesses);
311
+ const fragments = renderOptionalHtmlFragments(
312
+ certificate,
313
+ template,
314
+ secondaryFormattedList
315
+ );
316
+ return `<!DOCTYPE html>
317
+ <html lang="en">
318
+ <head>
319
+ <meta charset="utf-8" />
320
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
321
+ <title>${escapeHtml(template.titleText)} — ${escapeHtml(certificate.serialNumber)}</title>
322
+ <style>
323
+ @page { size: A4 landscape; margin: 12mm; }
324
+ * { box-sizing: border-box; }
325
+ body {
326
+ margin: 0;
327
+ padding: 24px;
328
+ font-family: Georgia, "Times New Roman", serif;
329
+ background: #f8fafc;
330
+ color: #0f172a;
331
+ }
332
+ .certificate-frame {
333
+ max-width: 1020px;
334
+ margin: 0 auto;
335
+ background: #ffffff;
336
+ border: 3px solid ${accent};
337
+ padding: 10px;
338
+ box-shadow: 0 10px 25px rgba(15, 23, 42, 0.08);
339
+ position: relative;
340
+ }
341
+ .certificate-inner {
342
+ border: 1px solid ${accent};
343
+ padding: 36px 48px;
344
+ position: relative;
345
+ }
346
+ .watermark-alert {
347
+ background: #fef2f2;
348
+ color: #991b1b;
349
+ border: 2px solid #dc2626;
350
+ padding: 8px 16px;
351
+ text-align: center;
352
+ font-weight: bold;
353
+ letter-spacing: 0.1em;
354
+ margin-bottom: 16px;
355
+ }
356
+ .header-Invocation {
357
+ text-align: center;
358
+ font-size: 20px;
359
+ color: ${accent};
360
+ margin-bottom: 8px;
361
+ font-weight: 600;
362
+ }
363
+ .org-name {
364
+ text-align: center;
365
+ font-size: 24px;
366
+ font-weight: 700;
367
+ letter-spacing: 0.06em;
368
+ text-transform: uppercase;
369
+ color: #0f172a;
370
+ }
371
+ .org-subheader {
372
+ text-align: center;
373
+ font-size: 13px;
374
+ color: #475569;
375
+ margin-top: 4px;
376
+ margin-bottom: 24px;
377
+ }
378
+ .cert-title {
379
+ text-align: center;
380
+ font-size: 30px;
381
+ color: ${accent};
382
+ margin: 18px 0 24px;
383
+ font-weight: 700;
384
+ border-bottom: 2px solid ${accent};
385
+ display: inline-block;
386
+ padding-bottom: 6px;
387
+ }
388
+ .title-wrap { text-align: center; }
389
+ .recipient-banner {
390
+ text-align: center;
391
+ margin: 16px 0 20px;
392
+ }
393
+ .recipient-name {
394
+ font-size: 28px;
395
+ font-weight: 700;
396
+ color: #0f172a;
397
+ }
398
+ .recipient-chosen {
399
+ font-size: 18px;
400
+ color: ${accent};
401
+ margin-top: 4px;
402
+ font-style: italic;
403
+ }
404
+ .cert-body {
405
+ font-size: 16px;
406
+ line-height: 1.75;
407
+ text-align: center;
408
+ max-width: 820px;
409
+ margin: 0 auto 28px;
410
+ color: #1e293b;
411
+ }
412
+ .dates-bar {
413
+ display: flex;
414
+ justify-content: center;
415
+ gap: 28px;
416
+ font-size: 13px;
417
+ color: #334155;
418
+ margin-bottom: 28px;
419
+ flex-wrap: wrap;
420
+ }
421
+ .sig-row {
422
+ display: flex;
423
+ justify-content: space-around;
424
+ gap: 24px;
425
+ margin-top: 32px;
426
+ margin-bottom: 24px;
427
+ flex-wrap: wrap;
428
+ }
429
+ .sig-block {
430
+ min-width: 200px;
431
+ text-align: center;
432
+ }
433
+ .sig-line {
434
+ border-bottom: 1px solid #334155;
435
+ height: 36px;
436
+ margin-bottom: 8px;
437
+ }
438
+ .esign-anchor {
439
+ color: #ffffff;
440
+ font-size: 1px;
441
+ line-height: 1px;
442
+ opacity: 0.01;
443
+ user-select: none;
444
+ }
445
+ .sig-name { font-weight: 700; font-size: 15px; }
446
+ .sig-title { font-size: 13px; color: #475569; }
447
+ .sig-role { font-size: 12px; color: ${accent}; }
448
+ .witness-section {
449
+ border-top: 1px dashed #cbd5e1;
450
+ padding-top: 14px;
451
+ margin-top: 16px;
452
+ margin-bottom: 20px;
453
+ }
454
+ .witness-heading {
455
+ text-align: center;
456
+ font-size: 12px;
457
+ text-transform: uppercase;
458
+ letter-spacing: 0.08em;
459
+ color: #64748b;
460
+ margin-bottom: 10px;
461
+ }
462
+ .witness-grid {
463
+ display: flex;
464
+ justify-content: center;
465
+ gap: 24px;
466
+ flex-wrap: wrap;
467
+ }
468
+ .witness-card {
469
+ font-size: 13px;
470
+ text-align: center;
471
+ padding: 6px 14px;
472
+ background: #f8fafc;
473
+ border-radius: 6px;
474
+ border: 1px solid #e2e8f0;
475
+ }
476
+ .witness-name { font-weight: 600; }
477
+ .witness-meta { font-size: 11px; color: #64748b; display: flex; gap: 8px; justify-content: center; }
478
+ .crypto-footer {
479
+ display: flex;
480
+ align-items: center;
481
+ justify-content: space-between;
482
+ border-top: 2px solid #e2e8f0;
483
+ padding-top: 16px;
484
+ margin-top: 16px;
485
+ gap: 16px;
486
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
487
+ font-size: 11px;
488
+ color: #475569;
489
+ }
490
+ .crypto-details { line-height: 1.5; }
491
+ @media print {
492
+ body { background: #ffffff; padding: 0; }
493
+ .certificate-frame { box-shadow: none; }
494
+ }
495
+ </style>
496
+ </head>
497
+ <body>
498
+ <div class="certificate-frame">
499
+ <div class="certificate-inner">
500
+ ${fragments.statusBanner}
501
+ ${fragments.headerHtml}
502
+ <div class="org-name">${escapeHtml(template.organizationName)}</div>
503
+ ${fragments.subheaderHtml}
504
+ <div class="title-wrap">
505
+ <div class="cert-title">${escapeHtml(template.titleText)}</div>
506
+ </div>
507
+ <div class="recipient-banner">
508
+ <div class="recipient-name">${escapeHtml(certificate.recipientFullName)}</div>
509
+ ${fragments.chosenNameHtml}
510
+ </div>
511
+ <div class="cert-body">${renderedBody}</div>
512
+ <div class="dates-bar">
513
+ <span><strong>Serial No:</strong> ${escapeHtml(certificate.serialNumber)}</span>
514
+ <span><strong>Issued Date:</strong> ${escapeHtml(issuedDateFormatted)}</span>
515
+ ${fragments.secondaryDateHtml}
516
+ ${fragments.nationalIdHtml}
517
+ </div>
518
+ <div class="sig-row">${signatoriesHtml}</div>
519
+ ${witnessesHtml}
520
+ <div class="crypto-footer">
521
+ <div class="crypto-details">
522
+ <div><strong>CRYPTOGRAPHICALLY SIGNED RECORD (Ed25519)</strong></div>
523
+ <div>Serial: ${escapeHtml(certificate.serialNumber)} | Key Fingerprint: ${escapeHtml(certificate.publicKeyFingerprint)}</div>
524
+ <div>Payload SHA-256: ${escapeHtml(certificate.canonicalPayloadHash.slice(0, 32))}...</div>
525
+ <div>Signature (Ed25519): ${escapeHtml(certificate.cryptographicSignature.slice(0, 40))}...</div>
526
+ ${fragments.esignLineHtml}
527
+ <div>Verify Online: ${escapeHtml(verificationUrl)}</div>
528
+ </div>
529
+ <div>${matrixSvg}</div>
530
+ </div>
531
+ </div>
532
+ </div>
533
+ </body>
534
+ </html>`;
535
+ }
536
+ function escapeCsvCell(value) {
537
+ let str = String(value ?? "").replace(/\r?\n/g, " ").trim();
538
+ if (/^[=+\-@\t\r]/.test(str)) {
539
+ str = `'${str}`;
540
+ }
541
+ if (str.includes(",") || str.includes('"') || str.includes("'")) {
542
+ return `"${str.replace(/"/g, '""')}"`;
543
+ }
544
+ return str;
545
+ }
546
+ function exportCertificatesToCsv(records) {
547
+ const headers = [
548
+ "Serial Number",
549
+ "Template",
550
+ "Status",
551
+ "Recipient Full Name",
552
+ "Chosen Name / Title",
553
+ "Masked ID",
554
+ "Issued At",
555
+ "Expires At",
556
+ "Signatories",
557
+ "Witnesses Count",
558
+ "Key Fingerprint"
559
+ ];
560
+ const rows = records.map((r) => [
561
+ escapeCsvCell(r.serialNumber),
562
+ escapeCsvCell(r.templateName || r.templateSlug),
563
+ escapeCsvCell(r.status),
564
+ escapeCsvCell(r.recipientFullName),
565
+ escapeCsvCell(r.recipientChosenName || ""),
566
+ escapeCsvCell(r.nationalIdMasked || ""),
567
+ escapeCsvCell(r.issuedAt),
568
+ escapeCsvCell(r.expiresAt || ""),
569
+ escapeCsvCell((r.signatories || []).map((s) => s.name).join("; ")),
570
+ escapeCsvCell((r.witnesses || []).length),
571
+ escapeCsvCell(r.publicKeyFingerprint)
572
+ ]);
573
+ return [headers.join(","), ...rows.map((row) => row.join(","))].join("\n");
574
+ }
575
+ const bootstrap = async ({ strapi }) => {
576
+ const actions = [
577
+ {
578
+ section: "plugins",
579
+ displayName: "Access the Certificates Registry",
580
+ uid: "read",
581
+ pluginName: PLUGIN_ID
582
+ },
583
+ {
584
+ section: "plugins",
585
+ displayName: "Issue, Approve, and Revoke Certificates",
586
+ uid: "issue",
587
+ pluginName: PLUGIN_ID
588
+ },
589
+ {
590
+ section: "plugins",
591
+ displayName: "Manage Certificate Templates & Signing Keys",
592
+ uid: "settings",
593
+ pluginName: PLUGIN_ID
594
+ }
595
+ ];
596
+ await strapi.admin?.services?.permission?.actionProvider?.registerMany(
597
+ actions
598
+ );
599
+ const settingsService = strapi.plugin(PLUGIN_ID).service("settings");
600
+ const keyPair = await settingsService.getSigningKeyPair();
601
+ const issuanceService = strapi.plugin(PLUGIN_ID).service("issuance");
602
+ const seeded = await issuanceService.seedDefaultTemplates();
603
+ strapi.log.info(
604
+ `[${PLUGIN_ID}] Bootstrapped with Ed25519 key fingerprint ${keyPair.fingerprint} (seeded ${seeded} default template(s))`
605
+ );
606
+ };
607
+ const kind$1 = "collectionType";
608
+ const collectionName$1 = "certificates_records";
609
+ const info$1 = { "singularName": "certificate", "pluralName": "certificates", "displayName": "Issued Certificate", "description": "Cryptographically signed official certificate record with signatory and witness attestation" };
610
+ const options$1 = { "draftAndPublish": false };
611
+ const pluginOptions$1 = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
612
+ const attributes$1 = { "serialNumber": { "type": "string", "required": true, "unique": true, "maxLength": 64 }, "templateDocumentId": { "type": "string", "required": true, "maxLength": 120 }, "templateSlug": { "type": "string", "required": true, "maxLength": 120 }, "templateName": { "type": "string", "required": true, "maxLength": 160 }, "status": { "type": "enumeration", "enum": ["pending_approval", "issued", "revoked", "expired"], "default": "pending_approval", "required": true }, "recipientFullName": { "type": "string", "required": true, "maxLength": 200 }, "recipientChosenName": { "type": "string", "maxLength": 200 }, "recipientEmail": { "type": "email" }, "recipientPhone": { "type": "string", "maxLength": 64 }, "recipientNationality": { "type": "string", "maxLength": 120 }, "nationalIdMasked": { "type": "string", "maxLength": 32 }, "nationalIdHash": { "type": "string", "private": true, "maxLength": 128 }, "issuedAt": { "type": "string", "required": true, "maxLength": 40 }, "expiresAt": { "type": "string", "maxLength": 40 }, "revokedAt": { "type": "string", "maxLength": 40 }, "revocationReason": { "type": "text" }, "signatories": { "type": "json", "default": [] }, "witnesses": { "type": "json", "default": [] }, "customData": { "type": "json", "default": {} }, "secondaryDates": { "type": "json", "default": {} }, "canonicalPayloadHash": { "type": "string", "required": true, "maxLength": 128 }, "cryptographicSignature": { "type": "text", "required": true }, "publicKeyFingerprint": { "type": "string", "required": true, "maxLength": 96 }, "verificationToken": { "type": "string", "private": true, "maxLength": 128 }, "esignProvider": { "type": "enumeration", "enum": ["none", "docusign", "webhook"], "default": "none" }, "esignEnvelopeId": { "type": "string", "maxLength": 128 }, "esignEnvelopeStatus": { "type": "enumeration", "enum": ["not_sent", "sent", "delivered", "completed", "declined", "voided"], "default": "not_sent" }, "esignCompletedAt": { "type": "string", "maxLength": 40 }, "linkedBookingCode": { "type": "string", "maxLength": 64 }, "notes": { "type": "text" } };
613
+ const certificateSchema = {
614
+ kind: kind$1,
615
+ collectionName: collectionName$1,
616
+ info: info$1,
617
+ options: options$1,
618
+ pluginOptions: pluginOptions$1,
619
+ attributes: attributes$1
620
+ };
621
+ const kind = "collectionType";
622
+ const collectionName = "certificates_templates";
623
+ const info = { "singularName": "template", "pluralName": "templates", "displayName": "Certificate Template", "description": "Reusable official certificate template with signatory rules, witness requirements, and body statement" };
624
+ const options = { "draftAndPublish": false };
625
+ const pluginOptions = { "content-manager": { "visible": true }, "content-type-builder": { "visible": true } };
626
+ const attributes = { "name": { "type": "string", "required": true, "maxLength": 160 }, "slug": { "type": "uid", "targetField": "name", "required": true }, "description": { "type": "text" }, "serialPrefix": { "type": "string", "required": true, "default": "CERT", "maxLength": 8 }, "defaultValidityDays": { "type": "integer", "min": 0 }, "requiresWitnesses": { "type": "boolean", "default": false }, "minWitnesses": { "type": "integer", "default": 0, "min": 0, "max": 10 }, "requiresApproval": { "type": "boolean", "default": true }, "esignEnabled": { "type": "boolean", "default": false }, "esignProvider": { "type": "enumeration", "enum": ["none", "docusign", "webhook"], "default": "none" }, "organizationName": { "type": "string", "required": true, "maxLength": 200 }, "organizationSubheader": { "type": "string", "maxLength": 240 }, "headerText": { "type": "string", "maxLength": 240 }, "titleText": { "type": "string", "required": true, "maxLength": 200 }, "bodyTemplate": { "type": "text", "required": true }, "footerText": { "type": "text" }, "defaultSignatories": { "type": "json", "default": [] }, "customFieldsSchema": { "type": "json", "default": [] }, "secondaryCalendars": { "type": "json", "default": [] }, "accentColor": { "type": "string", "default": "#14532d", "maxLength": 16 }, "active": { "type": "boolean", "default": true } };
627
+ const templateSchema = {
628
+ kind,
629
+ collectionName,
630
+ info,
631
+ options,
632
+ pluginOptions,
633
+ attributes
634
+ };
635
+ const contentTypes = {
636
+ template: { schema: templateSchema },
637
+ certificate: { schema: certificateSchema }
638
+ };
639
+ const admin$1 = ({ strapi }) => {
640
+ const getSettings = () => strapi.plugin(PLUGIN_ID).service("settings");
641
+ const getIssuance = () => strapi.plugin(PLUGIN_ID).service("issuance");
642
+ const getDocuSign = () => strapi.plugin(PLUGIN_ID).service("docusign");
643
+ return {
644
+ async getSettings(ctx) {
645
+ const data = await getSettings().getSettings();
646
+ ctx.body = { data };
647
+ },
648
+ async updateSettings(ctx) {
649
+ const payload = ctx.request.body || {};
650
+ const data = await getSettings().updateSettings(payload);
651
+ ctx.body = { data };
652
+ },
653
+ async rotateKeys(ctx) {
654
+ const data = await getSettings().rotateKeyPair();
655
+ ctx.body = { data };
656
+ },
657
+ async listTemplates(ctx) {
658
+ const data = await getIssuance().listTemplates(false);
659
+ ctx.body = { data };
660
+ },
661
+ async createTemplate(ctx) {
662
+ try {
663
+ const payload = ctx.request.body || {};
664
+ const data = await getIssuance().createTemplate(payload);
665
+ ctx.body = { data };
666
+ } catch (err) {
667
+ return ctx.badRequest(
668
+ err.message || "Failed to create template"
669
+ );
670
+ }
671
+ },
672
+ async seedTemplates(ctx) {
673
+ const seededCount = await getIssuance().seedDefaultTemplates();
674
+ const templates = await getIssuance().listTemplates(false);
675
+ ctx.body = { data: { seededCount, templates } };
676
+ },
677
+ async listCertificates(ctx) {
678
+ const { status, templateSlug, search } = ctx.query;
679
+ const result = await getIssuance().listCertificates({
680
+ status,
681
+ templateSlug,
682
+ search
683
+ });
684
+ ctx.body = { data: result.records, meta: { stats: result.stats } };
685
+ },
686
+ async issueCertificate(ctx) {
687
+ try {
688
+ const body = ctx.request.body || {};
689
+ const autoApprove = body.sendViaESign === true ? false : body.autoApprove !== false;
690
+ let data = await getIssuance().issueCertificate({
691
+ ...body,
692
+ autoApprove
693
+ });
694
+ if (body.sendViaESign === true) {
695
+ const sent = await getDocuSign().sendEnvelopeForCertificate(
696
+ data.documentId
697
+ );
698
+ data = sent.certificate;
699
+ }
700
+ ctx.body = { data };
701
+ } catch (err) {
702
+ return ctx.badRequest(
703
+ err.message || "Failed to issue certificate"
704
+ );
705
+ }
706
+ },
707
+ async sendDocuSignEnvelope(ctx) {
708
+ const documentId = ctx.params.documentId;
709
+ if (!documentId) {
710
+ return ctx.badRequest("documentId is required");
711
+ }
712
+ try {
713
+ const result = await getDocuSign().sendEnvelopeForCertificate(documentId);
714
+ ctx.body = { data: result };
715
+ } catch (err) {
716
+ return ctx.badRequest(
717
+ err.message || "Failed to dispatch DocuSign envelope"
718
+ );
719
+ }
720
+ },
721
+ async approveCertificate(ctx) {
722
+ const documentId = ctx.params.documentId;
723
+ if (!documentId) {
724
+ return ctx.badRequest("documentId is required");
725
+ }
726
+ try {
727
+ const body = ctx.request.body || {};
728
+ const data = await getIssuance().approveCertificate(documentId, body);
729
+ ctx.body = { data };
730
+ } catch (err) {
731
+ return ctx.badRequest(
732
+ err.message || "Failed to approve certificate"
733
+ );
734
+ }
735
+ },
736
+ async revokeCertificate(ctx) {
737
+ const documentId = ctx.params.documentId;
738
+ if (!documentId) {
739
+ return ctx.badRequest("documentId is required");
740
+ }
741
+ try {
742
+ const reason = String(
743
+ (ctx.request.body || {}).reason || "Revoked by administrator"
744
+ );
745
+ const data = await getIssuance().revokeCertificate(documentId, reason);
746
+ ctx.body = { data };
747
+ } catch (err) {
748
+ return ctx.badRequest(
749
+ err.message || "Failed to revoke certificate"
750
+ );
751
+ }
752
+ },
753
+ async printCertificate(ctx) {
754
+ const documentId = ctx.params.documentId;
755
+ if (!documentId) {
756
+ return ctx.badRequest("documentId is required");
757
+ }
758
+ const rendered = await getIssuance().renderCertificateDocument(
759
+ { documentId },
760
+ { isAdmin: true }
761
+ );
762
+ if (!rendered) {
763
+ return ctx.notFound("Certificate not found");
764
+ }
765
+ ctx.set("Content-Type", "text/html; charset=utf-8");
766
+ ctx.set(
767
+ "Content-Security-Policy",
768
+ "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:;"
769
+ );
770
+ ctx.set("X-Content-Type-Options", "nosniff");
771
+ ctx.set("X-Frame-Options", "SAMEORIGIN");
772
+ ctx.set("Cache-Control", "no-store");
773
+ ctx.body = rendered.html;
774
+ },
775
+ async exportCsv(ctx) {
776
+ const { status, templateSlug } = ctx.query;
777
+ const csv = await getIssuance().exportCsv({ status, templateSlug });
778
+ ctx.set("Content-Type", "text/csv; charset=utf-8");
779
+ ctx.set(
780
+ "Content-Disposition",
781
+ 'attachment; filename="certificates-registry.csv"'
782
+ );
783
+ ctx.set("X-Content-Type-Options", "nosniff");
784
+ ctx.set("Cache-Control", "no-store");
785
+ ctx.body = csv;
786
+ }
787
+ };
788
+ };
789
+ const rateLimitStore = /* @__PURE__ */ new Map();
790
+ function checkRateLimit(key, maxRequests = 20, windowMs = 6e4) {
791
+ const now = Date.now();
792
+ const existing = rateLimitStore.get(key) || [];
793
+ const recent = existing.filter((ts) => now - ts < windowMs);
794
+ if (recent.length >= maxRequests) {
795
+ rateLimitStore.set(key, recent);
796
+ return false;
797
+ }
798
+ recent.push(now);
799
+ rateLimitStore.set(key, recent);
800
+ return true;
801
+ }
802
+ function normalizeSignatories(raw) {
803
+ if (!Array.isArray(raw)) return [];
804
+ return raw.filter(
805
+ (item) => Boolean(item && typeof item === "object")
806
+ ).map((item) => ({
807
+ name: String(item.name || "").trim(),
808
+ title: String(item.title || "").trim(),
809
+ role: item.role ? String(item.role).trim() : void 0,
810
+ email: item.email ? String(item.email).trim() : void 0,
811
+ routingOrder: typeof item.routingOrder === "number" && Number.isFinite(item.routingOrder) ? Math.max(1, Math.trunc(item.routingOrder)) : void 0,
812
+ signatureImageUrl: item.signatureImageUrl ? String(item.signatureImageUrl).trim() : void 0,
813
+ signedAt: item.signedAt ? String(item.signedAt).trim() : void 0
814
+ })).filter((s) => Boolean(s.name && s.title));
815
+ }
816
+ function normalizeWitnesses(raw) {
817
+ if (!Array.isArray(raw)) return [];
818
+ return raw.filter(
819
+ (item) => Boolean(item && typeof item === "object")
820
+ ).map((item) => {
821
+ const rawDocId = item.documentIdRaw ? String(item.documentIdRaw) : void 0;
822
+ const maskedDocId = item.documentIdMasked ? String(item.documentIdMasked).trim() : rawDocId ? maskNationalId(rawDocId) : void 0;
823
+ return {
824
+ fullName: String(item.fullName || "").trim(),
825
+ documentIdMasked: maskedDocId,
826
+ role: item.role ? String(item.role).trim() : void 0,
827
+ email: item.email ? String(item.email).trim() : void 0,
828
+ routingOrder: typeof item.routingOrder === "number" && Number.isFinite(item.routingOrder) ? Math.max(1, Math.trunc(item.routingOrder)) : void 0,
829
+ signedAt: item.signedAt ? String(item.signedAt).trim() : void 0
830
+ };
831
+ }).filter((w) => Boolean(w.fullName));
832
+ }
833
+ function asStr(val, fallback = "") {
834
+ return val ? String(val) : fallback;
835
+ }
836
+ function asOptStr(val) {
837
+ return val ? String(val) : void 0;
838
+ }
839
+ function asNullStr(val) {
840
+ return val ? String(val) : null;
841
+ }
842
+ function asArr(val, fallback = []) {
843
+ return Array.isArray(val) ? val : fallback;
844
+ }
845
+ function asObj(val) {
846
+ return val && typeof val === "object" ? val : {};
847
+ }
848
+ function asEsignProvider(val) {
849
+ const raw = asStr(val, "none");
850
+ return raw === "docusign" || raw === "webhook" ? raw : "none";
851
+ }
852
+ function asNonNegativeInt(val, fallback = 0) {
853
+ return typeof val === "number" && Number.isFinite(val) ? Math.max(0, Math.trunc(val)) : fallback;
854
+ }
855
+ function normalizeTemplateRecord(doc) {
856
+ return {
857
+ id: doc.id,
858
+ documentId: asStr(doc.documentId, asStr(doc.id)),
859
+ name: asStr(doc.name),
860
+ slug: asStr(doc.slug),
861
+ description: asOptStr(doc.description),
862
+ serialPrefix: asStr(doc.serialPrefix, "CERT"),
863
+ defaultValidityDays: typeof doc.defaultValidityDays === "number" ? doc.defaultValidityDays : null,
864
+ requiresWitnesses: Boolean(doc.requiresWitnesses),
865
+ minWitnesses: asNonNegativeInt(doc.minWitnesses, 0),
866
+ requiresApproval: doc.requiresApproval !== false,
867
+ esignEnabled: Boolean(doc.esignEnabled),
868
+ esignProvider: asEsignProvider(doc.esignProvider),
869
+ organizationName: asStr(doc.organizationName),
870
+ organizationSubheader: asOptStr(doc.organizationSubheader),
871
+ headerText: asOptStr(doc.headerText),
872
+ titleText: asStr(doc.titleText),
873
+ bodyTemplate: asStr(doc.bodyTemplate),
874
+ footerText: asOptStr(doc.footerText),
875
+ defaultSignatories: normalizeSignatories(doc.defaultSignatories),
876
+ customFieldsSchema: asArr(doc.customFieldsSchema),
877
+ secondaryCalendars: asArr(doc.secondaryCalendars),
878
+ accentColor: asStr(doc.accentColor, "#14532d"),
879
+ active: doc.active !== false
880
+ };
881
+ }
882
+ function normalizeCertificateRecord(doc, includePrivate = false) {
883
+ const record = {
884
+ id: doc.id,
885
+ documentId: asStr(doc.documentId, asStr(doc.id)),
886
+ serialNumber: asStr(doc.serialNumber),
887
+ templateDocumentId: asStr(doc.templateDocumentId),
888
+ templateSlug: asStr(doc.templateSlug),
889
+ templateName: asStr(doc.templateName),
890
+ status: asStr(doc.status, "pending_approval"),
891
+ recipientFullName: asStr(doc.recipientFullName),
892
+ recipientChosenName: asOptStr(doc.recipientChosenName),
893
+ recipientEmail: asOptStr(doc.recipientEmail),
894
+ recipientPhone: asOptStr(doc.recipientPhone),
895
+ recipientNationality: asOptStr(doc.recipientNationality),
896
+ nationalIdMasked: asOptStr(doc.nationalIdMasked),
897
+ issuedAt: asStr(doc.issuedAt),
898
+ expiresAt: asNullStr(doc.expiresAt),
899
+ revokedAt: asNullStr(doc.revokedAt),
900
+ revocationReason: asNullStr(doc.revocationReason),
901
+ signatories: normalizeSignatories(doc.signatories),
902
+ witnesses: normalizeWitnesses(doc.witnesses),
903
+ customData: asObj(doc.customData),
904
+ secondaryDates: asObj(
905
+ doc.secondaryDates
906
+ ),
907
+ canonicalPayloadHash: asStr(doc.canonicalPayloadHash),
908
+ cryptographicSignature: asStr(doc.cryptographicSignature),
909
+ publicKeyFingerprint: asStr(doc.publicKeyFingerprint),
910
+ esignProvider: asEsignProvider(doc.esignProvider),
911
+ esignEnvelopeId: asOptStr(doc.esignEnvelopeId),
912
+ esignEnvelopeStatus: asStr(
913
+ doc.esignEnvelopeStatus,
914
+ "not_sent"
915
+ ),
916
+ esignCompletedAt: asNullStr(doc.esignCompletedAt),
917
+ linkedBookingCode: asOptStr(doc.linkedBookingCode),
918
+ notes: asOptStr(doc.notes)
919
+ };
920
+ if (includePrivate) {
921
+ record.nationalIdHash = asOptStr(doc.nationalIdHash);
922
+ record.verificationToken = asOptStr(doc.verificationToken);
923
+ }
924
+ return record;
925
+ }
926
+ function buildCanonicalPayload(params) {
927
+ return {
928
+ version: "1.0",
929
+ serialNumber: params.serialNumber,
930
+ templateSlug: params.templateSlug,
931
+ organizationName: params.organizationName,
932
+ recipientFullName: params.recipientFullName,
933
+ recipientChosenName: params.recipientChosenName || "",
934
+ nationalIdHash: params.nationalIdHash || "",
935
+ issuedAt: params.issuedAt,
936
+ expiresAt: params.expiresAt || "",
937
+ signatories: (params.signatories || []).map((s) => ({
938
+ name: s.name,
939
+ title: s.title
940
+ })),
941
+ witnesses: (params.witnesses || []).map((w) => ({
942
+ fullName: w.fullName,
943
+ role: w.role || "Witness"
944
+ }))
945
+ };
946
+ }
947
+ function evaluateCertificateVerification(params) {
948
+ const { cert, template, settings: settings2, options: options2 } = params;
949
+ const organizationName = template?.organizationName || settings2.organizationName;
950
+ const canonicalPayload = buildCanonicalPayload({
951
+ serialNumber: cert.serialNumber,
952
+ templateSlug: cert.templateSlug,
953
+ organizationName,
954
+ recipientFullName: cert.recipientFullName,
955
+ recipientChosenName: cert.recipientChosenName,
956
+ nationalIdHash: cert.nationalIdHash,
957
+ issuedAt: cert.issuedAt,
958
+ expiresAt: cert.expiresAt,
959
+ signatories: cert.signatories,
960
+ witnesses: cert.witnesses
961
+ });
962
+ const cryptographicallyVerified = verifyCanonicalPayloadSignature(
963
+ canonicalPayload,
964
+ cert.cryptographicSignature,
965
+ settings2.publicKeyPem
966
+ );
967
+ let effectiveStatus = cert.status;
968
+ if (effectiveStatus === "issued" && cert.expiresAt && new Date(cert.expiresAt).getTime() < Date.now()) {
969
+ effectiveStatus = "expired";
970
+ }
971
+ const isTokenValid = verifyTokenConstantTime(
972
+ options2?.token,
973
+ cert.verificationToken
974
+ );
975
+ const isUnmasked = isTokenValid || !settings2.maskPublicRecipientName;
976
+ const nationalIdMatches = options2?.nationalId ? verifyNationalIdHash(
977
+ options2.nationalId,
978
+ cert.serialNumber,
979
+ cert.nationalIdHash
980
+ ) : void 0;
981
+ return {
982
+ valid: effectiveStatus === "issued" && cryptographicallyVerified,
983
+ cryptographicallyVerified,
984
+ status: effectiveStatus,
985
+ serialNumber: cert.serialNumber,
986
+ templateSlug: cert.templateSlug,
987
+ templateName: cert.templateName,
988
+ organizationName,
989
+ recipientNameDisplay: isUnmasked ? cert.recipientFullName : maskRecipientName(cert.recipientFullName),
990
+ recipientChosenName: cert.recipientChosenName,
991
+ nationalIdMasked: cert.nationalIdMasked,
992
+ nationalIdMatches,
993
+ issuedAt: cert.issuedAt,
994
+ expiresAt: cert.expiresAt,
995
+ revokedAt: cert.revokedAt,
996
+ revocationReason: cert.revocationReason,
997
+ secondaryDates: cert.secondaryDates,
998
+ signatories: cert.signatories,
999
+ witnesses: cert.witnesses.map((w) => ({
1000
+ fullName: isUnmasked ? w.fullName : maskRecipientName(w.fullName),
1001
+ role: w.role
1002
+ })),
1003
+ canonicalPayloadHash: cert.canonicalPayloadHash,
1004
+ cryptographicSignature: cert.cryptographicSignature,
1005
+ publicKeyFingerprint: cert.publicKeyFingerprint,
1006
+ publicKeyPem: settings2.publicKeyPem,
1007
+ esignProvider: cert.esignProvider,
1008
+ esignEnvelopeId: cert.esignEnvelopeId,
1009
+ esignEnvelopeStatus: cert.esignEnvelopeStatus,
1010
+ esignCompletedAt: cert.esignCompletedAt,
1011
+ isUnmasked,
1012
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
1013
+ };
1014
+ }
1015
+ function renderPrintableFromRecord(params) {
1016
+ const { cert, template, settings: settings2, options: options2 } = params;
1017
+ if (!options2?.isAdmin) {
1018
+ const tokenOk = verifyTokenConstantTime(
1019
+ options2?.token,
1020
+ cert.verificationToken
1021
+ );
1022
+ if (!tokenOk) {
1023
+ throw new Error("Valid verificationToken required to print certificate");
1024
+ }
1025
+ }
1026
+ const effectiveTemplate = template || {
1027
+ documentId: "fallback",
1028
+ name: cert.templateName,
1029
+ slug: cert.templateSlug,
1030
+ serialPrefix: "CERT",
1031
+ requiresWitnesses: false,
1032
+ minWitnesses: 0,
1033
+ requiresApproval: false,
1034
+ organizationName: settings2.organizationName,
1035
+ organizationSubheader: settings2.organizationSubheader,
1036
+ titleText: cert.templateName,
1037
+ bodyTemplate: "This is to certify that {{recipientFullName}} has been officially attested by {{organizationName}} on {{issuedDateFormatted}}.",
1038
+ defaultSignatories: cert.signatories,
1039
+ active: true
1040
+ };
1041
+ const verificationUrl = `${settings2.verificationBaseUrl}/${encodeURIComponent(
1042
+ cert.serialNumber
1043
+ )}`;
1044
+ const html = renderPrintableCertificateHtml({
1045
+ certificate: cert,
1046
+ template: effectiveTemplate,
1047
+ verificationUrl
1048
+ });
1049
+ return { html, serialNumber: cert.serialNumber };
1050
+ }
1051
+ function filterAndComputeCertificateStats(allRecords, filters) {
1052
+ const stats = {
1053
+ total: allRecords.length,
1054
+ issued: allRecords.filter((r) => r.status === "issued").length,
1055
+ pending_approval: allRecords.filter((r) => r.status === "pending_approval").length,
1056
+ revoked: allRecords.filter((r) => r.status === "revoked").length,
1057
+ expired: allRecords.filter((r) => r.status === "expired").length
1058
+ };
1059
+ const records = allRecords.filter((r) => {
1060
+ if (filters?.status && r.status !== filters.status) return false;
1061
+ if (filters?.templateSlug && r.templateSlug !== filters.templateSlug) {
1062
+ return false;
1063
+ }
1064
+ if (filters?.search) {
1065
+ const q = filters.search.toLowerCase();
1066
+ const hay = [
1067
+ r.serialNumber,
1068
+ r.recipientFullName,
1069
+ r.recipientChosenName || "",
1070
+ r.recipientEmail || ""
1071
+ ].join(" ").toLowerCase();
1072
+ if (!hay.includes(q)) return false;
1073
+ }
1074
+ return true;
1075
+ });
1076
+ return { records, stats };
1077
+ }
1078
+ const contentApi$1 = ({ strapi }) => {
1079
+ const getSettings = () => strapi.plugin(PLUGIN_ID).service("settings");
1080
+ const getIssuance = () => strapi.plugin(PLUGIN_ID).service("issuance");
1081
+ const getDocuSign = () => strapi.plugin(PLUGIN_ID).service("docusign");
1082
+ return {
1083
+ async listPublicTemplates(ctx) {
1084
+ const templates = await getIssuance().listTemplates(true);
1085
+ const sanitized = templates.map((t) => ({
1086
+ documentId: t.documentId,
1087
+ name: t.name,
1088
+ slug: t.slug,
1089
+ description: t.description,
1090
+ requiresWitnesses: t.requiresWitnesses,
1091
+ minWitnesses: t.minWitnesses,
1092
+ organizationName: t.organizationName,
1093
+ titleText: t.titleText,
1094
+ customFieldsSchema: t.customFieldsSchema || []
1095
+ }));
1096
+ ctx.body = { data: sanitized };
1097
+ },
1098
+ async getPublicKey(ctx) {
1099
+ const settings2 = await getSettings().getSettings();
1100
+ ctx.body = {
1101
+ data: {
1102
+ organizationName: settings2.organizationName,
1103
+ algorithm: "Ed25519",
1104
+ publicKeyFingerprint: settings2.publicKeyFingerprint,
1105
+ publicKeyPem: settings2.publicKeyPem
1106
+ }
1107
+ };
1108
+ },
1109
+ async applyCertificate(ctx) {
1110
+ const ipKey = `apply:${ctx.ip || "unknown"}`;
1111
+ if (!checkRateLimit(ipKey, 10, 6e4)) {
1112
+ ctx.status = 429;
1113
+ ctx.body = {
1114
+ error: { message: "Too many certificate requests. Please wait." }
1115
+ };
1116
+ return;
1117
+ }
1118
+ const settings2 = await getSettings().getSettings();
1119
+ if (!settings2.allowPublicApplications) {
1120
+ return ctx.forbidden("Public certificate applications are disabled");
1121
+ }
1122
+ try {
1123
+ const body = ctx.request.body || {};
1124
+ const created = await getIssuance().issueCertificate({
1125
+ ...body,
1126
+ autoApprove: false
1127
+ });
1128
+ ctx.body = {
1129
+ data: {
1130
+ serialNumber: created.serialNumber,
1131
+ status: created.status,
1132
+ templateSlug: created.templateSlug,
1133
+ templateName: created.templateName,
1134
+ recipientFullName: created.recipientFullName,
1135
+ recipientChosenName: created.recipientChosenName,
1136
+ issuedAt: created.issuedAt,
1137
+ verificationToken: created.verificationToken
1138
+ }
1139
+ };
1140
+ } catch (err) {
1141
+ return ctx.badRequest(
1142
+ err.message || "Invalid certificate application"
1143
+ );
1144
+ }
1145
+ },
1146
+ async verifyBySerial(ctx) {
1147
+ const ipKey = `verify:${ctx.ip || "unknown"}`;
1148
+ if (!checkRateLimit(ipKey, 30, 6e4)) {
1149
+ ctx.status = 429;
1150
+ ctx.body = {
1151
+ error: { message: "Rate limit exceeded for verification." }
1152
+ };
1153
+ return;
1154
+ }
1155
+ const serial = ctx.params.serial;
1156
+ if (!serial) {
1157
+ return ctx.badRequest("Certificate serial number is required");
1158
+ }
1159
+ const result = await getIssuance().verifyCertificateBySerial(serial, {
1160
+ token: ctx.query.token,
1161
+ nationalId: ctx.query.nationalId
1162
+ });
1163
+ if (!result) {
1164
+ return ctx.notFound(
1165
+ "Certificate serial number not found in official registry"
1166
+ );
1167
+ }
1168
+ ctx.set("Cache-Control", "no-store");
1169
+ ctx.body = { data: result };
1170
+ },
1171
+ async downloadPrintableHtml(ctx) {
1172
+ const serial = ctx.params.serial;
1173
+ const token = ctx.query.token;
1174
+ if (!serial || !token) {
1175
+ return ctx.badRequest(
1176
+ "Both serial and verification token are required"
1177
+ );
1178
+ }
1179
+ try {
1180
+ const rendered = await getIssuance().renderCertificateDocument(
1181
+ { serialNumber: serial },
1182
+ { token, isAdmin: false }
1183
+ );
1184
+ if (!rendered) {
1185
+ return ctx.notFound("Certificate not found");
1186
+ }
1187
+ ctx.set("Content-Type", "text/html; charset=utf-8");
1188
+ ctx.set(
1189
+ "Content-Security-Policy",
1190
+ "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:;"
1191
+ );
1192
+ ctx.set("X-Content-Type-Options", "nosniff");
1193
+ ctx.set("X-Frame-Options", "SAMEORIGIN");
1194
+ ctx.set("Cache-Control", "no-store");
1195
+ ctx.body = rendered.html;
1196
+ } catch (err) {
1197
+ return ctx.forbidden(
1198
+ err.message || "Invalid verification token"
1199
+ );
1200
+ }
1201
+ },
1202
+ async docusignWebhook(ctx) {
1203
+ const ipKey = `webhook:${ctx.ip || "unknown"}`;
1204
+ if (!checkRateLimit(ipKey, 60, 6e4)) {
1205
+ ctx.status = 429;
1206
+ ctx.body = { error: { message: "Webhook rate limit exceeded." } };
1207
+ return;
1208
+ }
1209
+ const payload = ctx.request.body || {};
1210
+ const rawBody = typeof ctx.request.rawBody === "string" && ctx.request.rawBody ? ctx.request.rawBody : JSON.stringify(payload);
1211
+ const signatureHeader = ctx.headers?.["x-docusign-signature-1"] || ctx.headers?.["X-DocuSign-Signature-1"];
1212
+ try {
1213
+ const result = await getDocuSign().handleWebhookEvent({
1214
+ rawBody,
1215
+ signatureHeader,
1216
+ payload
1217
+ });
1218
+ ctx.body = {
1219
+ data: {
1220
+ envelopeId: result.envelopeId,
1221
+ envelopeStatus: result.envelopeStatus,
1222
+ serialNumber: result.certificate.serialNumber,
1223
+ status: result.certificate.status
1224
+ }
1225
+ };
1226
+ } catch (err) {
1227
+ return ctx.forbidden(
1228
+ err.message || "DocuSign webhook verification failed"
1229
+ );
1230
+ }
1231
+ }
1232
+ };
1233
+ };
1234
+ const controllers = {
1235
+ admin: admin$1,
1236
+ "content-api": contentApi$1
1237
+ };
1238
+ function mergeAttributesIntoTarget(target, attrName, attrDef) {
1239
+ if (!target) return;
1240
+ target[attrName] = {
1241
+ ...target[attrName],
1242
+ ...attrDef
1243
+ };
1244
+ }
1245
+ function applyExtraAttributesToSchema(schema, extraAttributes) {
1246
+ for (const [attrName, attrDef] of Object.entries(extraAttributes)) {
1247
+ if (!attrName || !attrDef || typeof attrDef !== "object") continue;
1248
+ mergeAttributesIntoTarget(schema?.attributes, attrName, attrDef);
1249
+ mergeAttributesIntoTarget(
1250
+ schema?.__schema__?.attributes,
1251
+ attrName,
1252
+ attrDef
1253
+ );
1254
+ }
1255
+ }
1256
+ const register = ({ strapi }) => {
1257
+ const config = strapi.config?.get?.(`plugin::${PLUGIN_ID}`) ?? {};
1258
+ for (const [targetKey, extraAttributes] of Object.entries(
1259
+ config.schemaExtensions ?? {}
1260
+ )) {
1261
+ if (!extraAttributes || typeof extraAttributes !== "object") continue;
1262
+ const resolvedUid = targetKey.startsWith("plugin::") ? targetKey : `plugin::${PLUGIN_ID}.${targetKey}`;
1263
+ const schema = strapi.contentTypes?.[resolvedUid];
1264
+ applyExtraAttributesToSchema(schema, extraAttributes);
1265
+ }
1266
+ strapi.log.debug(`[${PLUGIN_ID}] Registering official certificates plugin`);
1267
+ };
1268
+ const admin = {
1269
+ type: "admin",
1270
+ routes: [
1271
+ {
1272
+ method: "GET",
1273
+ path: "/settings",
1274
+ handler: "admin.getSettings",
1275
+ config: { policies: [] }
1276
+ },
1277
+ {
1278
+ method: "PUT",
1279
+ path: "/settings",
1280
+ handler: "admin.updateSettings",
1281
+ config: { policies: [] }
1282
+ },
1283
+ {
1284
+ method: "POST",
1285
+ path: "/settings/rotate-keys",
1286
+ handler: "admin.rotateKeys",
1287
+ config: { policies: [] }
1288
+ },
1289
+ {
1290
+ method: "GET",
1291
+ path: "/templates",
1292
+ handler: "admin.listTemplates",
1293
+ config: { policies: [] }
1294
+ },
1295
+ {
1296
+ method: "POST",
1297
+ path: "/templates",
1298
+ handler: "admin.createTemplate",
1299
+ config: { policies: [] }
1300
+ },
1301
+ {
1302
+ method: "POST",
1303
+ path: "/templates/seed",
1304
+ handler: "admin.seedTemplates",
1305
+ config: { policies: [] }
1306
+ },
1307
+ {
1308
+ method: "GET",
1309
+ path: "/certificates",
1310
+ handler: "admin.listCertificates",
1311
+ config: { policies: [] }
1312
+ },
1313
+ {
1314
+ method: "POST",
1315
+ path: "/certificates/issue",
1316
+ handler: "admin.issueCertificate",
1317
+ config: { policies: [] }
1318
+ },
1319
+ {
1320
+ method: "POST",
1321
+ path: "/certificates/:documentId/send-docusign",
1322
+ handler: "admin.sendDocuSignEnvelope",
1323
+ config: { policies: [] }
1324
+ },
1325
+ {
1326
+ method: "PUT",
1327
+ path: "/certificates/:documentId/approve",
1328
+ handler: "admin.approveCertificate",
1329
+ config: { policies: [] }
1330
+ },
1331
+ {
1332
+ method: "PUT",
1333
+ path: "/certificates/:documentId/revoke",
1334
+ handler: "admin.revokeCertificate",
1335
+ config: { policies: [] }
1336
+ },
1337
+ {
1338
+ method: "GET",
1339
+ path: "/certificates/:documentId/print",
1340
+ handler: "admin.printCertificate",
1341
+ config: { policies: [] }
1342
+ },
1343
+ {
1344
+ method: "GET",
1345
+ path: "/certificates/export/csv",
1346
+ handler: "admin.exportCsv",
1347
+ config: { policies: [] }
1348
+ }
1349
+ ]
1350
+ };
1351
+ const contentApi = {
1352
+ type: "content-api",
1353
+ routes: [
1354
+ {
1355
+ method: "GET",
1356
+ path: "/templates",
1357
+ handler: "content-api.listPublicTemplates",
1358
+ config: { auth: false }
1359
+ },
1360
+ {
1361
+ method: "GET",
1362
+ path: "/public-key",
1363
+ handler: "content-api.getPublicKey",
1364
+ config: { auth: false }
1365
+ },
1366
+ {
1367
+ method: "POST",
1368
+ path: "/apply",
1369
+ handler: "content-api.applyCertificate",
1370
+ config: { auth: false }
1371
+ },
1372
+ {
1373
+ method: "GET",
1374
+ path: "/verify/:serial",
1375
+ handler: "content-api.verifyBySerial",
1376
+ config: { auth: false }
1377
+ },
1378
+ {
1379
+ method: "GET",
1380
+ path: "/verify/:serial/print",
1381
+ handler: "content-api.downloadPrintableHtml",
1382
+ config: { auth: false }
1383
+ },
1384
+ {
1385
+ method: "POST",
1386
+ path: "/webhooks/docusign",
1387
+ handler: "content-api.docusignWebhook",
1388
+ config: { auth: false }
1389
+ }
1390
+ ]
1391
+ };
1392
+ const routes = {
1393
+ admin,
1394
+ "content-api": contentApi
1395
+ };
1396
+ function stripTrailingSlashes(value) {
1397
+ let end = value.length;
1398
+ while (end > 0 && value.charCodeAt(end - 1) === 47) {
1399
+ end -= 1;
1400
+ }
1401
+ return end === value.length ? value : value.slice(0, end);
1402
+ }
1403
+ const CERTIFICATE_UID$1 = "plugin::certificates.certificate";
1404
+ const PRIVATE_HOST_PREFIXES = ["127.", "10.", "192.168.", "169.254."];
1405
+ function isPrivateOrLoopbackHost(host) {
1406
+ if (!host || host === "localhost") return true;
1407
+ if (PRIVATE_HOST_PREFIXES.some((prefix) => host.startsWith(prefix))) {
1408
+ return true;
1409
+ }
1410
+ return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host);
1411
+ }
1412
+ function validateDocuSignBaseUrl(rawUrl) {
1413
+ const cleaned = stripTrailingSlashes(String(rawUrl || "").trim());
1414
+ if (!cleaned.startsWith("https://")) {
1415
+ throw new Error("DocuSign baseUrl must use HTTPS");
1416
+ }
1417
+ const withoutScheme = cleaned.slice("https://".length);
1418
+ const host = withoutScheme.split("/")[0].split(":")[0].toLowerCase();
1419
+ if (isPrivateOrLoopbackHost(host)) {
1420
+ throw new Error("DocuSign baseUrl cannot target private or loopback hosts");
1421
+ }
1422
+ const isOfficialDocuSign = host.endsWith(".docusign.net") || host.endsWith(".docusign.com");
1423
+ if (!isOfficialDocuSign) {
1424
+ throw new Error(
1425
+ "DocuSign baseUrl must belong to *.docusign.net or *.docusign.com"
1426
+ );
1427
+ }
1428
+ return cleaned;
1429
+ }
1430
+ function buildDocuSignEnvelopePayload(params) {
1431
+ const { certificate, htmlDocument, emailSubjectPrefix } = params;
1432
+ const signers = [];
1433
+ let recipientIdSeq = 1;
1434
+ (certificate.witnesses || []).forEach((w, idx) => {
1435
+ if (!w.email) return;
1436
+ signers.push({
1437
+ email: w.email,
1438
+ name: w.fullName,
1439
+ recipientId: String(recipientIdSeq++),
1440
+ routingOrder: String(w.routingOrder ?? 1),
1441
+ roleName: w.role || `Witness ${idx + 1}`,
1442
+ tabs: {
1443
+ signHereTabs: [
1444
+ {
1445
+ anchorString: `/wit_${idx + 1}/`,
1446
+ anchorUnits: "pixels",
1447
+ anchorXOffset: "0",
1448
+ anchorYOffset: "-6"
1449
+ }
1450
+ ]
1451
+ }
1452
+ });
1453
+ });
1454
+ (certificate.signatories || []).forEach((s, idx) => {
1455
+ if (!s.email) return;
1456
+ signers.push({
1457
+ email: s.email,
1458
+ name: s.name,
1459
+ recipientId: String(recipientIdSeq++),
1460
+ routingOrder: String(s.routingOrder ?? 2),
1461
+ roleName: s.role || s.title,
1462
+ tabs: {
1463
+ signHereTabs: [
1464
+ {
1465
+ anchorString: `/sig_${idx + 1}/`,
1466
+ anchorUnits: "pixels",
1467
+ anchorXOffset: "0",
1468
+ anchorYOffset: "-6"
1469
+ }
1470
+ ]
1471
+ }
1472
+ });
1473
+ });
1474
+ if (signers.length === 0) {
1475
+ throw new Error(
1476
+ "At least one signatory or witness must have an email address to send a DocuSign envelope"
1477
+ );
1478
+ }
1479
+ const subjectPrefix = emailSubjectPrefix || "Official Certificate Signature Request";
1480
+ const documentBase64 = Buffer.from(htmlDocument, "utf8").toString("base64");
1481
+ return {
1482
+ emailSubject: `${subjectPrefix} — ${certificate.serialNumber} (${certificate.recipientFullName})`,
1483
+ status: "sent",
1484
+ documents: [
1485
+ {
1486
+ documentBase64,
1487
+ name: `${certificate.serialNumber}.html`,
1488
+ fileExtension: "html",
1489
+ documentId: "1"
1490
+ }
1491
+ ],
1492
+ recipients: { signers },
1493
+ customFields: {
1494
+ textCustomFields: [
1495
+ {
1496
+ name: "serialNumber",
1497
+ value: certificate.serialNumber,
1498
+ show: "false"
1499
+ },
1500
+ {
1501
+ name: "certificateDocumentId",
1502
+ value: certificate.documentId,
1503
+ show: "false"
1504
+ }
1505
+ ]
1506
+ }
1507
+ };
1508
+ }
1509
+ function resolveDocuSignCredentials(settings2, options2) {
1510
+ if (!settings2.esign?.enabled || settings2.esign.provider !== "docusign") {
1511
+ throw new Error(
1512
+ "DocuSign eSignature provider is not enabled in Certificates settings"
1513
+ );
1514
+ }
1515
+ const safeBaseUrl = validateDocuSignBaseUrl(settings2.esign.baseUrl);
1516
+ const accountId = firstNonEmptyString(
1517
+ settings2.esign.accountId,
1518
+ process.env.DOCUSIGN_ACCOUNT_ID
1519
+ );
1520
+ if (!accountId) {
1521
+ throw new Error("DocuSign accountId is required");
1522
+ }
1523
+ const accessToken = firstNonEmptyString(
1524
+ options2?.accessToken,
1525
+ process.env.DOCUSIGN_ACCESS_TOKEN
1526
+ );
1527
+ if (!accessToken) {
1528
+ throw new Error(
1529
+ "DOCUSIGN_ACCESS_TOKEN environment variable is required to dispatch DocuSign envelopes"
1530
+ );
1531
+ }
1532
+ const fetchFn = options2?.fetchImpl ?? globalThis.fetch;
1533
+ if (!fetchFn) {
1534
+ throw new Error("Fetch implementation unavailable");
1535
+ }
1536
+ return { safeBaseUrl, accountId, accessToken, fetchFn };
1537
+ }
1538
+ async function dispatchDocuSignEnvelopeRequest(fetchFn, endpoint, accessToken, envelopePayload) {
1539
+ const response = await fetchFn(endpoint, {
1540
+ method: "POST",
1541
+ headers: {
1542
+ "Authorization": `Bearer ${accessToken}`,
1543
+ "Content-Type": "application/json",
1544
+ "Accept": "application/json"
1545
+ },
1546
+ body: JSON.stringify(envelopePayload)
1547
+ });
1548
+ if (!response.ok) {
1549
+ throw new Error(
1550
+ `DocuSign API returned HTTP ${response.status} when creating envelope`
1551
+ );
1552
+ }
1553
+ const responseJson = await response.json();
1554
+ const envelopeId = String(responseJson.envelopeId || "").trim();
1555
+ if (!envelopeId) {
1556
+ throw new Error("DocuSign API did not return an envelopeId");
1557
+ }
1558
+ return envelopeId;
1559
+ }
1560
+ function asRecord(val, fallback) {
1561
+ return val && typeof val === "object" ? val : fallback;
1562
+ }
1563
+ const ALLOWED_ENVELOPE_STATUSES = [
1564
+ "sent",
1565
+ "delivered",
1566
+ "completed",
1567
+ "declined",
1568
+ "voided"
1569
+ ];
1570
+ function firstNonEmptyString(...values) {
1571
+ for (const val of values) {
1572
+ if (typeof val === "string" && val.trim().length > 0) {
1573
+ return val.trim();
1574
+ }
1575
+ }
1576
+ return "";
1577
+ }
1578
+ function parseWebhookEnvelopeInfo(payload) {
1579
+ const dataObj = asRecord(payload.data, payload);
1580
+ const summaryObj = asRecord(dataObj.envelopeSummary, dataObj);
1581
+ const envelopeId = firstNonEmptyString(
1582
+ dataObj.envelopeId,
1583
+ summaryObj.envelopeId,
1584
+ payload.envelopeId
1585
+ );
1586
+ if (!envelopeId) {
1587
+ throw new Error("Missing envelopeId in DocuSign webhook payload");
1588
+ }
1589
+ const rawStatus = firstNonEmptyString(
1590
+ summaryObj.status,
1591
+ payload.status,
1592
+ "sent"
1593
+ ).toLowerCase();
1594
+ const envelopeStatus = ALLOWED_ENVELOPE_STATUSES.includes(rawStatus) ? rawStatus : "sent";
1595
+ return { envelopeId, envelopeStatus };
1596
+ }
1597
+ const docusign = ({ strapi }) => {
1598
+ const getSettingsService = () => strapi.plugin(PLUGIN_ID).service("settings");
1599
+ const getIssuanceService = () => strapi.plugin(PLUGIN_ID).service("issuance");
1600
+ const completeWebhookEnvelope = async (existing, fallbackDoc, envelopeId) => {
1601
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1602
+ const stampedWitnesses = existing.witnesses.map((w) => ({
1603
+ ...w,
1604
+ signedAt: w.signedAt || nowIso
1605
+ }));
1606
+ const stampedSignatories = existing.signatories.map((s) => ({
1607
+ ...s,
1608
+ signedAt: s.signedAt || nowIso
1609
+ }));
1610
+ await getIssuanceService().approveCertificate(existing.documentId, {
1611
+ witnesses: stampedWitnesses,
1612
+ signatories: stampedSignatories
1613
+ });
1614
+ const updatedCompleted = await strapi.documents(CERTIFICATE_UID$1).update({
1615
+ documentId: existing.documentId,
1616
+ data: {
1617
+ esignProvider: "docusign",
1618
+ esignEnvelopeStatus: "completed",
1619
+ esignCompletedAt: nowIso
1620
+ }
1621
+ });
1622
+ const finalRecord = normalizeCertificateRecord(
1623
+ updatedCompleted || fallbackDoc,
1624
+ true
1625
+ );
1626
+ strapi.eventHub?.emit("certificates.docusign.completed", {
1627
+ serialNumber: finalRecord.serialNumber,
1628
+ envelopeId
1629
+ });
1630
+ return {
1631
+ envelopeId,
1632
+ envelopeStatus: "completed",
1633
+ certificate: finalRecord
1634
+ };
1635
+ };
1636
+ return {
1637
+ async sendEnvelopeForCertificate(documentId, options2) {
1638
+ const settings2 = await getSettingsService().getSettings();
1639
+ const { safeBaseUrl, accountId, accessToken, fetchFn } = resolveDocuSignCredentials(settings2, options2);
1640
+ const rawDoc = await strapi.documents(CERTIFICATE_UID$1).findOne({ documentId });
1641
+ if (!rawDoc) {
1642
+ throw new Error("Certificate record not found");
1643
+ }
1644
+ const cert = normalizeCertificateRecord(rawDoc, true);
1645
+ const rendered = await getIssuanceService().renderCertificateDocument(
1646
+ { documentId },
1647
+ { isAdmin: true }
1648
+ );
1649
+ if (!rendered) {
1650
+ throw new Error(
1651
+ "Failed to render certificate HTML for DocuSign envelope"
1652
+ );
1653
+ }
1654
+ const envelopePayload = buildDocuSignEnvelopePayload({
1655
+ certificate: cert,
1656
+ htmlDocument: rendered.html,
1657
+ emailSubjectPrefix: settings2.esign.emailSubjectPrefix
1658
+ });
1659
+ const endpoint = `${safeBaseUrl}/v2.1/accounts/${encodeURIComponent(accountId)}/envelopes`;
1660
+ const envelopeId = await dispatchDocuSignEnvelopeRequest(
1661
+ fetchFn,
1662
+ endpoint,
1663
+ accessToken,
1664
+ envelopePayload
1665
+ );
1666
+ const updated = await strapi.documents(CERTIFICATE_UID$1).update({
1667
+ documentId,
1668
+ data: {
1669
+ esignProvider: "docusign",
1670
+ esignEnvelopeId: envelopeId,
1671
+ esignEnvelopeStatus: "sent"
1672
+ }
1673
+ });
1674
+ const updatedRecord = normalizeCertificateRecord(updated || rawDoc, true);
1675
+ strapi.eventHub?.emit("certificates.docusign.sent", {
1676
+ serialNumber: updatedRecord.serialNumber,
1677
+ envelopeId
1678
+ });
1679
+ return {
1680
+ envelopeId,
1681
+ envelopeStatus: "sent",
1682
+ certificate: updatedRecord
1683
+ };
1684
+ },
1685
+ async handleWebhookEvent(params) {
1686
+ const secret = firstNonEmptyString(
1687
+ params.webhookSecret,
1688
+ process.env.DOCUSIGN_WEBHOOK_SECRET
1689
+ );
1690
+ if (!secret) {
1691
+ throw new Error("DOCUSIGN_WEBHOOK_SECRET is not configured");
1692
+ }
1693
+ if (!verifyDocuSignWebhookHmac(
1694
+ params.rawBody,
1695
+ params.signatureHeader,
1696
+ secret
1697
+ )) {
1698
+ throw new Error("Invalid DocuSign webhook HMAC-SHA256 signature");
1699
+ }
1700
+ const { envelopeId, envelopeStatus } = parseWebhookEnvelopeInfo(
1701
+ params.payload
1702
+ );
1703
+ const matches = await strapi.documents(CERTIFICATE_UID$1).findMany({
1704
+ filters: { esignEnvelopeId: envelopeId },
1705
+ limit: 1
1706
+ });
1707
+ if (matches.length === 0) {
1708
+ throw new Error(
1709
+ `No certificate found for DocuSign envelopeId ${envelopeId}`
1710
+ );
1711
+ }
1712
+ const existing = normalizeCertificateRecord(matches[0], true);
1713
+ if (envelopeStatus === "completed") {
1714
+ return completeWebhookEnvelope(existing, matches[0], envelopeId);
1715
+ }
1716
+ const updated = await strapi.documents(CERTIFICATE_UID$1).update({
1717
+ documentId: existing.documentId,
1718
+ data: { esignEnvelopeStatus: envelopeStatus }
1719
+ });
1720
+ return {
1721
+ envelopeId,
1722
+ envelopeStatus,
1723
+ certificate: normalizeCertificateRecord(updated || matches[0], true)
1724
+ };
1725
+ }
1726
+ };
1727
+ };
1728
+ const TEMPLATE_UID = "plugin::certificates.template";
1729
+ const CERTIFICATE_UID = "plugin::certificates.certificate";
1730
+ const trimStr = (val, fallback = "") => (val ? String(val).trim() : "") || fallback;
1731
+ const trimOptStr = (val) => trimStr(val) || void 0;
1732
+ const pickNonEmptyArray = (primary, fallback) => Array.isArray(primary) && primary.length > 0 ? primary : fallback;
1733
+ const slugifyName = (name, explicitSlug) => trimStr(explicitSlug) || name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1734
+ const DEFAULT_BODY_TEMPLATE = "This is to certify that {{recipientFullName}} has been officially attested by {{organizationName}} on {{issuedDateFormatted}}.";
1735
+ function buildTemplateCreateData(name, data, settings2) {
1736
+ const days = data.defaultValidityDays;
1737
+ return {
1738
+ name,
1739
+ slug: slugifyName(name, data.slug),
1740
+ description: trimStr(data.description),
1741
+ serialPrefix: trimStr(data.serialPrefix, "CERT").toUpperCase().slice(0, 8),
1742
+ defaultValidityDays: typeof days === "number" ? days : null,
1743
+ requiresWitnesses: Boolean(data.requiresWitnesses),
1744
+ minWitnesses: typeof data.minWitnesses === "number" ? data.minWitnesses : 0,
1745
+ requiresApproval: data.requiresApproval !== false,
1746
+ organizationName: trimStr(data.organizationName, settings2.organizationName),
1747
+ organizationSubheader: data.organizationSubheader ?? settings2.organizationSubheader,
1748
+ headerText: trimStr(data.headerText),
1749
+ titleText: trimStr(data.titleText, name),
1750
+ bodyTemplate: trimStr(data.bodyTemplate, DEFAULT_BODY_TEMPLATE),
1751
+ footerText: trimStr(data.footerText),
1752
+ defaultSignatories: normalizeSignatories(data.defaultSignatories),
1753
+ customFieldsSchema: Array.isArray(data.customFieldsSchema) ? data.customFieldsSchema : [],
1754
+ secondaryCalendars: Array.isArray(data.secondaryCalendars) ? data.secondaryCalendars : settings2.secondaryCalendars,
1755
+ accentColor: trimStr(data.accentColor, "#14532d"),
1756
+ active: data.active !== false
1757
+ };
1758
+ }
1759
+ function assertWitnessCount(template, witnesses, enforceNow) {
1760
+ if (enforceNow && template?.requiresWitnesses) {
1761
+ if (witnesses.length < template.minWitnesses) {
1762
+ throw new Error(
1763
+ `Template "${template.name}" requires at least ${template.minWitnesses} official witness(es) before issuance`
1764
+ );
1765
+ }
1766
+ }
1767
+ }
1768
+ function emitCertEvent(strapi, event, record) {
1769
+ strapi.eventHub?.emit(event, {
1770
+ serialNumber: record.serialNumber,
1771
+ templateSlug: record.templateSlug,
1772
+ status: record.status
1773
+ });
1774
+ }
1775
+ function resolveIssueDatesAndIdentity(input, template) {
1776
+ const issuedDateObj = input.issuedAt ? new Date(input.issuedAt) : /* @__PURE__ */ new Date();
1777
+ if (Number.isNaN(issuedDateObj.getTime())) {
1778
+ throw new Error("Invalid issuedAt date");
1779
+ }
1780
+ const issuedAt = issuedDateObj.toISOString();
1781
+ const days = template.defaultValidityDays;
1782
+ const computedExpiry = typeof days === "number" && days > 0 ? new Date(issuedDateObj.getTime() + days * 864e5).toISOString() : null;
1783
+ const serialNumber = generateSerialNumber(
1784
+ template.serialPrefix,
1785
+ issuedDateObj.getUTCFullYear()
1786
+ );
1787
+ const rawNationalId = trimOptStr(input.nationalIdRaw);
1788
+ return {
1789
+ issuedDateObj,
1790
+ fields: {
1791
+ issuedAt,
1792
+ expiresAt: input.expiresAt ?? computedExpiry,
1793
+ serialNumber,
1794
+ verificationToken: generateVerificationToken(),
1795
+ nationalIdMasked: rawNationalId ? maskNationalId(rawNationalId) : "",
1796
+ nationalIdHash: rawNationalId ? hashNationalId(rawNationalId, serialNumber) : ""
1797
+ }
1798
+ };
1799
+ }
1800
+ function resolveApprovalFields(existing, options2) {
1801
+ return {
1802
+ witnesses: options2?.witnesses !== void 0 ? normalizeWitnesses(options2.witnesses) : existing.witnesses,
1803
+ signatories: options2?.signatories !== void 0 ? normalizeSignatories(options2.signatories) : existing.signatories,
1804
+ recipientChosenName: options2?.recipientChosenName !== void 0 ? trimStr(options2.recipientChosenName) : existing.recipientChosenName,
1805
+ notes: options2?.notes !== void 0 ? options2.notes : existing.notes
1806
+ };
1807
+ }
1808
+ const issuance = ({ strapi }) => {
1809
+ const getSettingsService = () => strapi.plugin(PLUGIN_ID).service("settings");
1810
+ return {
1811
+ async listTemplates(activeOnly = false) {
1812
+ const filters = activeOnly ? { active: true } : void 0;
1813
+ const docs = await strapi.documents(TEMPLATE_UID).findMany({ filters, limit: 100 });
1814
+ return docs.map(normalizeTemplateRecord);
1815
+ },
1816
+ async findTemplate(identifier) {
1817
+ if (identifier.templateDocumentId) {
1818
+ const doc = await strapi.documents(TEMPLATE_UID).findOne({
1819
+ documentId: identifier.templateDocumentId
1820
+ });
1821
+ if (doc) return normalizeTemplateRecord(doc);
1822
+ }
1823
+ if (identifier.templateSlug) {
1824
+ const [first] = await strapi.documents(TEMPLATE_UID).findMany({
1825
+ filters: { slug: identifier.templateSlug },
1826
+ limit: 1
1827
+ });
1828
+ if (first) return normalizeTemplateRecord(first);
1829
+ }
1830
+ return null;
1831
+ },
1832
+ async createTemplate(data) {
1833
+ const settings2 = await getSettingsService().getSettings();
1834
+ const name = trimStr(data.name);
1835
+ if (!name) throw new Error("Template name is required");
1836
+ const created = await strapi.documents(TEMPLATE_UID).create({
1837
+ data: buildTemplateCreateData(name, data, settings2)
1838
+ });
1839
+ return normalizeTemplateRecord(created);
1840
+ },
1841
+ async seedDefaultTemplates() {
1842
+ const settings2 = await getSettingsService().getSettings();
1843
+ let seededCount = 0;
1844
+ for (const tpl of settings2.defaultTemplates || []) {
1845
+ if (!await this.findTemplate({ templateSlug: tpl.slug })) {
1846
+ await this.createTemplate(tpl);
1847
+ seededCount++;
1848
+ }
1849
+ }
1850
+ return seededCount;
1851
+ },
1852
+ async issueCertificate(input) {
1853
+ const recipientFullName = trimStr(input.recipientFullName);
1854
+ if (!recipientFullName) throw new Error("recipientFullName is required");
1855
+ const template = await this.findTemplate({
1856
+ templateDocumentId: input.templateDocumentId,
1857
+ templateSlug: input.templateSlug
1858
+ });
1859
+ if (!template?.active)
1860
+ throw new Error("Active certificate template not found");
1861
+ const settings2 = await getSettingsService().getSettings();
1862
+ const keyPair = await getSettingsService().getSigningKeyPair();
1863
+ const isIssued = input.autoApprove === true || !template.requiresApproval;
1864
+ const status = isIssued ? "issued" : "pending_approval";
1865
+ const witnesses = normalizeWitnesses(input.witnesses);
1866
+ assertWitnessCount(template, witnesses, isIssued);
1867
+ const signatories = pickNonEmptyArray(
1868
+ normalizeSignatories(input.signatories),
1869
+ template.defaultSignatories
1870
+ );
1871
+ const { issuedDateObj, fields } = resolveIssueDatesAndIdentity(
1872
+ input,
1873
+ template
1874
+ );
1875
+ const secondaryDates = formatSecondaryCalendars(
1876
+ issuedDateObj,
1877
+ pickNonEmptyArray(
1878
+ template.secondaryCalendars,
1879
+ settings2.secondaryCalendars
1880
+ ),
1881
+ trimStr(input.locale, trimStr(settings2.defaultLocale, "en"))
1882
+ );
1883
+ const signed = signCanonicalPayload(
1884
+ buildCanonicalPayload({
1885
+ ...fields,
1886
+ templateSlug: template.slug,
1887
+ organizationName: trimStr(
1888
+ template.organizationName,
1889
+ settings2.organizationName
1890
+ ),
1891
+ recipientFullName,
1892
+ recipientChosenName: input.recipientChosenName,
1893
+ signatories,
1894
+ witnesses
1895
+ }),
1896
+ keyPair.privateKeyPem
1897
+ );
1898
+ const created = await strapi.documents(CERTIFICATE_UID).create({
1899
+ data: {
1900
+ ...fields,
1901
+ templateDocumentId: template.documentId,
1902
+ templateSlug: template.slug,
1903
+ templateName: template.name,
1904
+ status,
1905
+ recipientFullName,
1906
+ recipientChosenName: trimStr(input.recipientChosenName),
1907
+ recipientEmail: trimOptStr(input.recipientEmail),
1908
+ recipientPhone: trimStr(input.recipientPhone),
1909
+ recipientNationality: trimStr(input.recipientNationality),
1910
+ signatories,
1911
+ witnesses,
1912
+ customData: input.customData ?? {},
1913
+ secondaryDates,
1914
+ canonicalPayloadHash: signed.payloadHash,
1915
+ cryptographicSignature: signed.signature,
1916
+ publicKeyFingerprint: keyPair.fingerprint,
1917
+ linkedBookingCode: trimStr(input.linkedBookingCode),
1918
+ notes: trimStr(input.notes)
1919
+ }
1920
+ });
1921
+ const record = normalizeCertificateRecord(created, true);
1922
+ emitCertEvent(
1923
+ strapi,
1924
+ isIssued ? "certificates.issued" : "certificates.requested",
1925
+ record
1926
+ );
1927
+ return record;
1928
+ },
1929
+ async approveCertificate(documentId, options2) {
1930
+ const existingDoc = await strapi.documents(CERTIFICATE_UID).findOne({ documentId });
1931
+ if (!existingDoc) throw new Error("Certificate record not found");
1932
+ const existing = normalizeCertificateRecord(existingDoc, true);
1933
+ const template = await this.findTemplate({
1934
+ templateDocumentId: existing.templateDocumentId,
1935
+ templateSlug: existing.templateSlug
1936
+ });
1937
+ const { witnesses, signatories, recipientChosenName, notes } = resolveApprovalFields(existing, options2);
1938
+ assertWitnessCount(template, witnesses, true);
1939
+ const keyPair = await getSettingsService().getSigningKeyPair();
1940
+ const settings2 = await getSettingsService().getSettings();
1941
+ const issuedAt = (/* @__PURE__ */ new Date()).toISOString();
1942
+ const signed = signCanonicalPayload(
1943
+ buildCanonicalPayload({
1944
+ ...existing,
1945
+ organizationName: trimStr(
1946
+ template?.organizationName,
1947
+ settings2.organizationName
1948
+ ),
1949
+ recipientChosenName,
1950
+ issuedAt,
1951
+ signatories,
1952
+ witnesses
1953
+ }),
1954
+ keyPair.privateKeyPem
1955
+ );
1956
+ const updated = await strapi.documents(CERTIFICATE_UID).update({
1957
+ documentId,
1958
+ data: {
1959
+ status: "issued",
1960
+ issuedAt,
1961
+ recipientChosenName: trimStr(recipientChosenName),
1962
+ signatories,
1963
+ witnesses,
1964
+ canonicalPayloadHash: signed.payloadHash,
1965
+ cryptographicSignature: signed.signature,
1966
+ publicKeyFingerprint: keyPair.fingerprint,
1967
+ notes
1968
+ }
1969
+ });
1970
+ if (!updated) throw new Error("Failed to update certificate record");
1971
+ const record = normalizeCertificateRecord(updated, true);
1972
+ emitCertEvent(strapi, "certificates.issued", record);
1973
+ return record;
1974
+ },
1975
+ async revokeCertificate(documentId, reason) {
1976
+ const cleanReason = trimStr(reason, "Revoked by administrator");
1977
+ const updated = await strapi.documents(CERTIFICATE_UID).update({
1978
+ documentId,
1979
+ data: {
1980
+ status: "revoked",
1981
+ revokedAt: (/* @__PURE__ */ new Date()).toISOString(),
1982
+ revocationReason: cleanReason
1983
+ }
1984
+ });
1985
+ if (!updated) throw new Error("Certificate record not found");
1986
+ const record = normalizeCertificateRecord(updated, false);
1987
+ strapi.eventHub?.emit("certificates.revoked", {
1988
+ serialNumber: record.serialNumber,
1989
+ revocationReason: cleanReason
1990
+ });
1991
+ return record;
1992
+ },
1993
+ async verifyCertificateBySerial(serialNumber, options2) {
1994
+ const cleanSerial = trimStr(serialNumber).toUpperCase();
1995
+ if (!cleanSerial) return null;
1996
+ const [match] = await strapi.documents(CERTIFICATE_UID).findMany({
1997
+ filters: { serialNumber: cleanSerial },
1998
+ limit: 1
1999
+ });
2000
+ if (!match) return null;
2001
+ const cert = normalizeCertificateRecord(match, true);
2002
+ const settings2 = await getSettingsService().getSettings();
2003
+ const template = await this.findTemplate({
2004
+ templateDocumentId: cert.templateDocumentId,
2005
+ templateSlug: cert.templateSlug
2006
+ });
2007
+ return evaluateCertificateVerification({
2008
+ cert,
2009
+ template,
2010
+ settings: settings2,
2011
+ options: options2
2012
+ });
2013
+ },
2014
+ async renderCertificateDocument(identifier, options2) {
2015
+ let rawDoc = null;
2016
+ if (identifier.documentId) {
2017
+ rawDoc = await strapi.documents(CERTIFICATE_UID).findOne({
2018
+ documentId: identifier.documentId
2019
+ });
2020
+ } else if (identifier.serialNumber) {
2021
+ const [first] = await strapi.documents(CERTIFICATE_UID).findMany({
2022
+ filters: {
2023
+ serialNumber: identifier.serialNumber.trim().toUpperCase()
2024
+ },
2025
+ limit: 1
2026
+ });
2027
+ rawDoc = first || null;
2028
+ }
2029
+ if (!rawDoc) return null;
2030
+ const cert = normalizeCertificateRecord(rawDoc, true);
2031
+ const settings2 = await getSettingsService().getSettings();
2032
+ const template = await this.findTemplate({
2033
+ templateDocumentId: cert.templateDocumentId,
2034
+ templateSlug: cert.templateSlug
2035
+ });
2036
+ return renderPrintableFromRecord({
2037
+ cert,
2038
+ template,
2039
+ settings: settings2,
2040
+ options: options2
2041
+ });
2042
+ },
2043
+ async listCertificates(filters) {
2044
+ const docs = await strapi.documents(CERTIFICATE_UID).findMany({ limit: 500 });
2045
+ return filterAndComputeCertificateStats(
2046
+ docs.map((d) => normalizeCertificateRecord(d, true)),
2047
+ filters
2048
+ );
2049
+ },
2050
+ async exportCsv(filters) {
2051
+ return exportCertificatesToCsv(
2052
+ (await this.listCertificates(filters)).records
2053
+ );
2054
+ }
2055
+ };
2056
+ };
2057
+ const SETTINGS_STORE_KEY = "settings";
2058
+ const KEYPAIR_STORE_KEY = "ed25519_keypair";
2059
+ const DEFAULT_SECONDARY_CALENDARS = [
2060
+ {
2061
+ id: "islamic-umalqura",
2062
+ label: "Hijri (Umm al-Qura)",
2063
+ intlCalendar: "islamic-umalqura",
2064
+ enabled: true,
2065
+ dayOffset: 0
2066
+ }
2067
+ ];
2068
+ const DEFAULT_ESIGN_CONFIG = {
2069
+ enabled: false,
2070
+ provider: "none",
2071
+ baseUrl: "https://demo.docusign.net/restapi",
2072
+ accountId: "",
2073
+ integrationKey: "",
2074
+ emailSubjectPrefix: "Official Certificate Signature Request"
2075
+ };
2076
+ const DEFAULT_SETTINGS = {
2077
+ organizationName: "Official Certification Authority",
2078
+ organizationSubheader: "Office of Official Records & Attestation",
2079
+ verificationBaseUrl: "http://localhost:1337/api/certificates/verify",
2080
+ defaultTimezone: "UTC",
2081
+ defaultLocale: "en",
2082
+ maskPublicRecipientName: true,
2083
+ allowPublicApplications: true,
2084
+ secondaryCalendars: DEFAULT_SECONDARY_CALENDARS,
2085
+ esign: DEFAULT_ESIGN_CONFIG,
2086
+ defaultTemplates: []
2087
+ };
2088
+ function sanitizeCalendarSystems(raw) {
2089
+ if (!Array.isArray(raw)) return DEFAULT_SECONDARY_CALENDARS;
2090
+ return raw.filter(
2091
+ (item) => Boolean(item && typeof item === "object")
2092
+ ).map((item) => ({
2093
+ id: String(item.id || "islamic-umalqura").trim(),
2094
+ label: String(item.label || item.id || "Hijri").trim(),
2095
+ intlCalendar: String(item.intlCalendar || "islamic-umalqura").trim(),
2096
+ enabled: item.enabled !== false,
2097
+ dayOffset: typeof item.dayOffset === "number" && Number.isFinite(item.dayOffset) ? Math.max(-5, Math.min(5, Math.trunc(item.dayOffset))) : 0
2098
+ }));
2099
+ }
2100
+ function pickFirstString(...candidates) {
2101
+ for (const candidate of candidates) {
2102
+ if (candidate !== void 0 && candidate !== "") {
2103
+ return String(candidate).trim();
2104
+ }
2105
+ }
2106
+ return "";
2107
+ }
2108
+ function normalizeEsignProvider(providerRaw, enabled) {
2109
+ if (providerRaw === "docusign" || providerRaw === "webhook") {
2110
+ return providerRaw;
2111
+ }
2112
+ return enabled ? "docusign" : "none";
2113
+ }
2114
+ function toRecordObject(raw) {
2115
+ return raw && typeof raw === "object" ? raw : {};
2116
+ }
2117
+ function sanitizeESignConfig(raw, fallback) {
2118
+ const obj = toRecordObject(raw);
2119
+ const envAccountId = process.env.DOCUSIGN_ACCOUNT_ID?.trim();
2120
+ const envBaseUrl = process.env.DOCUSIGN_BASE_URL?.trim();
2121
+ const envIntegrationKey = process.env.DOCUSIGN_INTEGRATION_KEY?.trim();
2122
+ const enabled = Boolean(
2123
+ obj.enabled ?? fallback.enabled ?? Boolean(envAccountId)
2124
+ );
2125
+ const providerRaw = pickFirstString(obj.provider, fallback.provider, "none");
2126
+ return {
2127
+ enabled,
2128
+ provider: normalizeEsignProvider(providerRaw, Boolean(obj.enabled)),
2129
+ baseUrl: stripTrailingSlashes(
2130
+ pickFirstString(
2131
+ obj.baseUrl,
2132
+ envBaseUrl,
2133
+ fallback.baseUrl,
2134
+ DEFAULT_ESIGN_CONFIG.baseUrl
2135
+ )
2136
+ ),
2137
+ accountId: pickFirstString(obj.accountId, envAccountId, fallback.accountId),
2138
+ integrationKey: pickFirstString(
2139
+ obj.integrationKey,
2140
+ envIntegrationKey,
2141
+ fallback.integrationKey
2142
+ ),
2143
+ emailSubjectPrefix: pickFirstString(
2144
+ obj.emailSubjectPrefix,
2145
+ fallback.emailSubjectPrefix,
2146
+ DEFAULT_ESIGN_CONFIG.emailSubjectPrefix
2147
+ )
2148
+ };
2149
+ }
2150
+ const settings = ({ strapi }) => {
2151
+ const getPluginStore = () => strapi.store({
2152
+ type: "plugin",
2153
+ name: PLUGIN_ID
2154
+ });
2155
+ return {
2156
+ async getSigningKeyPair() {
2157
+ const envPrivate = process.env.CERTIFICATES_PRIVATE_KEY_PEM?.trim();
2158
+ const envPublic = process.env.CERTIFICATES_PUBLIC_KEY_PEM?.trim();
2159
+ if (envPrivate && envPublic) {
2160
+ return {
2161
+ privateKeyPem: envPrivate.replace(/\\n/g, "\n"),
2162
+ publicKeyPem: envPublic.replace(/\\n/g, "\n"),
2163
+ fingerprint: computePublicKeyFingerprint(
2164
+ envPublic.replace(/\\n/g, "\n")
2165
+ )
2166
+ };
2167
+ }
2168
+ const store = getPluginStore();
2169
+ const stored = await store.get({ key: KEYPAIR_STORE_KEY });
2170
+ if (stored?.privateKeyPem && stored?.publicKeyPem) {
2171
+ return {
2172
+ privateKeyPem: stored.privateKeyPem,
2173
+ publicKeyPem: stored.publicKeyPem,
2174
+ fingerprint: stored.fingerprint || computePublicKeyFingerprint(stored.publicKeyPem)
2175
+ };
2176
+ }
2177
+ const generated = generateEd25519KeyPair();
2178
+ await store.set({
2179
+ key: KEYPAIR_STORE_KEY,
2180
+ value: generated
2181
+ });
2182
+ strapi.log.info(
2183
+ `[${PLUGIN_ID}] Generated and persisted new Ed25519 signing keypair (${generated.fingerprint}). Set CERTIFICATES_PRIVATE_KEY_PEM and CERTIFICATES_PUBLIC_KEY_PEM in production.`
2184
+ );
2185
+ return generated;
2186
+ },
2187
+ async rotateKeyPair() {
2188
+ const store = getPluginStore();
2189
+ const generated = generateEd25519KeyPair();
2190
+ await store.set({
2191
+ key: KEYPAIR_STORE_KEY,
2192
+ value: generated
2193
+ });
2194
+ strapi.log.warn(
2195
+ `[${PLUGIN_ID}] Rotated Ed25519 signing keypair to ${generated.fingerprint}`
2196
+ );
2197
+ return {
2198
+ publicKeyPem: generated.publicKeyPem,
2199
+ publicKeyFingerprint: generated.fingerprint
2200
+ };
2201
+ },
2202
+ async getSettings() {
2203
+ const store = getPluginStore();
2204
+ const configDefaults = strapi.config.get(
2205
+ `plugin::${PLUGIN_ID}`,
2206
+ {}
2207
+ ) || {};
2208
+ const stored = await store.get({ key: SETTINGS_STORE_KEY }) || {};
2209
+ const keyPair = await this.getSigningKeyPair();
2210
+ const merged = {
2211
+ ...DEFAULT_SETTINGS,
2212
+ ...configDefaults,
2213
+ ...stored
2214
+ };
2215
+ const defaultEsign = sanitizeESignConfig(
2216
+ configDefaults.esign,
2217
+ DEFAULT_ESIGN_CONFIG
2218
+ );
2219
+ return {
2220
+ organizationName: String(
2221
+ merged.organizationName || DEFAULT_SETTINGS.organizationName
2222
+ ).trim(),
2223
+ organizationSubheader: String(
2224
+ merged.organizationSubheader ?? DEFAULT_SETTINGS.organizationSubheader
2225
+ ).trim(),
2226
+ verificationBaseUrl: stripTrailingSlashes(
2227
+ String(
2228
+ merged.verificationBaseUrl || DEFAULT_SETTINGS.verificationBaseUrl
2229
+ ).trim()
2230
+ ),
2231
+ defaultTimezone: String(
2232
+ merged.defaultTimezone || DEFAULT_SETTINGS.defaultTimezone
2233
+ ).trim(),
2234
+ defaultLocale: String(
2235
+ merged.defaultLocale || DEFAULT_SETTINGS.defaultLocale
2236
+ ).trim(),
2237
+ maskPublicRecipientName: merged.maskPublicRecipientName !== false,
2238
+ allowPublicApplications: merged.allowPublicApplications !== false,
2239
+ publicKeyPem: keyPair.publicKeyPem,
2240
+ publicKeyFingerprint: keyPair.fingerprint,
2241
+ secondaryCalendars: sanitizeCalendarSystems(merged.secondaryCalendars),
2242
+ esign: sanitizeESignConfig(stored.esign, defaultEsign),
2243
+ defaultTemplates: Array.isArray(merged.defaultTemplates) ? merged.defaultTemplates : []
2244
+ };
2245
+ },
2246
+ async updateSettings(partial) {
2247
+ const current = await this.getSettings();
2248
+ const updated = {
2249
+ organizationName: typeof partial.organizationName === "string" ? partial.organizationName.trim() : current.organizationName,
2250
+ organizationSubheader: typeof partial.organizationSubheader === "string" ? partial.organizationSubheader.trim() : current.organizationSubheader,
2251
+ verificationBaseUrl: typeof partial.verificationBaseUrl === "string" ? stripTrailingSlashes(partial.verificationBaseUrl.trim()) : current.verificationBaseUrl,
2252
+ defaultTimezone: typeof partial.defaultTimezone === "string" ? partial.defaultTimezone.trim() : current.defaultTimezone,
2253
+ defaultLocale: typeof partial.defaultLocale === "string" ? partial.defaultLocale.trim() : current.defaultLocale,
2254
+ maskPublicRecipientName: typeof partial.maskPublicRecipientName === "boolean" ? partial.maskPublicRecipientName : current.maskPublicRecipientName,
2255
+ allowPublicApplications: typeof partial.allowPublicApplications === "boolean" ? partial.allowPublicApplications : current.allowPublicApplications,
2256
+ secondaryCalendars: partial.secondaryCalendars !== void 0 ? sanitizeCalendarSystems(partial.secondaryCalendars) : current.secondaryCalendars,
2257
+ esign: partial.esign !== void 0 ? sanitizeESignConfig(partial.esign, current.esign) : current.esign,
2258
+ defaultTemplates: current.defaultTemplates
2259
+ };
2260
+ const store = getPluginStore();
2261
+ await store.set({
2262
+ key: SETTINGS_STORE_KEY,
2263
+ value: updated
2264
+ });
2265
+ return this.getSettings();
2266
+ }
2267
+ };
2268
+ };
2269
+ const services = {
2270
+ settings,
2271
+ issuance,
2272
+ docusign
2273
+ };
2274
+ const index = {
2275
+ register,
2276
+ bootstrap,
2277
+ contentTypes,
2278
+ controllers,
2279
+ routes,
2280
+ services
2281
+ };
2282
+ exports.default = index;