@bdking71/spsignature 1.0.5 → 1.1.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.
@@ -1,14 +1,78 @@
1
1
  "use strict";
2
+ /**
3
+ * @file SecureAuditSignature.ts
4
+ *
5
+ * Provides a secure, auditable digital-signature workflow for SharePoint
6
+ * web parts. The module renders a modal dialog that supports:
7
+ *
8
+ * • Drawing a signature on an HTML canvas
9
+ * • Uploading a signature image (PNG / JPG, ≤ 5 MB)
10
+ * • Re-using a previously cached (LZW-compressed) signature from
11
+ * localStorage
12
+ * • Optional two-factor authentication via a 5-digit passcode
13
+ * dispatched through email or Microsoft Teams
14
+ *
15
+ * After the user signs, the module hashes the audit envelope
16
+ * (payload + signer + timestamp) with SHA-256 so that the record can
17
+ * later be verified without exposing the original payload.
18
+ *
19
+ * @module SecureAuditSignature
20
+ */
2
21
  Object.defineProperty(exports, "__esModule", { value: true });
3
22
  exports.verifySecureAuditRecord = exports.getReportableSignature = exports.promptAndGenerateSecureAudit = void 0;
4
- const VerificationService_1 = require("./VerificationService");
23
+ const OtpService_1 = require("./OtpService");
24
+ /** localStorage key used to persist a compressed signature across sessions. */
5
25
  const STORAGE_KEY = "secure_audit_cached_signature_v1";
26
+ /** Maximum upload file size in bytes (5 MB). */
27
+ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers – passcode generation
30
+ // ---------------------------------------------------------------------------
31
+ /**
32
+ * Generates a cryptographically random 5-digit passcode (10 000 – 99 999)
33
+ * using the Web Crypto API.
34
+ *
35
+ * @returns A string representation of the 5-digit code.
36
+ */
6
37
  function generateFiveDigitPasscode() {
7
38
  const array = new Uint32Array(1);
8
39
  window.crypto.getRandomValues(array);
9
40
  const code = (array[0] % 90000) + 10000;
10
41
  return code.toString();
11
42
  }
43
+ // ---------------------------------------------------------------------------
44
+ // Main public function – modal signing ceremony
45
+ // ---------------------------------------------------------------------------
46
+ /**
47
+ * Opens a full-screen modal dialog that walks the user through:
48
+ *
49
+ * 1. (Optional) Two-factor passcode verification
50
+ * 2. Providing a digital signature (cached / drawn / uploaded)
51
+ * 3. Generating a tamper-evident SHA-256 audit record
52
+ *
53
+ * The returned promise resolves with a `SharePointAuditRecord` on
54
+ * success or `undefined` if the user cancels.
55
+ *
56
+ * @param context - Signer metadata and SPFx context.
57
+ * @param modalTitle - Title shown in the modal header.
58
+ * @param warningMessage - Instructional HTML rendered above the
59
+ * signature area.
60
+ * @returns A promise that resolves to the audit record or `undefined`.
61
+ *
62
+ * @throws {Error} If `context.spContext` is falsy.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const record = await promptAndGenerateSecureAudit({
67
+ * itemID: 42,
68
+ * signer: currentUser.email,
69
+ * payload: { amount: 1500, vendor: "Contoso" },
70
+ * spContext: this.context,
71
+ * channel: "email",
72
+ * requireTFA: true,
73
+ * });
74
+ * ```
75
+ */
12
76
  async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purchase Requisition", warningMessage = "Please review your entry and apply your signature to finalize this record.") {
