@josephomills/esign 0.2.2

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.
@@ -0,0 +1,1016 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+ var zod = require('zod');
5
+ var pdfLib = require('pdf-lib');
6
+
7
+ // src/server/tokens.ts
8
+ function sha256Hex(input) {
9
+ return crypto.createHash("sha256").update(input).digest("hex");
10
+ }
11
+ function newSigningToken(bytes = 32) {
12
+ const cleartext = crypto.randomBytes(bytes).toString("base64url");
13
+ return { cleartext, tokenHash: sha256Hex(cleartext) };
14
+ }
15
+
16
+ // src/server/audit-chain.ts
17
+ function canonicalize(value) {
18
+ if (value === void 0) return "null";
19
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
20
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
21
+ const obj = value;
22
+ const keys = Object.keys(obj).sort();
23
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`).join(",")}}`;
24
+ }
25
+ function linkHash(prevHash, seq, type, occurredAt, payload) {
26
+ return sha256Hex(
27
+ `${prevHash ?? ""}|${seq}|${type}|${occurredAt}|${canonicalize(payload)}`
28
+ );
29
+ }
30
+ function appendEvent(prev, input) {
31
+ const seq = prev ? prev.seq + 1 : 0;
32
+ const prevHash = prev ? prev.hash : null;
33
+ const hash = linkHash(prevHash, seq, input.type, input.occurredAt, input.payload);
34
+ return { ...input, seq, prevHash, hash };
35
+ }
36
+ function buildChain(inputs) {
37
+ const out = [];
38
+ let prev = null;
39
+ for (const input of inputs) {
40
+ prev = appendEvent(prev, input);
41
+ out.push(prev);
42
+ }
43
+ return out;
44
+ }
45
+ function verifyChain(links) {
46
+ let prev = null;
47
+ for (const link of links) {
48
+ const expectedSeq = prev ? prev.seq + 1 : 0;
49
+ const expectedPrev = prev ? prev.hash : null;
50
+ const expectedHash = linkHash(
51
+ expectedPrev,
52
+ link.seq,
53
+ link.type,
54
+ link.occurredAt,
55
+ link.payload
56
+ );
57
+ if (link.seq !== expectedSeq || link.prevHash !== expectedPrev || link.hash !== expectedHash) {
58
+ return { ok: false, brokenAt: link.seq };
59
+ }
60
+ prev = link;
61
+ }
62
+ return { ok: true, brokenAt: null };
63
+ }
64
+
65
+ // src/server/errors.ts
66
+ var EsignError = class extends Error {
67
+ code;
68
+ details;
69
+ status;
70
+ constructor(code, message, details) {
71
+ super(message);
72
+ this.name = "EsignError";
73
+ this.code = code;
74
+ this.details = details;
75
+ this.status = statusFor(code);
76
+ }
77
+ };
78
+ function statusFor(code) {
79
+ switch (code) {
80
+ case "UNAUTHENTICATED":
81
+ return 401;
82
+ case "FORBIDDEN":
83
+ return 403;
84
+ case "NOT_FOUND":
85
+ return 404;
86
+ case "INVALID_INPUT":
87
+ return 422;
88
+ case "CONFLICT":
89
+ return 409;
90
+ case "GONE":
91
+ return 410;
92
+ case "RATE_LIMITED":
93
+ return 429;
94
+ case "PAYLOAD_TOO_LARGE":
95
+ return 413;
96
+ case "STORAGE_ERROR":
97
+ return 502;
98
+ case "INTERNAL":
99
+ default:
100
+ return 500;
101
+ }
102
+ }
103
+ function toErrorResponse(err) {
104
+ if (err instanceof EsignError) {
105
+ return Response.json(
106
+ { error: { code: err.code, message: err.message, details: err.details } },
107
+ { status: err.status }
108
+ );
109
+ }
110
+ console.error("[esign] unexpected error", err);
111
+ return Response.json(
112
+ { error: { code: "INTERNAL", message: "Unexpected error." } },
113
+ { status: 500 }
114
+ );
115
+ }
116
+ var ESIGN_CHANNELS = [
117
+ "EMAIL",
118
+ "TELEGRAM",
119
+ "SMS",
120
+ "WHATSAPP"
121
+ ];
122
+ var esignChannelSchema = zod.z.enum(["EMAIL", "TELEGRAM", "SMS", "WHATSAPP"]);
123
+ var unit = zod.z.number().min(0).max(1);
124
+ var placementSchema = zod.z.object({
125
+ page: zod.z.number().int().min(1),
126
+ x: unit,
127
+ y: unit,
128
+ w: zod.z.number().min(0.01).max(1),
129
+ h: zod.z.number().min(0.01).max(1)
130
+ }).strict();
131
+ function addressFor(recipient, channel) {
132
+ switch (channel) {
133
+ case "EMAIL":
134
+ return recipient.email ?? null;
135
+ case "SMS":
136
+ return recipient.phone ?? null;
137
+ case "WHATSAPP":
138
+ return recipient.whatsapp ?? recipient.phone ?? null;
139
+ case "TELEGRAM":
140
+ return null;
141
+ }
142
+ }
143
+ var subjectSelectionSchema = zod.z.union([
144
+ zod.z.object({
145
+ mode: zod.z.literal("all"),
146
+ type: zod.z.string().min(1),
147
+ group: zod.z.string().min(1).optional(),
148
+ filter: zod.z.record(zod.z.unknown()).optional()
149
+ }).strict(),
150
+ zod.z.object({
151
+ mode: zod.z.literal("ids"),
152
+ type: zod.z.string().min(1),
153
+ subjectIds: zod.z.array(zod.z.string().min(1)).min(1)
154
+ }).strict()
155
+ ]);
156
+ var submitSignatureSchema = zod.z.object({
157
+ signaturePng: zod.z.string().min(1).max(15e5),
158
+ // ~1MB cap on the data URL
159
+ signerName: zod.z.string().trim().min(1).max(200),
160
+ consent: zod.z.literal(true)
161
+ }).strict();
162
+ var declineSchema = zod.z.object({ reason: zod.z.string().trim().max(500).optional() }).strict();
163
+ var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
164
+ function uid(size = 16) {
165
+ let out = "";
166
+ while (out.length < size) {
167
+ for (const byte of crypto.randomBytes(size)) {
168
+ if (byte < 248) {
169
+ out += ALPHABET[byte % 62];
170
+ if (out.length === size) break;
171
+ }
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // src/server/campaign.ts
178
+ async function recordEvent(persistence, requestId, prev, input) {
179
+ const link = appendEvent(prev, input);
180
+ await persistence.appendAuditEvent({
181
+ requestId,
182
+ seq: link.seq,
183
+ type: link.type,
184
+ payload: link.payload,
185
+ prevHash: link.prevHash,
186
+ hash: link.hash
187
+ });
188
+ return link;
189
+ }
190
+ var defaultContent = ({
191
+ documentTitle,
192
+ note,
193
+ recipientName
194
+ }) => ({
195
+ subject: `Please sign: ${documentTitle}`,
196
+ body: [
197
+ `Hello ${recipientName},`,
198
+ note ?? `You have a document to review and sign: ${documentTitle}.`,
199
+ "Open the secure link to sign \u2014 no account or login needed."
200
+ ].filter(Boolean).join("\n\n")
201
+ });
202
+ var DAY_MS = 864e5;
203
+ async function createCampaign(deps, args) {
204
+ const { persistence, subjects, notifier, clock, baseUrl, defaultExpiryDays } = deps;
205
+ const content = deps.content ?? defaultContent;
206
+ if (args.channels.length === 0) {
207
+ throw new EsignError("INVALID_INPUT", "Choose at least one channel.");
208
+ }
209
+ const version = await persistence.getVersion(args.documentVersionId);
210
+ if (!version) throw new EsignError("NOT_FOUND", "Document version not found.");
211
+ const document = await persistence.getDocument(version.documentId);
212
+ if (!document) throw new EsignError("NOT_FOUND", "Document not found.");
213
+ const descriptor = subjects.get(args.selection.type);
214
+ if (!descriptor) {
215
+ throw new EsignError(
216
+ "INVALID_INPUT",
217
+ `Unknown subject type: ${args.selection.type}.`
218
+ );
219
+ }
220
+ const configured = new Set(notifier.configuredChannels());
221
+ const channels = args.channels.filter((c) => configured.has(c));
222
+ if (channels.length === 0) {
223
+ throw new EsignError(
224
+ "INVALID_INPUT",
225
+ "None of the chosen channels are configured."
226
+ );
227
+ }
228
+ const scopeId = args.scopeId ?? document.scopeId;
229
+ const resolved = await descriptor.resolveRecipients({
230
+ scopeId,
231
+ selection: args.selection,
232
+ channels
233
+ });
234
+ const skipped = resolved.skipped;
235
+ let recipients = resolved.recipients;
236
+ if (args.resendPolicy && args.resendPolicy !== "all") {
237
+ const summaries = await persistence.requestSummaryBySubject(version.documentId);
238
+ const byId = new Map(summaries.map((s) => [s.subjectId, s]));
239
+ const vNum = version.version;
240
+ recipients = recipients.filter((r) => {
241
+ const s = byId.get(r.subjectId);
242
+ switch (args.resendPolicy) {
243
+ case "uncovered":
244
+ return !s;
245
+ case "outstanding":
246
+ return !(s && s.status === "SIGNED" && s.version >= vNum);
247
+ case "olderVersion":
248
+ return Boolean(s && s.status === "SIGNED" && s.version < vNum);
249
+ default:
250
+ return true;
251
+ }
252
+ });
253
+ }
254
+ if (recipients.length === 0) return { campaignId: null, sent: 0, skipped };
255
+ const now = clock.now();
256
+ const expiresAt = new Date(
257
+ now.getTime() + (args.expiresInDays ?? defaultExpiryDays) * DAY_MS
258
+ );
259
+ const campaign = await persistence.createCampaign({
260
+ documentId: version.documentId,
261
+ documentVersionId: version.id,
262
+ scopeId,
263
+ note: args.note ?? null,
264
+ emailReceipt: args.emailReceipt ?? false,
265
+ targeting: args.selection,
266
+ expiresAt,
267
+ createdById: args.createdById ?? null
268
+ });
269
+ const targetedGroup = args.selection.mode === "all" ? args.selection.group ?? null : null;
270
+ const prepared = recipients.map((r) => {
271
+ const token = newSigningToken();
272
+ const row = {
273
+ id: uid(),
274
+ campaignId: campaign.id,
275
+ documentId: version.documentId,
276
+ documentVersionId: version.id,
277
+ scopeId,
278
+ subjectType: r.subjectType,
279
+ subjectId: r.subjectId,
280
+ subjectGroup: targetedGroup ?? r.group ?? null,
281
+ recipientName: r.name,
282
+ recipientEmail: r.email ?? null,
283
+ recipientWhatsapp: r.whatsapp ?? null,
284
+ channels,
285
+ tokenHash: token.tokenHash,
286
+ expiresAt
287
+ };
288
+ return { row, cleartext: token.cleartext, recipient: r };
289
+ });
290
+ await persistence.createRequests(prepared.map((p) => p.row));
291
+ const base = baseUrl.replace(/\/$/, "");
292
+ const nowIso = now.toISOString();
293
+ let sent = 0;
294
+ for (const { row, cleartext, recipient } of prepared) {
295
+ let prev = await recordEvent(persistence, row.id, null, {
296
+ type: "CREATED",
297
+ payload: {
298
+ subjectType: row.subjectType,
299
+ subjectId: row.subjectId,
300
+ by: args.createdById ?? null
301
+ },
302
+ occurredAt: nowIso
303
+ });
304
+ const actionUrl = `${base}/sign/${cleartext}`;
305
+ const c = content({
306
+ documentTitle: document.title,
307
+ note: args.note ?? null,
308
+ recipientName: recipient.name
309
+ });
310
+ for (const channel of channels) {
311
+ const address = addressFor(recipient, channel);
312
+ if (!address) continue;
313
+ let ok2 = false;
314
+ let error = null;
315
+ try {
316
+ const result = await notifier.send({
317
+ channel,
318
+ recipient: address,
319
+ subject: c.subject,
320
+ body: c.body,
321
+ actionUrl,
322
+ idempotencyKey: row.id
323
+ });
324
+ ok2 = result.ok;
325
+ error = result.error ?? null;
326
+ } catch (err) {
327
+ ok2 = false;
328
+ error = err instanceof Error ? err.message : String(err);
329
+ }
330
+ prev = await recordEvent(persistence, row.id, prev, {
331
+ type: "NOTIFIED",
332
+ payload: { channel, ok: ok2, error },
333
+ occurredAt: nowIso
334
+ });
335
+ }
336
+ sent++;
337
+ }
338
+ return { campaignId: campaign.id, sent, skipped };
339
+ }
340
+
341
+ // src/server/coverage.ts
342
+ async function documentCoverage(deps, documentId) {
343
+ const { persistence, subjects } = deps;
344
+ const doc = await persistence.getDocument(documentId);
345
+ if (!doc) throw new EsignError("NOT_FOUND", "Document not found.");
346
+ const summaries = await persistence.requestSummaryBySubject(documentId);
347
+ const summaryIds = new Set(summaries.map((s) => s.subjectId));
348
+ let currentNumber = 0;
349
+ if (doc.currentVersionId) {
350
+ const v = await persistence.getVersion(doc.currentVersionId);
351
+ currentNumber = v?.version ?? 0;
352
+ }
353
+ let signed = 0;
354
+ let outstanding = 0;
355
+ let needsResign = 0;
356
+ for (const s of summaries) {
357
+ if (s.status === "SIGNED") {
358
+ if (s.version >= currentNumber) signed++;
359
+ else needsResign++;
360
+ } else if (s.status === "PENDING" || s.status === "VIEWED") {
361
+ outstanding++;
362
+ }
363
+ }
364
+ let live = [];
365
+ if (doc.audience) {
366
+ const descriptor = subjects.get(doc.audience.type);
367
+ if (descriptor) {
368
+ const page = await descriptor.listSubjects({
369
+ scopeId: doc.scopeId,
370
+ group: doc.audience.mode === "all" ? doc.audience.group : void 0,
371
+ filter: doc.audience.mode === "all" ? doc.audience.filter : void 0,
372
+ limit: 1e3
373
+ });
374
+ live = page.subjects;
375
+ }
376
+ }
377
+ const liveIds = new Set(live.map((s) => s.subjectId));
378
+ const uncovered = live.filter((s) => !summaryIds.has(s.subjectId));
379
+ const departed = doc.audience ? summaries.filter((s) => !liveIds.has(s.subjectId)).map((s) => ({ subjectType: doc.audience.type, subjectId: s.subjectId })) : [];
380
+ return {
381
+ documentId,
382
+ totalNow: live.length,
383
+ signed,
384
+ outstanding,
385
+ needsResign,
386
+ uncovered,
387
+ departed
388
+ };
389
+ }
390
+ async function createDocument(persistence, input) {
391
+ if (!input.title.trim()) {
392
+ throw new EsignError("INVALID_INPUT", "A document title is required.");
393
+ }
394
+ return persistence.createDocument({
395
+ scopeId: input.scopeId ?? null,
396
+ documentType: input.documentType ?? null,
397
+ title: input.title,
398
+ audience: input.audience ?? null,
399
+ createdById: input.createdById ?? null
400
+ });
401
+ }
402
+ async function assertReadablePdf(bytes) {
403
+ try {
404
+ await pdfLib.PDFDocument.load(bytes);
405
+ } catch {
406
+ throw new EsignError(
407
+ "INVALID_INPUT",
408
+ "The upload is not a readable PDF (it may be encrypted or corrupt)."
409
+ );
410
+ }
411
+ }
412
+ async function addVersion(deps, input) {
413
+ const { persistence, storage } = deps;
414
+ let sourceObjectKey;
415
+ let bytes;
416
+ if ("objectKey" in input.source) {
417
+ sourceObjectKey = input.source.objectKey;
418
+ const fetched = await storage.get(sourceObjectKey);
419
+ if (!fetched) {
420
+ throw new EsignError("INVALID_INPUT", "Uploaded PDF was not found in storage.");
421
+ }
422
+ bytes = fetched.bytes;
423
+ } else {
424
+ bytes = input.source.pdfBytes;
425
+ sourceObjectKey = storage.stampKey({
426
+ scopeId: input.source.scopeId ?? null,
427
+ documentId: input.documentId,
428
+ contentType: "application/pdf"
429
+ });
430
+ await storage.put({
431
+ objectKey: sourceObjectKey,
432
+ bytes,
433
+ contentType: "application/pdf"
434
+ });
435
+ }
436
+ await assertReadablePdf(bytes);
437
+ const latest = await persistence.latestVersion(input.documentId);
438
+ const version = await persistence.createVersion({
439
+ documentId: input.documentId,
440
+ version: (latest?.version ?? 0) + 1,
441
+ sourceObjectKey,
442
+ sourceSha256: sha256Hex(bytes),
443
+ placement: input.placement,
444
+ changeNote: input.changeNote ?? null,
445
+ createdById: input.createdById ?? null
446
+ });
447
+ await persistence.updateDocument(input.documentId, {
448
+ currentVersionId: version.id
449
+ });
450
+ return version;
451
+ }
452
+
453
+ // src/server/route-utils.ts
454
+ function publicRoute(handler) {
455
+ return async (req, ctx) => {
456
+ try {
457
+ return await handler(req, ctx);
458
+ } catch (err) {
459
+ return toErrorResponse(err);
460
+ }
461
+ };
462
+ }
463
+ async function parseBody(req, schema) {
464
+ let raw;
465
+ try {
466
+ raw = await req.json();
467
+ } catch {
468
+ throw new EsignError("INVALID_INPUT", "Request body must be valid JSON.");
469
+ }
470
+ const parsed = schema.safeParse(raw);
471
+ if (!parsed.success) {
472
+ throw new EsignError("INVALID_INPUT", "Validation failed.", parsed.error.flatten());
473
+ }
474
+ return parsed.data;
475
+ }
476
+ function ok(data, init) {
477
+ return Response.json(data, init);
478
+ }
479
+ function clientIp(req) {
480
+ const fwd = req.headers.get("x-forwarded-for");
481
+ if (fwd) return fwd.split(",")[0]?.trim() ?? null;
482
+ return req.headers.get("x-real-ip");
483
+ }
484
+
485
+ // src/server/flow.ts
486
+ var ESIGN_STATUSES = [
487
+ "PENDING",
488
+ "VIEWED",
489
+ "SIGNED",
490
+ "DECLINED",
491
+ "EXPIRED",
492
+ "REVOKED"
493
+ ];
494
+ var ACTIVE_STATUSES = ["PENDING", "VIEWED"];
495
+ var TERMINAL_STATUSES = [
496
+ "SIGNED",
497
+ "DECLINED",
498
+ "EXPIRED",
499
+ "REVOKED"
500
+ ];
501
+ var TRANSITIONS = {
502
+ PENDING: ["VIEWED", "SIGNED", "DECLINED", "EXPIRED", "REVOKED"],
503
+ VIEWED: ["SIGNED", "DECLINED", "EXPIRED", "REVOKED"],
504
+ SIGNED: [],
505
+ DECLINED: [],
506
+ EXPIRED: [],
507
+ REVOKED: []
508
+ };
509
+ function isActive(status) {
510
+ return ACTIVE_STATUSES.includes(status);
511
+ }
512
+ function isTerminal(status) {
513
+ return TERMINAL_STATUSES.includes(status);
514
+ }
515
+ function canTransition(from, to) {
516
+ if (from === to) return true;
517
+ return TRANSITIONS[from].includes(to);
518
+ }
519
+ function assertTransition(from, to) {
520
+ if (!canTransition(from, to)) {
521
+ throw new EsignError(
522
+ "CONFLICT",
523
+ `Illegal signing-request transition: ${from} \u2192 ${to}.`
524
+ );
525
+ }
526
+ }
527
+ var A4 = [595.28, 841.89];
528
+ var MARGIN = 48;
529
+ var INK = pdfLib.rgb(0.12, 0.12, 0.14);
530
+ var MUTED = pdfLib.rgb(0.4, 0.42, 0.46);
531
+ async function renderAuditCertificatePage(pdf, info) {
532
+ const page = pdf.addPage(A4);
533
+ const font = await pdf.embedFont(pdfLib.StandardFonts.Helvetica);
534
+ const bold = await pdf.embedFont(pdfLib.StandardFonts.HelveticaBold);
535
+ const mono = await pdf.embedFont(pdfLib.StandardFonts.Courier);
536
+ const [pageW, pageH] = A4;
537
+ let y = pageH - MARGIN;
538
+ const draw = (text, opts = {}) => {
539
+ page.drawText(text, {
540
+ x: opts.x ?? MARGIN,
541
+ y,
542
+ size: opts.size ?? 10,
543
+ font: opts.font ?? font,
544
+ color: opts.color ?? INK
545
+ });
546
+ };
547
+ const advance = (by) => {
548
+ y -= by;
549
+ };
550
+ draw("Signature Certificate", { size: 20, font: bold });
551
+ advance(14);
552
+ draw("Electronic signature with a tamper-evident (Level-1 sealed) audit trail.", {
553
+ size: 9,
554
+ color: MUTED
555
+ });
556
+ advance(28);
557
+ const field = (label, value, valueFont = font) => {
558
+ draw(label, { size: 8, font: bold, color: MUTED });
559
+ advance(13);
560
+ draw(value, { size: 10, font: valueFont });
561
+ advance(20);
562
+ };
563
+ field("DOCUMENT", info.documentTitle);
564
+ field("SIGNED BY", info.signerName);
565
+ field("SIGNED AT", info.signedAt);
566
+ field("SIGNER IP", info.signerIp ?? "\u2014");
567
+ field("REQUEST ID", info.requestId, mono);
568
+ field("DOCUMENT SHA-256", info.documentSha256, mono);
569
+ field("AUDIT CHAIN TIP", info.finalHash, mono);
570
+ advance(6);
571
+ draw("AUDIT TRAIL", { size: 8, font: bold, color: MUTED });
572
+ advance(16);
573
+ draw("#", { x: MARGIN, size: 8, font: bold, color: MUTED });
574
+ draw("EVENT", { x: MARGIN + 24, size: 8, font: bold, color: MUTED });
575
+ draw("WHEN", { x: MARGIN + 170, size: 8, font: bold, color: MUTED });
576
+ draw("HASH", { x: MARGIN + 330, size: 8, font: bold, color: MUTED });
577
+ advance(4);
578
+ page.drawLine({
579
+ start: { x: MARGIN, y },
580
+ end: { x: pageW - MARGIN, y },
581
+ thickness: 0.5,
582
+ color: MUTED
583
+ });
584
+ advance(14);
585
+ for (const link of info.links) {
586
+ if (y < MARGIN + 20) break;
587
+ draw(String(link.seq), { x: MARGIN, size: 9 });
588
+ draw(link.type, { x: MARGIN + 24, size: 9 });
589
+ draw(link.occurredAt, { x: MARGIN + 170, size: 8, color: MUTED });
590
+ draw(`${link.hash.slice(0, 16)}\u2026`, { x: MARGIN + 330, size: 8, font: mono });
591
+ advance(15);
592
+ }
593
+ }
594
+
595
+ // src/server/seal.ts
596
+ async function sealPdf(input) {
597
+ let pdf;
598
+ try {
599
+ pdf = await pdfLib.PDFDocument.load(input.sourcePdf);
600
+ } catch {
601
+ throw new EsignError(
602
+ "INVALID_INPUT",
603
+ "Could not read the PDF \u2014 it may be encrypted or corrupt."
604
+ );
605
+ }
606
+ const when = new Date(input.signedAt);
607
+ pdf.setCreationDate(when);
608
+ pdf.setModificationDate(when);
609
+ pdf.setProducer("@josephomills/esign");
610
+ pdf.setCreator("@josephomills/esign");
611
+ const pages = pdf.getPages();
612
+ const pageIndex = Math.min(
613
+ Math.max(input.placement.page - 1, 0),
614
+ pages.length - 1
615
+ );
616
+ const page = pages[pageIndex];
617
+ const pageW = page.getWidth();
618
+ const pageH = page.getHeight();
619
+ const boxX = input.placement.x * pageW;
620
+ const boxW = input.placement.w * pageW;
621
+ const boxH = input.placement.h * pageH;
622
+ const boxBottomY = pageH * (1 - input.placement.y - input.placement.h);
623
+ const png = await pdf.embedPng(input.signaturePng);
624
+ const scale = Math.min(boxW / png.width, boxH / png.height);
625
+ const drawW = png.width * scale;
626
+ const drawH = png.height * scale;
627
+ page.drawImage(png, {
628
+ x: boxX + (boxW - drawW) / 2,
629
+ y: boxBottomY + (boxH - drawH) / 2,
630
+ width: drawW,
631
+ height: drawH
632
+ });
633
+ const font = await pdf.embedFont(pdfLib.StandardFonts.Helvetica);
634
+ const caption = `Signed by ${input.signerName} \xB7 ${input.signedAt}${input.signerIp ? ` \xB7 IP ${input.signerIp}` : ""}`;
635
+ page.drawText(caption, {
636
+ x: boxX,
637
+ y: Math.max(boxBottomY - 11, 4),
638
+ size: 7,
639
+ font,
640
+ color: pdfLib.rgb(0.35, 0.35, 0.4)
641
+ });
642
+ const stampedBytes = await pdf.save({ useObjectStreams: false });
643
+ const documentSha256 = sha256Hex(stampedBytes);
644
+ const last = input.priorChain.at(-1) ?? null;
645
+ const signed = appendEvent(last, {
646
+ type: "SIGNED",
647
+ payload: {
648
+ signerName: input.signerName,
649
+ signerIp: input.signerIp,
650
+ signedAt: input.signedAt
651
+ },
652
+ occurredAt: input.signedAt
653
+ });
654
+ const sealed = appendEvent(signed, {
655
+ type: "SEALED",
656
+ payload: { documentSha256 },
657
+ occurredAt: input.signedAt
658
+ });
659
+ const finalHash = sealed.hash;
660
+ await renderAuditCertificatePage(pdf, {
661
+ documentTitle: input.documentTitle,
662
+ signerName: input.signerName,
663
+ signedAt: input.signedAt,
664
+ signerIp: input.signerIp,
665
+ requestId: input.requestId,
666
+ documentSha256,
667
+ finalHash,
668
+ links: [...input.priorChain, signed, sealed]
669
+ });
670
+ const sealedPdf = await pdf.save({ useObjectStreams: false });
671
+ return {
672
+ sealedPdf,
673
+ sealedSha256: sha256Hex(sealedPdf),
674
+ documentSha256,
675
+ finalHash,
676
+ newLinks: [signed, sealed]
677
+ };
678
+ }
679
+
680
+ // src/server/sign.ts
681
+ var toLink = (e) => ({
682
+ seq: e.seq,
683
+ type: e.type,
684
+ payload: e.payload ?? {},
685
+ prevHash: e.prevHash,
686
+ hash: e.hash,
687
+ occurredAt: e.occurredAt
688
+ });
689
+ async function resolve(persistence, token) {
690
+ return persistence.findRequestByTokenHash(sha256Hex(token));
691
+ }
692
+ async function getSigningView(deps, token) {
693
+ const request = await resolve(deps.persistence, token);
694
+ if (!request) return null;
695
+ if (!isActive(request.status)) return null;
696
+ if (request.revokedAt) return null;
697
+ if (new Date(request.expiresAt) < deps.clock.now()) return null;
698
+ const version = await deps.persistence.getVersion(request.documentVersionId);
699
+ if (!version) return null;
700
+ const document = await deps.persistence.getDocument(request.documentId);
701
+ return {
702
+ request,
703
+ sourceObjectKey: version.sourceObjectKey,
704
+ placement: version.placement,
705
+ documentTitle: document?.title ?? "Document"
706
+ };
707
+ }
708
+ async function recordView(deps, token) {
709
+ const request = await resolve(deps.persistence, token);
710
+ if (!request || request.status !== "PENDING") return;
711
+ const existing = (await deps.persistence.listAuditEvents(request.id)).map(toLink);
712
+ const now = deps.clock.now();
713
+ await recordEvent(deps.persistence, request.id, existing.at(-1) ?? null, {
714
+ type: "VIEWED",
715
+ payload: {},
716
+ occurredAt: now.toISOString()
717
+ });
718
+ const updated = await deps.persistence.updateRequest(request.id, {
719
+ status: "VIEWED",
720
+ viewedAt: now
721
+ });
722
+ if (deps.hooks?.onRequestViewed) await deps.hooks.onRequestViewed(updated);
723
+ }
724
+ async function submitSignature(deps, input) {
725
+ const { persistence, storage, clock, notifier, hooks } = deps;
726
+ const request = await resolve(persistence, input.token);
727
+ if (!request) throw new EsignError("NOT_FOUND", "This signing link is not valid.");
728
+ if (request.status === "SIGNED") {
729
+ return {
730
+ requestId: request.id,
731
+ sealedObjectKey: request.sealedObjectKey ?? "",
732
+ alreadySigned: true
733
+ };
734
+ }
735
+ if (!isActive(request.status) || request.revokedAt) {
736
+ throw new EsignError("GONE", "This signing link is no longer available.");
737
+ }
738
+ const now = clock.now();
739
+ if (new Date(request.expiresAt) < now) {
740
+ await persistence.updateRequest(request.id, { status: "EXPIRED" });
741
+ throw new EsignError("GONE", "This signing link has expired.");
742
+ }
743
+ const version = await persistence.getVersion(request.documentVersionId);
744
+ if (!version) throw new EsignError("INTERNAL", "Document version missing.");
745
+ const src = await storage.get(version.sourceObjectKey);
746
+ if (!src) throw new EsignError("INTERNAL", "Source document missing.");
747
+ const document = await persistence.getDocument(request.documentId);
748
+ const existing = (await persistence.listAuditEvents(request.id)).map(toLink);
749
+ const nowIso = now.toISOString();
750
+ const consented = await recordEvent(persistence, request.id, existing.at(-1) ?? null, {
751
+ type: "CONSENTED",
752
+ payload: {
753
+ signerName: input.signerName,
754
+ ip: input.ip,
755
+ userAgent: input.userAgent
756
+ },
757
+ occurredAt: nowIso
758
+ });
759
+ const seal = await sealPdf({
760
+ sourcePdf: src.bytes,
761
+ signaturePng: input.signaturePng,
762
+ placement: version.placement,
763
+ signerName: input.signerName,
764
+ signedAt: nowIso,
765
+ signerIp: input.ip,
766
+ documentTitle: document?.title ?? "Document",
767
+ requestId: request.id,
768
+ priorChain: [...existing, consented]
769
+ });
770
+ const sealedObjectKey = storage.sealedKey({
771
+ scopeId: request.scopeId,
772
+ campaignId: request.campaignId,
773
+ requestId: request.id
774
+ });
775
+ await storage.put({
776
+ objectKey: sealedObjectKey,
777
+ bytes: seal.sealedPdf,
778
+ contentType: "application/pdf"
779
+ });
780
+ await persistence.updateRequest(request.id, {
781
+ status: "SIGNED",
782
+ signedAt: now,
783
+ signerName: input.signerName,
784
+ signerIp: input.ip ?? void 0,
785
+ signerUserAgent: input.userAgent ?? void 0,
786
+ sealedObjectKey,
787
+ sealedSha256: seal.sealedSha256,
788
+ finalAuditHash: seal.finalHash
789
+ });
790
+ for (const link of seal.newLinks) {
791
+ await persistence.appendAuditEvent({
792
+ requestId: request.id,
793
+ seq: link.seq,
794
+ type: link.type,
795
+ payload: link.payload,
796
+ prevHash: link.prevHash,
797
+ hash: link.hash
798
+ });
799
+ }
800
+ if (hooks?.onRequestSigned) {
801
+ await hooks.onRequestSigned({
802
+ subjectType: request.subjectType,
803
+ subjectId: request.subjectId,
804
+ documentId: request.documentId,
805
+ documentType: document?.documentType ?? null,
806
+ requestId: request.id,
807
+ sealedObjectKey,
808
+ signedAt: nowIso
809
+ });
810
+ }
811
+ const campaign = await persistence.getCampaign(request.campaignId);
812
+ if (campaign?.emailReceipt && request.recipientEmail) {
813
+ const title = document?.title ?? "Document";
814
+ try {
815
+ await notifier.send({
816
+ channel: "EMAIL",
817
+ recipient: request.recipientEmail,
818
+ subject: `Signed copy: ${title}`,
819
+ body: `Hello ${request.recipientName},
820
+
821
+ Attached is your signed copy of "${title}".`,
822
+ idempotencyKey: `${request.id}:receipt`,
823
+ attachments: [
824
+ {
825
+ filename: `${title.replace(/[^a-z0-9-_ ]/gi, "_")}.pdf`,
826
+ content: seal.sealedPdf,
827
+ contentType: "application/pdf"
828
+ }
829
+ ]
830
+ });
831
+ } catch {
832
+ }
833
+ }
834
+ return { requestId: request.id, sealedObjectKey, alreadySigned: false };
835
+ }
836
+ async function declineRequest(deps, input) {
837
+ const request = await resolve(deps.persistence, input.token);
838
+ if (!request || !isActive(request.status) || request.revokedAt) {
839
+ throw new EsignError("GONE", "This signing link is no longer available.");
840
+ }
841
+ const existing = (await deps.persistence.listAuditEvents(request.id)).map(toLink);
842
+ const now = deps.clock.now();
843
+ await recordEvent(deps.persistence, request.id, existing.at(-1) ?? null, {
844
+ type: "DECLINED",
845
+ payload: { reason: input.reason },
846
+ occurredAt: now.toISOString()
847
+ });
848
+ const updated = await deps.persistence.updateRequest(request.id, {
849
+ status: "DECLINED",
850
+ declinedAt: now,
851
+ declineReason: input.reason ?? void 0
852
+ });
853
+ if (deps.hooks?.onRequestDeclined) await deps.hooks.onRequestDeclined(updated);
854
+ }
855
+
856
+ // src/server/routes/sign-actions.ts
857
+ var PNG_MAGIC = [137, 80, 78, 71];
858
+ function pngFromDataUrl(dataUrl) {
859
+ const comma = dataUrl.indexOf(",");
860
+ const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
861
+ const bytes = new Uint8Array(Buffer.from(b64, "base64"));
862
+ const isPng = PNG_MAGIC.every((b, i) => bytes[i] === b);
863
+ if (!isPng) {
864
+ throw new EsignError("INVALID_INPUT", "Signature must be a PNG image.");
865
+ }
866
+ return bytes;
867
+ }
868
+ function createSignActionsRoute(deps) {
869
+ return {
870
+ POST: publicRoute(async (req, ctx) => {
871
+ const { token } = await ctx.params;
872
+ const action = new URL(req.url).searchParams.get("action");
873
+ if (action === "decline") {
874
+ const { reason } = await parseBody(req, declineSchema);
875
+ await declineRequest(deps, { token, reason: reason ?? null });
876
+ return ok({ ok: true, declined: true });
877
+ }
878
+ const body = await parseBody(req, submitSignatureSchema);
879
+ const result = await submitSignature(deps, {
880
+ token,
881
+ signaturePng: pngFromDataUrl(body.signaturePng),
882
+ signerName: body.signerName,
883
+ ip: clientIp(req),
884
+ userAgent: req.headers.get("user-agent")
885
+ });
886
+ return ok({
887
+ ok: true,
888
+ requestId: result.requestId,
889
+ alreadySigned: result.alreadySigned
890
+ });
891
+ })
892
+ };
893
+ }
894
+
895
+ // src/server/routes/sign-serve.ts
896
+ function createSignServeRoute(deps) {
897
+ return {
898
+ GET: publicRoute(async (_req, ctx) => {
899
+ const { token } = await ctx.params;
900
+ const view = await getSigningView(deps, token);
901
+ if (!view) throw new EsignError("NOT_FOUND", "Not available.");
902
+ const obj = await deps.storage.get(view.sourceObjectKey);
903
+ if (!obj) throw new EsignError("NOT_FOUND", "Not available.");
904
+ return new Response(obj.bytes, {
905
+ status: 200,
906
+ headers: {
907
+ "content-type": "application/pdf",
908
+ "content-disposition": "inline",
909
+ "cache-control": "private, no-store",
910
+ "x-robots-tag": "noindex"
911
+ }
912
+ });
913
+ })
914
+ };
915
+ }
916
+
917
+ // src/server/configure.ts
918
+ var DEFAULT_BASE = "/api/esign";
919
+ var DEFAULT_PRESIGN_TTL_S = 900;
920
+ var DEFAULT_EXPIRY_DAYS = 30;
921
+ var systemClock = { now: () => /* @__PURE__ */ new Date() };
922
+ function configureEsign(opts) {
923
+ const config = {
924
+ ...opts,
925
+ routeBasePath: opts.routeBasePath ?? DEFAULT_BASE,
926
+ presignTtlS: opts.presignTtlS ?? DEFAULT_PRESIGN_TTL_S,
927
+ defaultExpiryDays: opts.defaultExpiryDays ?? DEFAULT_EXPIRY_DAYS,
928
+ clock: opts.clock ?? systemClock
929
+ };
930
+ const { persistence, storage, notifier, subjects, hooks, clock, baseUrl } = config;
931
+ const signDeps = { persistence, storage, clock, notifier, hooks };
932
+ return {
933
+ config,
934
+ newSigningToken,
935
+ createDocument: (input) => createDocument(persistence, input),
936
+ addVersion: (input) => addVersion({ persistence, storage }, input),
937
+ createCampaign: (args) => createCampaign(
938
+ {
939
+ persistence,
940
+ subjects,
941
+ notifier,
942
+ clock,
943
+ baseUrl,
944
+ defaultExpiryDays: config.defaultExpiryDays
945
+ },
946
+ args
947
+ ),
948
+ getSigningView: (token) => getSigningView({ persistence, clock }, token),
949
+ recordView: (token) => recordView({ persistence, clock, hooks }, token),
950
+ submitSignature: (input) => submitSignature(signDeps, input),
951
+ declineRequest: (input) => declineRequest({ persistence, clock, hooks }, input),
952
+ subjectSigningStatus: (args) => persistence.subjectSigningStatus(args),
953
+ documentCoverage: (documentId) => documentCoverage({ persistence, subjects }, documentId),
954
+ campaignStats: (campaignId) => persistence.campaignStats(campaignId),
955
+ documentStats: (documentId) => persistence.documentStats(documentId),
956
+ scopeStats: (scopeId, range) => persistence.scopeStats(scopeId, range),
957
+ outstandingRequests: (filter) => persistence.outstandingRequests(filter),
958
+ routes: {
959
+ signServe: createSignServeRoute({ persistence, storage, clock }),
960
+ signActions: createSignActionsRoute(signDeps)
961
+ }
962
+ };
963
+ }
964
+
965
+ // src/server/subjects.ts
966
+ function registerSubjectTypes(types) {
967
+ const map = new Map(types.map((t) => [t.type, t]));
968
+ return {
969
+ types,
970
+ get: (type) => map.get(type)
971
+ };
972
+ }
973
+
974
+ exports.ACTIVE_STATUSES = ACTIVE_STATUSES;
975
+ exports.ESIGN_CHANNELS = ESIGN_CHANNELS;
976
+ exports.ESIGN_STATUSES = ESIGN_STATUSES;
977
+ exports.EsignError = EsignError;
978
+ exports.TERMINAL_STATUSES = TERMINAL_STATUSES;
979
+ exports.addVersion = addVersion;
980
+ exports.appendEvent = appendEvent;
981
+ exports.assertTransition = assertTransition;
982
+ exports.buildChain = buildChain;
983
+ exports.canTransition = canTransition;
984
+ exports.canonicalize = canonicalize;
985
+ exports.clientIp = clientIp;
986
+ exports.configureEsign = configureEsign;
987
+ exports.createCampaign = createCampaign;
988
+ exports.createDocument = createDocument;
989
+ exports.createSignActionsRoute = createSignActionsRoute;
990
+ exports.createSignServeRoute = createSignServeRoute;
991
+ exports.declineRequest = declineRequest;
992
+ exports.declineSchema = declineSchema;
993
+ exports.documentCoverage = documentCoverage;
994
+ exports.esignChannelSchema = esignChannelSchema;
995
+ exports.getSigningView = getSigningView;
996
+ exports.isActive = isActive;
997
+ exports.isTerminal = isTerminal;
998
+ exports.linkHash = linkHash;
999
+ exports.newSigningToken = newSigningToken;
1000
+ exports.ok = ok;
1001
+ exports.parseBody = parseBody;
1002
+ exports.placementSchema = placementSchema;
1003
+ exports.recordEvent = recordEvent;
1004
+ exports.recordView = recordView;
1005
+ exports.registerSubjectTypes = registerSubjectTypes;
1006
+ exports.renderAuditCertificatePage = renderAuditCertificatePage;
1007
+ exports.sealPdf = sealPdf;
1008
+ exports.sha256Hex = sha256Hex;
1009
+ exports.subjectSelectionSchema = subjectSelectionSchema;
1010
+ exports.submitSignature = submitSignature;
1011
+ exports.submitSignatureSchema = submitSignatureSchema;
1012
+ exports.toErrorResponse = toErrorResponse;
1013
+ exports.uid = uid;
1014
+ exports.verifyChain = verifyChain;
1015
+ //# sourceMappingURL=index.cjs.map
1016
+ //# sourceMappingURL=index.cjs.map