@teamscala/service-boot 0.23.415 → 0.23.417

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.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.415",
9
+ "version": "0.23.417",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -1181,6 +1181,46 @@
1181
1181
  "./agreement-gate/authorized-emails.ts": {
1182
1182
  "types": "./src/agreement-gate/authorized-emails.d.ts",
1183
1183
  "default": "./src/agreement-gate/authorized-emails.ts"
1184
+ },
1185
+ "./fn-routers/agreement-signing-evidence": {
1186
+ "types": "./src/fn-routers/agreement-signing-evidence.d.ts",
1187
+ "default": "./src/fn-routers/agreement-signing-evidence.ts"
1188
+ },
1189
+ "./fn-routers/agreement-signing-evidence.ts": {
1190
+ "types": "./src/fn-routers/agreement-signing-evidence.d.ts",
1191
+ "default": "./src/fn-routers/agreement-signing-evidence.ts"
1192
+ },
1193
+ "./fn-routers/agreement-certificate": {
1194
+ "types": "./src/fn-routers/agreement-certificate.d.ts",
1195
+ "default": "./src/fn-routers/agreement-certificate.ts"
1196
+ },
1197
+ "./fn-routers/agreement-certificate.ts": {
1198
+ "types": "./src/fn-routers/agreement-certificate.d.ts",
1199
+ "default": "./src/fn-routers/agreement-certificate.ts"
1200
+ },
1201
+ "./fn-routers/agreement-sign-side-effects": {
1202
+ "types": "./src/fn-routers/agreement-sign-side-effects.d.ts",
1203
+ "default": "./src/fn-routers/agreement-sign-side-effects.ts"
1204
+ },
1205
+ "./fn-routers/agreement-sign-side-effects.ts": {
1206
+ "types": "./src/fn-routers/agreement-sign-side-effects.d.ts",
1207
+ "default": "./src/fn-routers/agreement-sign-side-effects.ts"
1208
+ },
1209
+ "./fn-routers/assessment-report-email": {
1210
+ "types": "./src/fn-routers/assessment-report-email.d.ts",
1211
+ "default": "./src/fn-routers/assessment-report-email.ts"
1212
+ },
1213
+ "./fn-routers/assessment-report-email.ts": {
1214
+ "types": "./src/fn-routers/assessment-report-email.d.ts",
1215
+ "default": "./src/fn-routers/assessment-report-email.ts"
1216
+ },
1217
+ "./fn-routers/assessment-scoring": {
1218
+ "types": "./src/fn-routers/assessment-scoring.d.ts",
1219
+ "default": "./src/fn-routers/assessment-scoring.ts"
1220
+ },
1221
+ "./fn-routers/assessment-scoring.ts": {
1222
+ "types": "./src/fn-routers/assessment-scoring.d.ts",
1223
+ "default": "./src/fn-routers/assessment-scoring.ts"
1184
1224
  }
1185
1225
  },
1186
1226
  "repository": "https://github.com/teamgbg/service-boot",
@@ -20,7 +20,7 @@ import {
20
20
  import {
21
21
  buildCertificateData,
22
22
  type CertificateData,
23
- } from "../fn-routers/public-agreement-router.ts";
23
+ } from "../fn-routers/agreement-certificate.ts";
24
24
  import { findFirstAgreement, resolveAgreementViewerDirectory } from "./agreement-lookup.ts";
25
25
 