13
77
  if (!context.spContext) {
14
78
  throw new Error("Execution restricted: Valid WebPartContext must be supplied.");
@@ -16,7 +80,10 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
16
80
  return new Promise((resolve) => {
17
81
  const requireTFA = context.requireTFA !== false;
18
82
  const generatedPasscode = generateFiveDigitPasscode();
19
- let storedVerificationItemId = context.itemID;
83
+ let storedVerificationItemId = undefined;
84
+ // -----------------------------------------------------------------
85
+ // Overlay
86
+ // -----------------------------------------------------------------
20
87
  const overlay = document.createElement("div");
21
88
  overlay.style.position = "fixed";
22
89
  overlay.style.top = "0";
@@ -28,8 +95,12 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
28
95
  overlay.style.display = "flex";
29
96
  overlay.style.alignItems = "center";
30
97
  overlay.style.justifyContent = "center";
31
- overlay.style.fontFamily = "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif";
98
+ overlay.style.fontFamily =
99
+ "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif";
32
100
  overlay.style.backdropFilter = "blur(3px)";
101
+ // -----------------------------------------------------------------
102
+ // Modal container
103
+ // -----------------------------------------------------------------
33
104
  const modalBox = document.createElement("div");
34
105
  modalBox.style.backgroundColor = "#ffffff";
35
106
  modalBox.style.padding = "0";
@@ -41,6 +112,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
41
112
  modalBox.style.maxHeight = "90vh";
42
113
  modalBox.style.display = "flex";
43
114
  modalBox.style.flexDirection = "column";
115
+ // -----------------------------------------------------------------
116
+ // Header
117
+ // -----------------------------------------------------------------
44
118
  const headerSection = document.createElement("div");
45
119
  headerSection.style.backgroundColor = "#0078d4";
46
120
  headerSection.style.padding = "20px 24px";
@@ -53,6 +127,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
53
127
  title.style.fontWeight = "600";
54
128
  title.style.letterSpacing = "0.3px";
55
129
  headerSection.appendChild(title);
130
+ // -----------------------------------------------------------------
131
+ // Content wrapper
132
+ // -----------------------------------------------------------------
56
133
  const contentSection = document.createElement("div");
57
134
  contentSection.style.padding = "24px";
58
135
  contentSection.style.backgroundColor = "#fafafa";
@@ -64,6 +141,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
64
141
  body.style.color = "#323130";
65
142
  body.style.lineHeight = "1.6";
66
143
  body.style.margin = "0 0 20px 0";
144
+ // -----------------------------------------------------------------
145
+ // Sign button (footer)
146
+ // -----------------------------------------------------------------
67
147
  const signButton = document.createElement("button");
68
148
  signButton.innerText = "I Agree and Sign";
69
149
  signButton.style.padding = "10px 24px";
@@ -75,14 +155,23 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
75
155
  signButton.style.fontWeight = "600";
76
156
  signButton.style.borderRadius = "4px";
77
157
  signButton.style.transition = "all 0.2s ease";
158
+ // -----------------------------------------------------------------
159
+ // Two-factor verification panel
160
+ // -----------------------------------------------------------------
78
161
  const verificationContainer = document.createElement("div");
162
+ /** Direct reference to the passcode <input> (avoids fragile querySelector). */
163
+ let verificationInput = null;
164
+ /** Direct reference to the "Resend Code" button. */
165
+ let sendCodeButton = null;
79
166
  if (requireTFA) {
80
167
  verificationContainer.style.marginBottom = "20px";
81
168
  verificationContainer.style.padding = "16px";
82
169
  verificationContainer.style.backgroundColor = "#fff8e1";
83
170
  verificationContainer.style.border = "1px solid #ffd54f";
84
171
  verificationContainer.style.borderRadius = "6px";
85
- verificationContainer.style.boxShadow = "0 2px 4px rgba(0,0,0,0.05)";
172
+ verificationContainer.style.boxShadow =
173
+ "0 2px 4px rgba(0,0,0,0.05)";
174
+ /* Header row with lock icon */
86
175
  const verificationHeader = document.createElement("div");
87
176
  verificationHeader.style.display = "flex";
88
177
  verificationHeader.style.alignItems = "center";
@@ -106,10 +195,11 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
106
195
  verificationDescription.style.color = "#605e5c";
107
196
  verificationDescription.style.margin = "0 0 12px 0";
108
197
  verificationDescription.style.lineHeight = "1.5";
198
+ /* Input + Resend row */
109
199
  const verificationRow = document.createElement("div");
110
200
  verificationRow.style.display = "flex";
111
201
  verificationRow.style.gap = "10px";
112
- const verificationInput = document.createElement("input");
202
+ verificationInput = document.createElement("input");
113
203
  verificationInput.type = "text";
114
204
  verificationInput.maxLength = 5;
115
205
  verificationInput.placeholder = "Enter 5-digit code";
@@ -124,13 +214,14 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
124
214
  verificationInput.style.textAlign = "center";
125
215
  verificationInput.style.letterSpacing = "2px";
126
216
  verificationInput.style.fontWeight = "600";
127
- verificationInput.onfocus = () => {
128
- verificationInput.style.borderColor = "#0078d4";
217
+ const vInput = verificationInput; // capture for closures
218
+ vInput.onfocus = () => {
219
+ vInput.style.borderColor = "#0078d4";
129
220
  };
130
- verificationInput.onblur = () => {
131
- verificationInput.style.borderColor = "#d1d1d1";
221
+ vInput.onblur = () => {
222
+ vInput.style.borderColor = "#d1d1d1";
132
223
  };
133
- const sendCodeButton = document.createElement("button");
224
+ sendCodeButton = document.createElement("button");
134
225
  sendCodeButton.innerText = "Resend Code";
135
226
  sendCodeButton.type = "button";
136
227
  sendCodeButton.style.padding = "10px 16px";
@@ -143,14 +234,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
143
234
  sendCodeButton.style.borderRadius = "4px";
144
235
  sendCodeButton.style.fontWeight = "600";
145
236
  sendCodeButton.style.transition = "all 0.2s ease";
146
- sendCodeButton.onmouseover = () => {
147
- sendCodeButton.style.backgroundColor = "#0078d4";
148
- sendCodeButton.style.color = "#ffffff";
237
+ const scBtn = sendCodeButton; // capture for closures
238
+ scBtn.onmouseover = () => {
239
+ scBtn.style.backgroundColor = "#0078d4";
240
+ scBtn.style.color = "#ffffff";
149
241
  };
150
- sendCodeButton.onmouseout = () => {
151
- if (!sendCodeButton.disabled) {
152
- sendCodeButton.style.backgroundColor = "#ffffff";
153
- sendCodeButton.style.color = "#0078d4";
242
+ scBtn.onmouseout = () => {
243
+ if (!scBtn.disabled) {
244
+ scBtn.style.backgroundColor = "#ffffff";
245
+ scBtn.style.color = "#0078d4";
154
246
  }
155
247
  };
156
248
  verificationRow.appendChild(verificationInput);
@@ -159,11 +251,17 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
159
251
  verificationContainer.appendChild(verificationDescription);
160
252
  verificationContainer.appendChild(verificationRow);
161
253
  }
254
+ // -----------------------------------------------------------------
255
+ // Cached-signature detection
256
+ // -----------------------------------------------------------------
162
257
  const cachedCompressedSig = localStorage.getItem(STORAGE_KEY);
163
- const hasCachedSignature = cachedCompressedSig !== null && cachedCompressedSig.trim() !== "";
164
- const decompressedCachedDataUri = (hasCachedSignature && cachedCompressedSig)
258
+ let hasCachedSignature = cachedCompressedSig !== null && cachedCompressedSig.trim() !== "";
259
+ const decompressedCachedDataUri = hasCachedSignature && cachedCompressedSig
165
260
  ? lzwDecompress(cachedCompressedSig)
166
261
  : "";
262
+ // -----------------------------------------------------------------
263
+ // Signature section wrapper
264
+ // -----------------------------------------------------------------
167
265
  const signatureSection = document.createElement("div");
168
266
  signatureSection.style.marginBottom = "20px";
169
267
  signatureSection.style.padding = "16px";
@@ -180,12 +278,16 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
180
278
  signatureLabel.style.color = "#323130";
181
279
  signatureLabel.style.margin = "0 0 4px 0";
182
280
  const signatureSubtext = document.createElement("p");
183
- signatureSubtext.innerText = "Choose how you'd like to provide your signature";
281
+ signatureSubtext.innerText =
282
+ "Choose how you'd like to provide your signature";
184
283
  signatureSubtext.style.fontSize = "12px";
185
284
  signatureSubtext.style.color = "#605e5c";
186
285
  signatureSubtext.style.margin = "0";
187
286
  signatureHeader.appendChild(signatureLabel);
188
287
  signatureHeader.appendChild(signatureSubtext);
288
+ // -----------------------------------------------------------------
289
+ // "Remember signature" checkbox
290
+ // -----------------------------------------------------------------
189
291
  const cacheControlBox = document.createElement("div");
190
292
  cacheControlBox.style.marginTop = "16px";
191
293
  cacheControlBox.style.padding = "12px";
@@ -205,19 +307,25 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
205
307
  cacheCheckbox.style.width = "16px";
206
308
  cacheCheckbox.style.height = "16px";
207
309
  const cacheCheckboxText = document.createElement("span");
208
- cacheCheckboxText.innerText = "Remember my signature on this device for future transactions";
310
+ cacheCheckboxText.innerText =
311
+ "Remember my signature on this device for future transactions";
209
312
  cacheCheckboxText.style.color = "#323130";
210
313
  cacheCheckboxText.style.fontSize = "12px";
211
314
  cacheCheckboxLabel.appendChild(cacheCheckbox);
212
315
  cacheCheckboxLabel.appendChild(cacheCheckboxText);
213
316
  cacheControlBox.appendChild(cacheCheckboxLabel);
317
+ // -----------------------------------------------------------------
318
+ // Cached-signature notice panel
319
+ // -----------------------------------------------------------------
214
320
  const cachedNoticeContainer = document.createElement("div");
215
321
  cachedNoticeContainer.style.marginBottom = "16px";
216
322
  cachedNoticeContainer.style.padding = "16px";
217
323
  cachedNoticeContainer.style.backgroundColor = "#e6f4ff";
218
324
  cachedNoticeContainer.style.border = "1px solid #0078d4";
219
325
  cachedNoticeContainer.style.borderRadius = "6px";
220
- cachedNoticeContainer.style.display = hasCachedSignature ? "block" : "none";
326
+ cachedNoticeContainer.style.display = hasCachedSignature
327
+ ? "block"
328
+ : "none";
221
329
  const cachedNoticeText = document.createElement("div");
222
330
  cachedNoticeText.innerText = "✓ Saved signature on file";
223
331
  cachedNoticeText.style.fontSize = "13px";
@@ -259,11 +367,21 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
259
367
  cachedNoticeContainer.appendChild(cachedNoticeText);
260
368
  cachedNoticeContainer.appendChild(cachedPreviewImage);
261
369
  cachedNoticeContainer.appendChild(removeCachedBtn);
370
+ // -----------------------------------------------------------------
371
+ // Tab bar (Saved / Draw New / Upload Image)
372
+ // -----------------------------------------------------------------
262
373
  const modeContainer = document.createElement("div");
263
374
  modeContainer.style.display = "flex";
264
375
  modeContainer.style.gap = "8px";
265
376
  modeContainer.style.marginBottom = "16px";
266
377
  modeContainer.style.borderBottom = "2px solid #edebe9";
378
+ /**
379
+ * Factory that creates a styled tab button.
380
+ *
381
+ * @param text - Label for the tab.
382
+ * @param isActive - Whether the tab should appear selected initially.
383
+ * @returns The constructed `<button>` element.
384
+ */
267
385
  const createTab = (text, isActive) => {
268
386
  const tab = document.createElement("button");
269
387
  tab.innerText = text;
@@ -274,23 +392,35 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
274
392
  tab.style.backgroundColor = "transparent";
275
393
  tab.style.color = isActive ? "#0078d4" : "#605e5c";
276
394
  tab.style.border = "none";
277
- tab.style.borderBottom = isActive ? "3px solid #0078d4" : "3px solid transparent";
395
+ tab.style.borderBottom = isActive
396
+ ? "3px solid #0078d4"
397
+ : "3px solid transparent";
278
398
  tab.style.transition = "all 0.2s ease";
279
399
  tab.style.outline = "none";
400
+ /* Hover handlers check current activeMode so they remain correct
401
+ after the user switches tabs. */
280
402
  tab.onmouseover = () => {
281
- if (!isActive) {
403
+ const tabIsCurrentlyActive = (tab === cachedTabBtn && activeMode === "cached") ||
404
+ (tab === drawTabBtn && activeMode === "draw") ||
405
+ (tab === uploadTabBtn && activeMode === "upload");
406
+ if (!tabIsCurrentlyActive) {
282
407
  tab.style.color = "#0078d4";
283
408
  }
284
409
  };
285
410
  tab.onmouseout = () => {
286
- if (!isActive) {
411
+ const tabIsCurrentlyActive = (tab === cachedTabBtn && activeMode === "cached") ||
412
+ (tab === drawTabBtn && activeMode === "draw") ||
413
+ (tab === uploadTabBtn && activeMode === "upload");
414
+ if (!tabIsCurrentlyActive) {
287
415
  tab.style.color = "#605e5c";
288
416
  }
289
417
  };
290
418
  return tab;
291
419
  };
292
420
  const cachedTabBtn = createTab("Saved", hasCachedSignature);
293
- cachedTabBtn.style.display = hasCachedSignature ? "inline-block" : "none";
421
+ cachedTabBtn.style.display = hasCachedSignature
422
+ ? "inline-block"
423
+ : "none";
294
424
  const drawTabBtn = createTab("Draw New", !hasCachedSignature);
295
425
  const uploadTabBtn = createTab("Upload Image", false);
296
426
  if (hasCachedSignature) {
@@ -298,9 +428,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
298
428
  }
299
429
  modeContainer.appendChild(drawTabBtn);
300
430
  modeContainer.appendChild(uploadTabBtn);
431
+ // -----------------------------------------------------------------
432
+ // Cached panel
433
+ // -----------------------------------------------------------------
301
434
  const cachedPanel = document.createElement("div");
302
435
  cachedPanel.style.display = hasCachedSignature ? "block" : "none";
303
436
  cachedPanel.appendChild(cachedNoticeContainer);
437
+ // -----------------------------------------------------------------
438
+ // Draw panel (canvas)
439
+ // -----------------------------------------------------------------
304
440
  const drawPanel = document.createElement("div");
305
441
  drawPanel.style.display = hasCachedSignature ? "none" : "block";
306
442
  const canvasContainer = document.createElement("div");
@@ -324,6 +460,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
324
460
  canvas.style.width = "100%";
325
461
  canvas.style.borderRadius = "4px";
326
462
  canvas.style.boxShadow = "inset 0 1px 3px rgba(0,0,0,0.1)";
463
+ /** Button to clear the drawing canvas. */
327
464
  const clearButton = document.createElement("button");
328
465
  clearButton.innerText = "Clear Canvas";
329
466
  clearButton.style.marginTop = "10px";
@@ -346,6 +483,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
346
483
  canvasContainer.appendChild(canvas);
347
484
  canvasContainer.appendChild(clearButton);
348
485
  drawPanel.appendChild(canvasContainer);
486
+ // -----------------------------------------------------------------
487
+ // Upload panel
488
+ // -----------------------------------------------------------------
349
489
  const uploadPanel = document.createElement("div");
350
490
  uploadPanel.style.display = "none";
351
491
  uploadPanel.style.padding = "32px 24px";
@@ -355,7 +495,6 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
355
495
  uploadPanel.style.borderRadius = "6px";
356
496
  uploadPanel.style.transition = "all 0.2s ease";
357
497
  uploadPanel.style.minHeight = "200px";
358
- uploadPanel.style.display = "none";
359
498
  uploadPanel.style.flexDirection = "column";
360
499
  uploadPanel.style.alignItems = "center";
361
500
  uploadPanel.style.justifyContent = "center";
@@ -396,7 +535,12 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
396
535
  uploadPanel.appendChild(uploadSubtext);
397
536
  uploadPanel.appendChild(fileInput);
398
537
  uploadPanel.appendChild(uploadPreview);
399
- let activeMode = hasCachedSignature ? "cached" : "draw";
538
+ // -----------------------------------------------------------------
539
+ // State
540
+ // -----------------------------------------------------------------
541
+ let activeMode = hasCachedSignature
542
+ ? "cached"
543
+ : "draw";
400
544
  let compressedUploadBase64 = "";
401
545
  const ctx = canvas.getContext("2d");
402
546
  let isDrawing = false;
@@ -409,19 +553,30 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
409
553
  ctx.lineCap = "round";
410
554
  ctx.lineJoin = "round";
411
555
  }
556
+ // -----------------------------------------------------------------
557
+ // Button-state helper
558
+ // -----------------------------------------------------------------
559
+ /**
560
+ * Re-evaluates whether the "I Agree and Sign" button should be
561
+ * enabled, based on the current TFA code entry and selected
562
+ * signature mode.
563
+ */
412
564
  const updateButtonState = () => {
413
565
  let isCodeValid = true;
414
- if (requireTFA) {
415
- const verificationInput = verificationContainer.querySelector("input");
566
+ if (requireTFA && verificationInput) {
416
567
  const codeValue = verificationInput.value.trim();
417
- isCodeValid = codeValue.length === 5 && codeValue === generatedPasscode;
568
+ isCodeValid =
569
+ codeValue.length === 5 && codeValue === generatedPasscode;
418
570
  }
419
571
  let isSignatureValid = false;
420
572
  if (activeMode === "cached") {
421
573
  isSignatureValid = hasCachedSignature;
422
574
  }
423
575
  else if (activeMode === "draw") {
424
- isSignatureValid = hasDrawnContent && ctx !== null && !isCanvasEmpty(ctx, canvas.width, canvas.height);
576
+ isSignatureValid =
577
+ hasDrawnContent &&
578
+ ctx !== null &&
579
+ !isCanvasEmpty(ctx, canvas.width, canvas.height);
425
580
  }
426
581
  else {
427
582
  isSignatureValid = compressedUploadBase64.trim() !== "";
@@ -439,6 +594,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
439
594
  signButton.style.opacity = "0.6";
440
595
  }
441
596
  };
597
+ // -----------------------------------------------------------------
598
+ // Tab-switching logic
599
+ // -----------------------------------------------------------------
600
+ /**
601
+ * Activates the given tab, hiding the other panels and updating
602
+ * visual tab styles.
603
+ *
604
+ * @param mode - Which panel to show.
605
+ */
442
606
  const setActiveTab = (mode) => {
443
607
  activeMode = mode;
444
608
  cachedPanel.style.display = mode === "cached" ? "block" : "none";
@@ -448,7 +612,11 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
448
612
  btn.style.color = "#605e5c";
449
613
  btn.style.borderBottom = "3px solid transparent";
450
614
  });
451
- const activeBtn = mode === "cached" ? cachedTabBtn : mode === "draw" ? drawTabBtn : uploadTabBtn;
615
+ const activeBtn = mode === "cached"
616
+ ? cachedTabBtn
617
+ : mode === "draw"
618
+ ? drawTabBtn
619
+ : uploadTabBtn;
452
620
  activeBtn.style.color = "#0078d4";
453
621
  activeBtn.style.borderBottom = "3px solid #0078d4";
454
622
  updateButtonState();
@@ -458,50 +626,63 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
458
626
  }
459
627
  drawTabBtn.onclick = () => setActiveTab("draw");
460
628
  uploadTabBtn.onclick = () => setActiveTab("upload");
629
+ /** Removes the cached signature and switches to the draw tab. */
461
630
  removeCachedBtn.onclick = () => {
462
631
  localStorage.removeItem(STORAGE_KEY);
632
+ hasCachedSignature = false;
463
633
  cachedTabBtn.style.display = "none";
464
634
  setActiveTab("draw");
465
635
  };
466
- if (requireTFA && context.channel) {
467
- const sendCodeButton = verificationContainer.querySelector("button");
468
- const verificationInput = verificationContainer.querySelector("input");
636
+ // -----------------------------------------------------------------
637
+ // Two-factor: send-code logic
638
+ // -----------------------------------------------------------------
639
+ if (requireTFA && context.channel && sendCodeButton && verificationInput) {
469
640
  let cooldownTimer = null;
641
+ const scBtnRef = sendCodeButton;
642
+ const vInputRef = verificationInput;
643
+ /**
644
+ * Starts a 60-second cooldown on the "Resend Code" button to
645
+ * prevent rapid re-sends.
646
+ */
470
647
  const startCooldownTimer = () => {
471
648
  let secondsLeft = 60;
472
- sendCodeButton.disabled = true;
473
- sendCodeButton.style.backgroundColor = "#f3f2f1";
474
- sendCodeButton.style.color = "#a19f9d";
475
- sendCodeButton.style.borderColor = "#d1d1d1";
476
- sendCodeButton.style.cursor = "not-allowed";
477
- sendCodeButton.innerText = `Resend (${secondsLeft}s)`;
649
+ scBtnRef.disabled = true;
650
+ scBtnRef.style.backgroundColor = "#f3f2f1";
651
+ scBtnRef.style.color = "#a19f9d";
652
+ scBtnRef.style.borderColor = "#d1d1d1";
653
+ scBtnRef.style.cursor = "not-allowed";
654
+ scBtnRef.innerText = `Resend (${secondsLeft}s)`;
478
655
  if (cooldownTimer) {
479
656
  clearInterval(cooldownTimer);
480
657
  }
481
658
  cooldownTimer = window.setInterval(() => {
482
659
  secondsLeft -= 1;
483
660
  if (secondsLeft > 0) {
484
- sendCodeButton.innerText = `Resend (${secondsLeft}s)`;
661
+ scBtnRef.innerText = `Resend (${secondsLeft}s)`;
485
662
  }
486
663
  else {
487
664
  if (cooldownTimer) {
488
665
  clearInterval(cooldownTimer);
489
666
  }
490
- sendCodeButton.disabled = false;
491
- sendCodeButton.style.backgroundColor = "#ffffff";
492
- sendCodeButton.style.color = "#0078d4";
493
- sendCodeButton.style.borderColor = "#0078d4";
494
- sendCodeButton.style.cursor = "pointer";
495
- sendCodeButton.innerText = "Resend Code";
667
+ scBtnRef.disabled = false;
668
+ scBtnRef.style.backgroundColor = "#ffffff";
669
+ scBtnRef.style.color = "#0078d4";
670
+ scBtnRef.style.borderColor = "#0078d4";
671
+ scBtnRef.style.cursor = "pointer";
672
+ scBtnRef.innerText = "Resend Code";
496
673
  }
497
674
  }, 1000);
498
675
  };
676
+ /**
677
+ * Dispatches the verification passcode through the configured
678
+ * channel (email / Teams) and starts the cooldown timer.
679
+ */
499
680
  const triggerSendCode = async () => {
500
- sendCodeButton.innerText = "Sending...";
501
- sendCodeButton.disabled = true;
681
+ scBtnRef.innerText = "Sending...";
682
+ scBtnRef.disabled = true;
502
683
  try {
503
684
  if (context.spContext && context.channel) {
504
- const vResult = await (0, VerificationService_1.createPendingVerification)(context.spContext, {
685
+ const vResult = await (0, OtpService_1.dispatchOtpPasscode)(context.spContext, {
505
686
  title: context.signer,
506
687
  passcode: generatedPasscode,
507
688
  channel: context.channel,
@@ -522,23 +703,50 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
522
703
  startCooldownTimer();
523
704
  }
524
705
  };
525
- sendCodeButton.onclick = async () => {
706
+ scBtnRef.onclick = async () => {
526
707
  await triggerSendCode();
527
708
  };
528
- verificationInput.oninput = () => {
709
+ vInputRef.oninput = () => {
529
710
  updateButtonState();
530
711
  };
531
712
  void triggerSendCode();
532
713
  }
714
+ // -----------------------------------------------------------------
715
+ // Canvas drawing helpers
716
+ // -----------------------------------------------------------------
717
+ /**
718
+ * Translates a mouse or touch event into canvas-relative
719
+ * coordinates, accounting for CSS scaling.
720
+ *
721
+ * @param e - The originating mouse or touch event.
722
+ * @returns `{ x, y }` in canvas-pixel space.
723
+ */
533
724
  const getPos = (e) => {
534
725
  const rect = canvas.getBoundingClientRect();
535
- const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
536
- const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
726
+ let clientX;
727
+ let clientY;
728
+ if ("touches" in e && e.touches.length > 0) {
729
+ clientX = e.touches[0].clientX;
730
+ clientY = e.touches[0].clientY;
731
+ }
732
+ else if ("changedTouches" in e && e.changedTouches.length > 0) {
733
+ clientX = e.changedTouches[0].clientX;
734
+ clientY = e.changedTouches[0].clientY;
735
+ }
736
+ else {
737
+ clientX = e.clientX;
738
+ clientY = e.clientY;
739
+ }
537
740
  return {
538
741
  x: (clientX - rect.left) * (canvas.width / rect.width),
539
- y: (clientY - rect.top) * (canvas.height / rect.height)
742
+ y: (clientY - rect.top) * (canvas.height / rect.height),
540
743
  };
541
744
  };
745
+ /**
746
+ * Begins a new drawing stroke at the pointer position.
747
+ *
748
+ * @param e - The initiating mouse/touch event.
749
+ */
542
750
  const startDraw = (e) => {
543
751
  isDrawing = true;
544
752
  hasDrawnContent = true;
@@ -547,6 +755,11 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
547
755
  ctx === null || ctx === void 0 ? void 0 : ctx.moveTo(pos.x, pos.y);
548
756
  e.preventDefault();
549
757
  };
758
+ /**
759
+ * Extends the current stroke to the pointer's new position.
760
+ *
761
+ * @param e - The move event.
762
+ */
550
763
  const drawLine = (e) => {
551
764
  if (!isDrawing || !ctx)
552
765
  return;
@@ -556,6 +769,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
556
769
  updateButtonState();
557
770
  e.preventDefault();
558
771
  };
772
+ /** Ends the current drawing stroke. */
559
773
  const stopDraw = () => {
560
774
  isDrawing = false;
561
775
  updateButtonState();
@@ -563,9 +777,10 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
563
777
  canvas.addEventListener("mousedown", startDraw);
564
778
  canvas.addEventListener("mousemove", drawLine);
565
779
  window.addEventListener("mouseup", stopDraw);
566
- canvas.addEventListener("touchstart", startDraw);
567
- canvas.addEventListener("touchmove", drawLine);
780
+ canvas.addEventListener("touchstart", startDraw, { passive: false });
781
+ canvas.addEventListener("touchmove", drawLine, { passive: false });
568
782
  window.addEventListener("touchend", stopDraw);
783
+ /** Clears the drawing canvas back to a blank white rectangle. */
569
784
  clearButton.onclick = () => {
570
785
  if (ctx) {
571
786
  ctx.fillStyle = "#ffffff";
@@ -574,10 +789,23 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
574
789
  updateButtonState();
575
790
  }
576
791
  };
792
+ // -----------------------------------------------------------------
793
+ // File upload handler
794
+ // -----------------------------------------------------------------
795
+ /**
796
+ * Handles image file selection: validates size, resizes if needed,
797
+ * and stores the result as a data-URI.
798
+ */
577
799
  fileInput.onchange = (e) => {
578
800
  const target = e.target;
579
801
  if (target.files && target.files[0]) {
580
802
  const file = target.files[0];
803
+ /* Enforce the advertised 5 MB limit. */
804
+ if (file.size > MAX_UPLOAD_BYTES) {
805
+ alert(`File size exceeds 5 MB (${(file.size / 1024 / 1024).toFixed(1)} MB). Please choose a smaller image.`);
806
+ target.value = "";
807
+ return;
808
+ }
581
809
  const reader = new FileReader();
582
810
  reader.onload = (uploadEvent) => {
583
811
  var _a;
@@ -604,7 +832,8 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
604
832
  tCtx.fillStyle = "#ffffff";
605
833
  tCtx.fillRect(0, 0, w, h);
606
834
  tCtx.drawImage(img, 0, 0, w, h);
607
- compressedUploadBase64 = tempCanvas.toDataURL("image/png");
835
+ compressedUploadBase64 =
836
+ tempCanvas.toDataURL("image/png");
608
837
  uploadPreview.src = compressedUploadBase64;
609
838
  uploadPreview.style.display = "block";
610
839
  updateButtonState();
@@ -616,6 +845,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
616
845
  reader.readAsDataURL(file);
617
846
  }
618
847
  };
848
+ // -----------------------------------------------------------------
849
+ // Footer (Cancel + Sign buttons)
850
+ // -----------------------------------------------------------------
619
851
  const footerSection = document.createElement("div");
620
852
  footerSection.style.padding = "20px 24px";
621
853
  footerSection.style.backgroundColor = "#ffffff";
@@ -650,16 +882,33 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
650
882
  signButton.style.backgroundColor = "#0078d4";
651
883
  }
652
884
  };
885
+ // -----------------------------------------------------------------
886
+ // Cleanup helper
887
+ // -----------------------------------------------------------------
888
+ /**
889
+ * Removes the modal overlay from the DOM and unregisters window-
890
+ * level event listeners to prevent memory leaks.
891
+ */
653
892
  const cleanup = () => {
893
+ window.removeEventListener("mouseup", stopDraw);
894
+ window.removeEventListener("touchend", stopDraw);
654
895
  document.body.removeChild(overlay);
655
896
  };
897
+ /** Cancel button dismisses the dialog and resolves with `undefined`. */
656
898
  cancelButton.onclick = () => {
657
899
  cleanup();
658
900
  resolve(undefined);
659
901
  };
902
+ // -----------------------------------------------------------------
903
+ // Sign button handler
904
+ // -----------------------------------------------------------------
905
+ /**
906
+ * Validates the TFA code (if required), extracts the signature
907
+ * data from the active panel, compresses it, optionally caches it,
908
+ * then generates the SHA-256 audit record.
909
+ */
660
910
  signButton.onclick = async () => {
661
- if (requireTFA) {
662
- const verificationInput = verificationContainer.querySelector("input");
911
+ if (requireTFA && verificationInput) {
663
912
  const enteredCode = verificationInput.value.trim();
664
913
  if (enteredCode !== generatedPasscode) {
665
914
  alert("Invalid verification code. Please enter the correct 5-digit code.");
@@ -675,14 +924,16 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
675
924
  finalDataUrl = lzwDecompress(cachedCompressedSig);
676
925
  }
677
926
  else if (activeMode === "draw") {
678
- if (!ctx || isCanvasEmpty(ctx, canvas.width, canvas.height)) {
927
+ if (!ctx ||
928
+ isCanvasEmpty(ctx, canvas.width, canvas.height)) {
679
929
  alert("Please draw your signature before proceeding.");
680
930
  return;
681
931
  }
682
932
  finalDataUrl = downscaleCanvas(canvas, 560, 160);
683
933
  }
684
934
  else {
685
- if (!compressedUploadBase64 || compressedUploadBase64.trim() === "") {
935
+ if (!compressedUploadBase64 ||
936
+ compressedUploadBase64.trim() === "") {
686
937
  alert("Please upload a signature image before proceeding.");
687
938
  return;
688
939
  }
@@ -699,7 +950,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
699
950
  try {
700
951
  const fullContext = Object.assign(Object.assign({}, context), { signatureData: compressedString });
701
952
  const result = await generateSecureAuditRecordInternal(fullContext);
702
- if (storedVerificationItemId) {
953
+ if (storedVerificationItemId !== undefined) {
703
954
  result.verificationItemId = storedVerificationItemId;
704
955
  }
705
956
  resolve(result);
@@ -710,6 +961,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
710
961
  }
711
962
  };
712
963
  updateButtonState();
964
+ // -----------------------------------------------------------------
965
+ // Assemble DOM tree
966
+ // -----------------------------------------------------------------
713
967
  footerSection.appendChild(cancelButton);
714
968
  footerSection.appendChild(signButton);
715
969
  contentSection.appendChild(body);
@@ -731,6 +985,20 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
731
985
  });
732
986
  }
733
987
  exports.promptAndGenerateSecureAudit = promptAndGenerateSecureAudit;
988
+ // ---------------------------------------------------------------------------
989
+ // Signature decompression for reports
990
+ // ---------------------------------------------------------------------------
991
+ /**
992
+ * Decompresses an LZW-compressed signature string back into its
993
+ * original `data:image/…` data-URI for display in reports or
994
+ * print views.
995
+ *
996
+ * @param compressedSignatureData - The LZW-compressed string stored
997
+ * in SharePoint.
998
+ * @returns The original data-URI, or an empty string if
999
+ * decompression fails or the result is not an image
1000
+ * data-URI.
1001
+ */
734
1002
  function getReportableSignature(compressedSignatureData) {
735
1003
  const decompressed = lzwDecompress(compressedSignatureData);
736
1004
  if (!decompressed || !decompressed.startsWith("data:image")) {
@@ -739,6 +1007,22 @@ function getReportableSignature(compressedSignatureData) {
739
1007
  return decompressed;
740
1008
  }
741
1009
  exports.getReportableSignature = getReportableSignature;
1010
+ // ---------------------------------------------------------------------------
1011
+ // LZW compression / decompression
1012
+ // ---------------------------------------------------------------------------
1013
+ /**
1014
+ * Compresses a string using the LZW (Lempel-Ziv-Welch) algorithm.
1015
+ *
1016
+ * **Caveat:** When the dictionary grows past 65 535 entries,
1017
+ * `String.fromCharCode` will produce values that may not survive
1018
+ * a round-trip through `localStorage` or JSON. For very large
1019
+ * inputs consider chunking or switching to a Uint16Array-backed
1020
+ * encoding.
1021
+ *
1022
+ * @param input - The raw string to compress.
1023
+ * @returns The compressed string (each character encodes one
1024
+ * dictionary index).
1025
+ */
742
1026
  function lzwCompress(input) {
743
1027
  const dictionary = {};
744
1028
  let c = "";
@@ -764,8 +1048,17 @@ function lzwCompress(input) {
764
1048
  if (w !== "") {
765
1049
  result.push(dictionary[w]);
766
1050
  }
767
- return result.map((n) => String.fromCharCode(n)).join("");
1051
+ return result
1052
+ .map((n) => String.fromCharCode(n))
1053
+ .join("");
768
1054
  }
1055
+ /**
1056
+ * Decompresses a string that was compressed with {@link lzwCompress}.
1057
+ *
1058
+ * @param compressed - The LZW-compressed string.
1059
+ * @returns The original string, or an empty string if the input is
1060
+ * empty or contains an unrecognised dictionary reference.
1061
+ */
769
1062
  function lzwDecompress(compressed) {
770
1063
  const dictionary = {};
771
1064
  const result = [];
@@ -780,13 +1073,14 @@ function lzwDecompress(compressed) {
780
1073
  let entry = "";
781
1074
  for (let i = 1; i < compressed.length; i += 1) {
782
1075
  const k = compressed.charCodeAt(i);
783
- if (dictionary[k]) {
1076
+ if (dictionary[k] !== undefined) {
784
1077
  entry = dictionary[k];
785
1078
  }
786
1079
  else if (k === dictionarySize) {
787
1080
  entry = w + w.charAt(0);
788
1081
  }
789
1082
  else {
1083
+ console.warn(`lzwDecompress: unrecognised dictionary index ${k} at position ${i}.`);
790
1084
  return "";
791
1085
  }
792
1086
  result.push(entry);
@@ -795,6 +1089,18 @@ function lzwDecompress(compressed) {
795
1089
  }
796
1090
  return result.join("");
797
1091
  }
1092
+ // ---------------------------------------------------------------------------
1093
+ // Canvas utilities
1094
+ // ---------------------------------------------------------------------------
1095
+ /**
1096
+ * Draws the contents of `sourceCanvas` onto a new canvas at the
1097
+ * specified dimensions and returns the result as a PNG data-URI.
1098
+ *
1099
+ * @param sourceCanvas - The canvas element to read from.
1100
+ * @param targetWidth - Desired output width in pixels.
1101
+ * @param targetHeight - Desired output height in pixels.
1102
+ * @returns A `data:image/png;base64,…` string.
1103
+ */
798
1104
  function downscaleCanvas(sourceCanvas, targetWidth, targetHeight) {
799
1105
  const tempCanvas = document.createElement("canvas");
800
1106
  tempCanvas.width = targetWidth;
@@ -807,6 +1113,16 @@ function downscaleCanvas(sourceCanvas, targetWidth, targetHeight) {
807
1113
  }
808
1114
  return tempCanvas.toDataURL("image/png");
809
1115
  }
1116
+ /**
1117
+ * Determines whether every pixel on the canvas is pure white
1118
+ * (`rgb(255, 255, 255)`), which indicates that the user has not
1119
+ * drawn anything yet.
1120
+ *
1121
+ * @param ctx - The 2D rendering context of the canvas.
1122
+ * @param width - Canvas width in pixels.
1123
+ * @param height - Canvas height in pixels.
1124
+ * @returns `true` if the canvas contains only white pixels.
1125
+ */
810
1126
  function isCanvasEmpty(ctx, width, height) {
811
1127
  const pixelBuffer = ctx.getImageData(0, 0, width, height).data;
812
1128
  for (let i = 0; i < pixelBuffer.length; i += 4) {
@@ -818,7 +1134,31 @@ function isCanvasEmpty(ctx, width, height) {
818
1134
  }
819
1135
  return true;
820
1136
  }
821
- async function verifySecureAuditRecord(payloadToVerify, signer, timestamp, compressedSignatureData, storedHash) {
1137
+ // ---------------------------------------------------------------------------
1138
+ // Audit-record verification
1139
+ // ---------------------------------------------------------------------------
1140
+ /**
1141
+ * Re-computes the SHA-256 hash of the audit envelope constructed from
1142
+ * the supplied parameters and compares it to `storedHash`.
1143
+ *
1144
+ * This allows any consumer to independently verify that a signed
1145
+ * record has not been tampered with, without needing access to the
1146
+ * original signature image.
1147
+ *
1148
+ * **Note:** Only top-level payload keys are sorted. If your payload
1149
+ * contains nested objects whose key order may vary, consider using a
1150
+ * deep-sort utility before calling this function.
1151
+ *
1152
+ * @param payloadToVerify - The payload that was originally signed.
1153
+ * @param signer - The signer identifier used at sign time.
1154
+ * @param timestamp - The ISO-8601 timestamp captured at sign
1155
+ * time.
1156
+ * @param _compressedSignatureData - The compressed signature (unused by the
1157
+ * hash, retained for API symmetry).
1158
+ * @param storedHash - The SHA-256 hex digest to compare against.
1159
+ * @returns `true` if the recomputed hash matches `storedHash`.
1160
+ */
1161
+ async function verifySecureAuditRecord(payloadToVerify, signer, timestamp, _compressedSignatureData, storedHash) {
822
1162
  try {
823
1163
  if (!payloadToVerify || !storedHash)
824
1164
  return false;
@@ -831,7 +1171,8 @@ async function verifySecureAuditRecord(payloadToVerify, signer, timestamp, compr
831
1171
  const auditEnvelopeJson = JSON.stringify(auditEnvelope);
832
1172
  const encoder = new TextEncoder();
833
1173
  const encodedBytes = encoder.encode(auditEnvelopeJson);
834
- const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes);
1174
+ // Fixed BufferSource type incompatibility:
1175
+ const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes.buffer);
835
1176
  const hashArray = Array.from(new Uint8Array(hashBuffer));
836
1177
  const recomputedHash = hashArray
837
1178
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -843,29 +1184,50 @@ async function verifySecureAuditRecord(payloadToVerify, signer, timestamp, compr
843
1184
  }
844
1185
  }
845
1186
  exports.verifySecureAuditRecord = verifySecureAuditRecord;
1187
+ // ---------------------------------------------------------------------------
1188
+ // Internal – audit-record generation
1189
+ // ---------------------------------------------------------------------------
1190
+ /**
1191
+ * Constructs the canonical audit envelope from the signer context,
1192
+ * hashes it with SHA-256, and returns a `SharePointAuditRecord`
1193
+ * ready for persistence.
1194
+ *
1195
+ * This is an internal function and is not exported.
1196
+ *
1197
+ * @param context - The full signer context, extended with the
1198
+ * compressed signature data string.
1199
+ * @returns A fully populated `SharePointAuditRecord`.
1200
+ *
1201
+ * @throws {Error} If the payload is empty or the signer is blank.
1202
+ */
846
1203
  async function generateSecureAuditRecordInternal(context) {
847
1204
  const { payload, signer, signatureData } = context;
848
- if (!payload || Object.keys(payload).length === 0)
1205
+ if (!payload || Object.keys(payload).length === 0) {
849
1206
  throw new Error("Payload cannot be empty.");
850
- if (!signer || signer.trim() === "")
1207
+ }
1208
+ if (!signer || signer.trim() === "") {
851
1209
  throw new Error("Signer identifier is required for sealing.");
1210
+ }
852
1211
  const signatureTimestamp = new Date().toISOString();
853
1212
  const sortedPayloadString = JSON.stringify(payload, Object.keys(payload).sort());
854
1213
  const auditEnvelope = {
855
1214
  payload: JSON.parse(sortedPayloadString),
856
1215
  signer: signer.toLowerCase().trim(),
857
- timestamp: signatureTimestamp.trim()
1216
+ timestamp: signatureTimestamp.trim(),
858
1217
  };
859
1218
  const auditEnvelopeJson = JSON.stringify(auditEnvelope);
860
1219
  const encoder = new TextEncoder();
861
1220
  const encodedBytes = encoder.encode(auditEnvelopeJson);
862
- const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes);
1221
+ // Passing `encodedBytes.buffer as ArrayBuffer` satisfies the DOM BufferSource definition
1222
+ const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes.buffer);
863
1223
  const hashArray = Array.from(new Uint8Array(hashBuffer));
864
- const signatureHash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
1224
+ const signatureHash = hashArray
1225
+ .map((b) => b.toString(16).padStart(2, "0"))
1226
+ .join("");
865
1227
  return {
866
1228
  signatureHash,
867
1229
  signatureData,
868
1230
  signatureTimestamp,
869
- verificationItemId: context.itemID
1231
+ verificationItemId: context.itemID,
870
1232
  };
871
1233
  }