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