26
26
  export interface ContentBlock {
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The Certificate of Completion for an executed agreement: DB-driven display
7
+ * copy (config/certificate-of-completion), the CertificateData shape, and
8
+ * the party-gated builder.
9
+ *
10
+ * Split out of fn-routers/public-agreement-router.ts (853 lines) —
11
+ * byte-for-byte move; import paths adjusted only.
12
+ */
13
+
14
+ import * as v from "valibot";
15
+ import { queryRows } from "@teamscala/db/query-rows";
16
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
17
+ import {
18
+ parseAgreementParties as parseParties,
19
+ } from "@teamscala/document-signing/agreement-parties";
20
+ import { parseEvidence } from "./agreement-signing-evidence.ts";
21
+
22
+ /** The Certificate of Completion display copy. DB-driven via the
23
+ * `config/certificate-of-completion` registry row — mirrors the agreement gate's
24
+ * `config/content-gate-agreement` pattern (same signing surface), so an operator
25
+ * can rebrand/reword the certificate via `registry_edit` with no deploy; the
26
+ * hardcoded defaults are the fallback. Generic structural data-column labels
27
+ * (Signer/Role/Envelope ID/…) stay in the component, exactly as the gate keeps
28
+ * its structural markup in code. */
29
+ export interface CertificateCopy {
30
+ title: string;
31
+ signersHeading: string;
32
+ sealHeading: string;
33
+ sealNote: string;
34
+ footer: string;
35
+ badgeCompleted: string;
36
+ badgeInProgress: string;
37
+ /** Audit-table column labels — DB-driven so rewording is a registry_edit.
38
+ * Each labels BOTH the <th> header AND the stacked-row data-label on mobile
39
+ * (one source, kept in sync), never a second hardcoded copy. */
40
+ colSigner: string;
41
+ colRole: string;
42
+ colSigned: string;
43
+ colIp: string;
44
+ colAuth: string;
45
+ }
46
+
47
+ const CERTIFICATE_COPY_DEFAULTS: CertificateCopy = {
48
+ title: "CERTIFICATE OF COMPLETION",
49
+ signersHeading: "Signers",
50
+ sealHeading: "Tamper-evident seal",
51
+ sealNote:
52
+ "{{hashAlgo}} hash of the executed document + signed roster, sealed {{hashedAt}}. Re-hashing the stored document and comparing to this value proves it has not been altered since signing.",
53
+ footer:
54
+ "This certificate is a record of the electronic signing of the above agreement, produced from the platform's permanent audit trail. Electronic signatures are legally recognised under the ESIGN Act and UETA (US) and eIDAS (EU).",
55
+ badgeCompleted: "Completed",
56
+ badgeInProgress: "In progress",
57
+ colSigner: "Signer",
58
+ colRole: "Role",
59
+ colSigned: "Signed (UTC)",
60
+ colIp: "IP address",
61
+ colAuth: "Auth",
62
+ };
63
+
64
+ const CertificateCopySchema = v.object({
65
+ title: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.title),
66
+ signersHeading: v.optional(
67
+ v.string(),
68
+ CERTIFICATE_COPY_DEFAULTS.signersHeading,
69
+ ),
70
+ sealHeading: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealHeading),
71
+ sealNote: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealNote),
72
+ footer: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.footer),
73
+ badgeCompleted: v.optional(
74
+ v.string(),
75
+ CERTIFICATE_COPY_DEFAULTS.badgeCompleted,
76
+ ),
77
+ badgeInProgress: v.optional(
78
+ v.string(),
79
+ CERTIFICATE_COPY_DEFAULTS.badgeInProgress,
80
+ ),
81
+ colSigner: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigner),
82
+ colRole: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colRole),
83
+ colSigned: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigned),
84
+ colIp: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colIp),
85
+ colAuth: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colAuth),
86
+ });
87
+
88
+ async function loadCertificateCopy(): Promise<CertificateCopy | null> {
89
+ try {
90
+ return await loadRegistryConfig(
91
+ "config",
92
+ "certificate-of-completion",
93
+ CertificateCopySchema,
94
+ );
95
+ } catch {
96
+ // Absent signal, not a masking fallback (protective-layers-fail-explicitly):
97
+ // null propagates to CertificateData.copy, and the component renders its
98
+ // FALLBACK_COPY — same default values, but the absence is honest rather
99
+ // than disguised as a successful config load.
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /** The Certificate of Completion data shape — the court-style audit certificate.
105
+ * One home: {@link buildCertificateData} is consumed by the SSR agreement
106
+ * gate, so the signing block receives the server-seeded prop. */
107
+ export interface CertificateData {
108
+ ok: boolean;
109
+ envelopeId?: string;
110
+ documentName?: string | null;
111
+ issuedAt?: string;
112
+ complete?: boolean;
113
+ createdAt?: string | null;
114
+ sentAt?: string | null;
115
+ viewedAt?: string | null;
116
+ signedAt?: string | null;
117
+ documentHash?: string | null;
118
+ hashAlgo?: string | null;
119
+ hashedAt?: string | null;
120
+ parties?: {
121
+ role: string;
122
+ name: string | null;
123
+ email: string | null;
124
+ title: string | null;
125
+ company: string | null;
126
+ signedAt: string | null;
127
+ ip: string | null;
128
+ userAgent: string | null;
129
+ authMethod: string | null;
130
+ }[];
131
+ /** DB-driven display copy (config/certificate-of-completion); null when the
132
+ * config row is missing/broken — the component falls back to FALLBACK_COPY. */
133
+ copy?: CertificateCopy | null;
134
+ }
135
+
136
+ /**
137
+ * Build the Certificate of Completion data for an agreement, gated to a party
138
+ * email — signer evidence (IP/device) is never exposed to a non-party. One data
139
+ * home: the SSR agreement gate (`buildSigningSession` in agreement-gate.ts)
140
+ * calls this. The read is
141
+ * intentionally RLS-unscoped via the raw registry prisma — the certificate is
142
+ * reached without a session (AG cookie only), which context.prisma's RLS would
143
+ * otherwise treat as no-org and hide. Accepts either the agreement record id or
144
+ * its page id (the UUID the signer sees in the URL is the page id).
145
+ */
146
+ export async function buildCertificateData(
147
+ agreementId: string,
148
+ email: string,
149
+ ): Promise<CertificateData> {
150
+ const id = agreementId.trim();
151
+ const mail = email.trim().toLowerCase();
152
+ if (!id || !mail) return { ok: false };
153
+ const [agreement] = await queryRows<
154
+ {
155
+ id: string;
156
+ name: string;
157
+ created_at: Date | null;
158
+ sent_at: Date | null;
159
+ viewed_at: Date | null;
160
+ signed_at: Date | null;
161
+ signature_data: unknown;
162
+ signer_email: string | null;
163
+ signer_name: string | null;
164
+ }[]
165
+ >`
166
+ SELECT id, name, created_at, sent_at, viewed_at, signed_at,
167
+ signature_data, signer_email, signer_name
168
+ FROM agreements
169
+ WHERE (id = ${id} OR page_id = ${id})
170
+ LIMIT 1`;
171
+ if (!agreement) return { ok: false };
172
+ const parties = parseParties(agreement.signature_data);
173
+ const evidence = parseEvidence(agreement.signature_data);
174
+ const isParty = parties
175
+ ? parties.some((p) => p.email.trim().toLowerCase() === mail)
176
+ : String(agreement.signer_email ?? "")
177
+ .trim()
178
+ .toLowerCase() === mail;
179
+ if (!isParty) return { ok: false };
180
+ const complete =
181
+ !!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
182
+ const iso = (d: Date | null | undefined) => d?.toISOString() ?? null;
183
+ const copy = await loadCertificateCopy();
184
+ return {
185
+ ok: true,
186
+ copy,
187
+ envelopeId: agreement.id,
188
+ documentName: agreement.name,
189
+ issuedAt: new Date().toISOString(),
190
+ complete,
191
+ createdAt: iso(agreement.created_at),
192
+ sentAt: iso(agreement.sent_at),
193
+ viewedAt: iso(agreement.viewed_at),
194
+ signedAt: iso(agreement.signed_at),
195
+ documentHash: evidence?.documentHash ?? null,
196
+ hashAlgo: evidence?.hashAlgo ?? null,
197
+ hashedAt: evidence?.hashedAt ?? null,
198
+ parties: parties
199
+ ? parties.map((p) => ({
200
+ role: p.key,
201
+ name: p.name,
202
+ email: p.email,
203
+ title: p.title,
204
+ company: p.company,
205
+ signedAt: p.signedAt,
206
+ ip: p.signedIp ?? null,
207
+ userAgent: p.signedUserAgent ?? null,
208
+ authMethod: p.authMethod ?? null,
209
+ }))
210
+ : [
211
+ {
212
+ role: "signer",
213
+ name: agreement.signer_name,
214
+ email: agreement.signer_email,
215
+ title: null,
216
+ company: null,
217
+ signedAt: iso(agreement.signed_at),
218
+ ip: null,
219
+ userAgent: null,
220
+ authMethod: "email-match",
221
+ },
222
+ ],
223
+ };
224
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Post-signature side effects of public agreement signing: Stripe invoice
7
+ * creation via the payments service, the self-resetting demo harness, and
8
+ * CRM connection of the captured signer.
9
+ *
10
+ * Split out of fn-routers/public-agreement-router.ts (853 lines) —
11
+ * byte-for-byte move; import paths adjusted only.
12
+ */
13
+
14
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
15
+ import { queryRows } from "@teamscala/db/query-rows";
16
+ import type { AgreementParty } from "@teamscala/document-signing/agreement-parties";
17
+
18
+ /** On full agreement completion, delegate to the payment service over HTTP to
19
+ * create the Stripe invoice (per payment-is-one-service — all Stripe logic
20
+ * lives in @teamscala/payment, reached via POST /api/payments/checkout). Never
21
+ * throws — a payment-setup failure must not undo a completed signature; the
22
+ * signer is simply not auto-redirected to pay. But every failure mode is
23
+ * logged at WARN with the actual cause (URL missing, non-2xx, network, parse)
24
+ * so the silent-fail class the user would otherwise diagnose by hand is
25
+ * observable in GlitchTip + pm2 logs. */
26
+ export async function invoicePayUrl(
27
+ agreementId: string,
28
+ baseUrl: string,
29
+ ): Promise<string | null> {
30
+ const log = getAppLogger();
31
+ const paymentsUrl = (process.env.SCALA_PAYMENTS_URL ?? "").replace(/\/+$/, "");
32
+ if (!paymentsUrl) {
33
+ log.warn(
34
+ `[publicAgreement] agreement ${agreementId} signed but SCALA_PAYMENTS_URL is unset — no invoice created. Set the env var on scala-sign's service_env row to a pay-internal tunnel URL.`,
35
+ );
36
+ return null;
37
+ }
38
+ try {
39
+ const res = await fetch(`${paymentsUrl}/api/payments/checkout`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ agreementId, baseUrl }),
43
+ });
44
+ if (!res.ok) {
45
+ log.warn(
46
+ `[publicAgreement] payments checkout returned ${res.status} for agreement ${agreementId} (${paymentsUrl}/api/payments/checkout)`,
47
+ );
48
+ return null;
49
+ }
50
+ const json = (await res.json()) as { ok?: boolean; hostedInvoiceUrl?: string };
51
+ if (!json.ok) {
52
+ log.warn(
53
+ `[publicAgreement] payments checkout ok=false for agreement ${agreementId}: ${JSON.stringify(json).slice(0, 200)}`,
54
+ );
55
+ return null;
56
+ }
57
+ return json.hostedInvoiceUrl ?? null;
58
+ } catch (err) {
59
+ log.warn(
60
+ `[publicAgreement] payments checkout threw for agreement ${agreementId} (${paymentsUrl}): ${err instanceof Error ? err.message : String(err)}`,
61
+ );
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Self-resetting demo harness. If the signed agreement's `proposal_id` is in
68
+ * the `demo-reset-agreements.proposalIds` allow-list, reset its parties to
69
+ * UNSIGNED (signedAt/signature cleared) + clear signed_at/signed_by + status
70
+ * 'sent', so the next person gets a fresh form to test the UI.
71
+ *
72
+ * Keyed on the stable PROPOSAL id, NOT the agreement id: the demo agreement is
73
+ * regenerated under a fresh id whenever the demo proposal is re-accepted, but
74
+ * its `proposal_id` FK is constant — so the allow-list cannot dangle. This
75
+ * deletes the drift class (prevention, not a patrol): the prior design keyed on
76
+ * `agreementIds`, and when the demo was regenerated the list silently pointed
77
+ * at a dead id and the reset became a no-op for weeks. A `proposal_id`-
78
+ * referenced row that doesn't exist is a real proposal being deleted, not a
79
+ * regeneration — a negligible residual the operator would notice directly.
80
+ *
81
+ * Gated strictly by the allow-list — a real agreement is never reset.
82
+ * Best-effort: any failure is swallowed (a demo-reset must never fail the
83
+ * signer's own response). The current signer already has their response (incl.
84
+ * payUrl) before this runs; only the stored record is reset.
85
+ */
86
+ export async function maybeResetDemoAgreement(
87
+ prisma: any,
88
+ agreementId: string,
89
+ proposalId: string | null,
90
+ parties: AgreementParty[],
91
+ ): Promise<void> {
92
+ if (!proposalId) return;
93
+ try {
94
+ const rows = await queryRows`
95
+ SELECT config FROM registry_entries
96
+ WHERE type = 'config' AND slug = 'demo-reset-agreements' AND is_active = true
97
+ LIMIT 1` as { config: { proposalIds?: string[] } }[];
98
+ const ids = rows?.[0]?.config?.proposalIds ?? [];
99
+ if (!Array.isArray(ids) || !ids.includes(proposalId)) return;
100
+ const reset = parties.map((p) => ({
101
+ ...p,
102
+ signedAt: null,
103
+ signature: null,
104
+ title: null,
105
+ company: null,
106
+ }));
107
+ await prisma.agreements.update({
108
+ where: { id: agreementId },
109
+ data: {
110
+ signature_data: JSON.stringify({ parties: reset }),
111
+ signed_at: null,
112
+ signed_by: null,
113
+ status: "sent",
114
+ },
115
+ });
116
+ getAppLogger().info(`[publicAgreement.sign] demo agreement ${agreementId} auto-reset for re-testing`);
117
+ } catch {
118
+ // never fail the signer's response over a demo-reset
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Connect a captured signer to the CRM — every signable document lands in
124
+ * client_database, the same find-or-create capture path proposals / public-GPT /
125
+ * assessments already use (scoped by organisation_id + primary_email). Also
126
+ * links agreement.contact_id when the agreement wasn't generated from a proposal
127
+ * (contact_id NULL), so a standalone signed doc still connects to the CRM.
128
+ * Best-effort: a CRM write must never fail the signature (the signature is the
129
+ * legal act; CRM connection is bookkeeping).
130
+ */
131
+ export async function connectSignerToCrm(
132
+ prisma: any,
133
+ agreement: { id: string; organisation_id?: string | null; contact_id?: string | null },
134
+ email: string,
135
+ signerName?: string | null,
136
+ company?: string | null,
137
+ ): Promise<void> {
138
+ const orgId = agreement.organisation_id;
139
+ if (!orgId || !email || !prisma?.client_database) return;
140
+ try {
141
+ const parts = (signerName ?? "").trim().split(/\s+/).filter(Boolean);
142
+ const firstName = parts[0] ?? "";
143
+ const lastName = parts.slice(1).join(" ");
144
+ const existing = await prisma.client_database.findFirst({
145
+ where: { organisation_id: orgId, primary_email: email },
146
+ select: { id: true },
147
+ });
148
+ let contactId: string | null = null;
149
+ if (existing) {
150
+ contactId = existing.id as string;
151
+ await prisma.client_database.update({
152
+ where: { id: existing.id },
153
+ data: {
154
+ ...(firstName ? { first_name: firstName } : {}),
155
+ ...(lastName ? { last_name: lastName } : {}),
156
+ ...(company ? { company_name: company } : {}),
157
+ last_updated_by: "agreement-sign",
158
+ },
159
+ });
160
+ } else {
161
+ const created = await prisma.client_database.create({
162
+ data: {
163
+ organisation_id: orgId,
164
+ first_name: firstName,
165
+ last_name: lastName,
166
+ primary_email: email,
167
+ ...(company ? { company_name: company } : {}),
168
+ lead_source: "agreement-sign",
169
+ data_source: "public-form",
170
+ referral_source: agreement.id,
171
+ lifecycle_stage: "lead",
172
+ created_by: "agreement-sign",
173
+ last_updated_by: "agreement-sign",
174
+ },
175
+ select: { id: true },
176
+ });
177
+ contactId = (created?.id as string) ?? null;
178
+ }
179
+ if (contactId && !agreement.contact_id) {
180
+ await prisma.agreements.update({
181
+ where: { id: agreement.id },
182
+ data: { contact_id: contactId },
183
+ });
184
+ }
185
+ } catch (e) {
186
+ getAppLogger().warn(
187
+ `[publicAgreement.sign] CRM connect failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
188
+ );
189
+ }
190
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Signing-evidence utilities for public agreement signing: the signer's
7
+ * origin/IP extraction from request headers, the canonical JSON form, the
8
+ * tamper-evident document hash, and the evidence-seal parser.
9
+ *
10
+ * Split out of fn-routers/public-agreement-router.ts (853 lines) —
11
+ * byte-for-byte move except createHash → Bun.CryptoHasher
12
+ * (bun-native-crypto guard; identical digest output).
13
+ */
14
+
15
+ import type { AgreementParty } from "@teamscala/document-signing/agreement-parties";
16
+
17
+ /** The signer's own origin, from the request headers (sign[-dev].scala.business),
18
+ * so Checkout returns them to the same site they signed on. */
19
+ export function originFrom(headers: Headers): string {
20
+ const host = headers.get("x-forwarded-host") ?? headers.get("host");
21
+ const proto = headers.get("x-forwarded-proto") ?? "https";
22
+ return host ? `${proto}://${host}` : "https://sign.scala.business";
23
+ }
24
+
25
+ /** The signer's real client IP at the moment of signing, for the audit trail.
26
+ * Behind Cloudflare/Railway the original IP is the FIRST hop of
27
+ * x-forwarded-for (the chain is client, proxy1, proxy2…); cf-connecting-ip and
28
+ * x-real-ip are the per-proxy fallbacks. Returns null if none are present. */
29
+ export function clientIp(headers: Headers): string | null {
30
+ const fwd = headers.get("x-forwarded-for");
31
+ const first = fwd?.split(",")[0]?.trim();
32
+ return (
33
+ first ||
34
+ headers.get("cf-connecting-ip") ||
35
+ headers.get("x-real-ip") ||
36
+ null
37
+ );
38
+ }
39
+
40
+ /** Deterministic JSON (recursively sorted object keys) so a hash over the same
41
+ * logical content is reproducible regardless of property insertion order —
42
+ * required for tamper-verification (re-hash later, compare). */
43
+ function canonicalJson(value: unknown): string {
44
+ return JSON.stringify(value, (_key, val) =>
45
+ val && typeof val === "object" && !Array.isArray(val)
46
+ ? Object.fromEntries(
47
+ Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
48
+ a.localeCompare(b),
49
+ ),
50
+ )
51
+ : val,
52
+ );
53
+ }
54
+
55
+ /** Tamper-evident seal: SHA-256 over the executed document's content + the
56
+ * full signed roster. Storing this at completion lets the verification surface
57
+ * re-hash the stored document later and prove nothing changed after signing
58
+ * (the marketplace "tamper-evident" bar, short of a qualified-CA PAdES seal). */
59
+ export function computeDocumentHash(
60
+ documentContent: unknown,
61
+ parties: AgreementParty[],
62
+ ): string {
63
+ return new Bun.CryptoHasher("sha256")
64
+ .update(
65
+ canonicalJson({
66
+ document: documentContent ?? null,
67
+ signatures: parties.map((p) => ({
68
+ key: p.key,
69
+ email: p.email,
70
+ name: p.name,
71
+ title: p.title,
72
+ company: p.company,
73
+ signature: p.signature,
74
+ signedAt: p.signedAt,
75
+ })),
76
+ }),
77
+ "utf8",
78
+ )
79
+ .digest("hex");
80
+ }
81
+
82
+ /** Parse the optional evidence seal (document hash) from signature_data, which
83
+ * has the shape { parties, evidence? }. Returns null when no seal is present. */
84
+ export function parseEvidence(sigData: unknown): {
85
+ documentHash?: string;
86
+ hashAlgo?: string;
87
+ hashedAt?: string;
88
+ } | null {
89
+ if (typeof sigData !== "string") return null;
90
+ const t = sigData.trim();
91
+ if (!t.startsWith("{")) return null;
92
+ try {
93
+ const o = JSON.parse(t) as { evidence?: Record<string, unknown> };
94
+ const e = o.evidence;
95
+ if (!e || typeof e !== "object") return null;
96
+ return {
97
+ documentHash:
98
+ typeof e.documentHash === "string" ? e.documentHash : undefined,
99
+ hashAlgo: typeof e.hashAlgo === "string" ? e.hashAlgo : undefined,
100
+ hashedAt: typeof e.hashedAt === "string" ? e.hashedAt : undefined,
101
+ };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }