@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Public ORPC procedures for DocuSign-style agreement signing, reached by the
|
|
7
|
+
* sign-in block's in-document signing blocks over the public HTTP transport
|
|
8
|
+
* (getOrpcClient().fn.publicAgreement.*). The unguessable agreement id plus an
|
|
9
|
+
* email that matches one of the agreement's parties IS the gate (no password).
|
|
10
|
+
*
|
|
11
|
+
* This is the off-the-shelf path per building-applications: server logic beyond
|
|
12
|
+
* CRUD is a typed ORPC procedure (the publicAssessment/publicGptSession shape),
|
|
13
|
+
* NOT a hand-written /api/data/* Hono handler and NOT a loopback fetch to the
|
|
14
|
+
* service's own ORPC. Writes go through context.prisma inside the handler — the
|
|
15
|
+
* sanctioned in-handler DB access (orpc-is-the-only-api) — so the audit trigger
|
|
16
|
+
* sees the ORPC caller context, the same proven path publicGptSession uses.
|
|
17
|
+
*
|
|
18
|
+
* Multi-party model: signature_data holds a parties roster (JSON) so one
|
|
19
|
+
* agreement can require signatures from several parties (e.g. Provider + Client)
|
|
20
|
+
* with no schema change. Each party is gated by its own email; the agreement
|
|
21
|
+
* completes (signed_at set) only when every party has signed. A single-signer
|
|
22
|
+
* agreement falls back to the original signer_email/signer_name columns.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { os } from "@orpc/server";
|
|
27
|
+
import * as v from "valibot";
|
|
28
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
29
|
+
import { queryRows } from "@teamscala/db/query-rows";
|
|
30
|
+
import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
|
|
31
|
+
import {
|
|
32
|
+
type AgreementParty,
|
|
33
|
+
parseAgreementParties as parseParties,
|
|
34
|
+
toPublicParties as partySummary,
|
|
35
|
+
} from "@teamscala/document-signing/agreement-parties";
|
|
36
|
+
import { sendAgreementCompletionEmails } from "./agreement-completion-email.ts";
|
|
37
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
38
|
+
/** On full agreement completion, delegate to the payment service over HTTP to
|
|
39
|
+
* create the Stripe invoice (per payment-is-one-service — all Stripe logic
|
|
40
|
+
* lives in @teamscala/payment, reached via POST /api/payments/checkout). Never
|
|
41
|
+
* throws — a payment-setup failure must not undo a completed signature; the
|
|
42
|
+
* signer is simply not auto-redirected to pay. But every failure mode is
|
|
43
|
+
* logged at WARN with the actual cause (URL missing, non-2xx, network, parse)
|
|
44
|
+
* so the silent-fail class the user would otherwise diagnose by hand is
|
|
45
|
+
* observable in GlitchTip + pm2 logs. */
|
|
46
|
+
async function invoicePayUrl(
|
|
47
|
+
agreementId: string,
|
|
48
|
+
baseUrl: string,
|
|
49
|
+
): Promise<string | null> {
|
|
50
|
+
const log = getAppLogger();
|
|
51
|
+
const paymentsUrl = (process.env.SCALA_PAYMENTS_URL ?? "").replace(/\/+$/, "");
|
|
52
|
+
if (!paymentsUrl) {
|
|
53
|
+
log.warn(
|
|
54
|
+
`[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.`,
|
|
55
|
+
);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch(`${paymentsUrl}/api/payments/checkout`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ agreementId, baseUrl }),
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
log.warn(
|
|
66
|
+
`[publicAgreement] payments checkout returned ${res.status} for agreement ${agreementId} (${paymentsUrl}/api/payments/checkout)`,
|
|
67
|
+
);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const json = (await res.json()) as { ok?: boolean; hostedInvoiceUrl?: string };
|
|
71
|
+
if (!json.ok) {
|
|
72
|
+
log.warn(
|
|
73
|
+
`[publicAgreement] payments checkout ok=false for agreement ${agreementId}: ${JSON.stringify(json).slice(0, 200)}`,
|
|
74
|
+
);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return json.hostedInvoiceUrl ?? null;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log.warn(
|
|
80
|
+
`[publicAgreement] payments checkout threw for agreement ${agreementId} (${paymentsUrl}): ${err instanceof Error ? err.message : String(err)}`,
|
|
81
|
+
);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The signer's own origin, from the request headers (sign[-dev].scala.business),
|
|
87
|
+
* so Checkout returns them to the same site they signed on. */
|
|
88
|
+
function originFrom(headers: Headers): string {
|
|
89
|
+
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
|
90
|
+
const proto = headers.get("x-forwarded-proto") ?? "https";
|
|
91
|
+
return host ? `${proto}://${host}` : "https://sign.scala.business";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The signer's real client IP at the moment of signing, for the audit trail.
|
|
95
|
+
* Behind Cloudflare/Railway the original IP is the FIRST hop of
|
|
96
|
+
* x-forwarded-for (the chain is client, proxy1, proxy2…); cf-connecting-ip and
|
|
97
|
+
* x-real-ip are the per-proxy fallbacks. Returns null if none are present. */
|
|
98
|
+
function clientIp(headers: Headers): string | null {
|
|
99
|
+
const fwd = headers.get("x-forwarded-for");
|
|
100
|
+
const first = fwd?.split(",")[0]?.trim();
|
|
101
|
+
return (
|
|
102
|
+
first ||
|
|
103
|
+
headers.get("cf-connecting-ip") ||
|
|
104
|
+
headers.get("x-real-ip") ||
|
|
105
|
+
null
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Deterministic JSON (recursively sorted object keys) so a hash over the same
|
|
110
|
+
* logical content is reproducible regardless of property insertion order —
|
|
111
|
+
* required for tamper-verification (re-hash later, compare). */
|
|
112
|
+
function canonicalJson(value: unknown): string {
|
|
113
|
+
return JSON.stringify(value, (_key, val) =>
|
|
114
|
+
val && typeof val === "object" && !Array.isArray(val)
|
|
115
|
+
? Object.fromEntries(
|
|
116
|
+
Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
|
|
117
|
+
a.localeCompare(b),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
: val,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Tamper-evident seal: SHA-256 over the executed document's content + the
|
|
125
|
+
* full signed roster. Storing this at completion lets the verification surface
|
|
126
|
+
* re-hash the stored document later and prove nothing changed after signing
|
|
127
|
+
* (the marketplace "tamper-evident" bar, short of a qualified-CA PAdES seal). */
|
|
128
|
+
function computeDocumentHash(
|
|
129
|
+
documentContent: unknown,
|
|
130
|
+
parties: AgreementParty[],
|
|
131
|
+
): string {
|
|
132
|
+
return createHash("sha256")
|
|
133
|
+
.update(
|
|
134
|
+
canonicalJson({
|
|
135
|
+
document: documentContent ?? null,
|
|
136
|
+
signatures: parties.map((p) => ({
|
|
137
|
+
key: p.key,
|
|
138
|
+
email: p.email,
|
|
139
|
+
name: p.name,
|
|
140
|
+
title: p.title,
|
|
141
|
+
company: p.company,
|
|
142
|
+
signature: p.signature,
|
|
143
|
+
signedAt: p.signedAt,
|
|
144
|
+
})),
|
|
145
|
+
}),
|
|
146
|
+
"utf8",
|
|
147
|
+
)
|
|
148
|
+
.digest("hex");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Parse the optional evidence seal (document hash) from signature_data, which
|
|
152
|
+
* has the shape { parties, evidence? }. Returns null when no seal is present. */
|
|
153
|
+
function parseEvidence(sigData: unknown): {
|
|
154
|
+
documentHash?: string;
|
|
155
|
+
hashAlgo?: string;
|
|
156
|
+
hashedAt?: string;
|
|
157
|
+
} | null {
|
|
158
|
+
if (typeof sigData !== "string") return null;
|
|
159
|
+
const t = sigData.trim();
|
|
160
|
+
if (!t.startsWith("{")) return null;
|
|
161
|
+
try {
|
|
162
|
+
const o = JSON.parse(t) as { evidence?: Record<string, unknown> };
|
|
163
|
+
const e = o.evidence;
|
|
164
|
+
if (!e || typeof e !== "object") return null;
|
|
165
|
+
return {
|
|
166
|
+
documentHash:
|
|
167
|
+
typeof e.documentHash === "string" ? e.documentHash : undefined,
|
|
168
|
+
hashAlgo: typeof e.hashAlgo === "string" ? e.hashAlgo : undefined,
|
|
169
|
+
hashedAt: typeof e.hashedAt === "string" ? e.hashedAt : undefined,
|
|
170
|
+
};
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The Certificate of Completion display copy. DB-driven via the
|
|
177
|
+
* `config/certificate-of-completion` registry row — mirrors the agreement gate's
|
|
178
|
+
* `config/content-gate-agreement` pattern (same signing surface), so an operator
|
|
179
|
+
* can rebrand/reword the certificate via `registry_edit` with no deploy; the
|
|
180
|
+
* hardcoded defaults are the fallback. Generic structural data-column labels
|
|
181
|
+
* (Signer/Role/Envelope ID/…) stay in the component, exactly as the gate keeps
|
|
182
|
+
* its structural markup in code. */
|
|
183
|
+
export interface CertificateCopy {
|
|
184
|
+
title: string;
|
|
185
|
+
signersHeading: string;
|
|
186
|
+
sealHeading: string;
|
|
187
|
+
sealNote: string;
|
|
188
|
+
footer: string;
|
|
189
|
+
badgeCompleted: string;
|
|
190
|
+
badgeInProgress: string;
|
|
191
|
+
/** Audit-table column labels — DB-driven so rewording is a registry_edit.
|
|
192
|
+
* Each labels BOTH the <th> header AND the stacked-row data-label on mobile
|
|
193
|
+
* (one source, kept in sync), never a second hardcoded copy. */
|
|
194
|
+
colSigner: string;
|
|
195
|
+
colRole: string;
|
|
196
|
+
colSigned: string;
|
|
197
|
+
colIp: string;
|
|
198
|
+
colAuth: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const CERTIFICATE_COPY_DEFAULTS: CertificateCopy = {
|
|
202
|
+
title: "CERTIFICATE OF COMPLETION",
|
|
203
|
+
signersHeading: "Signers",
|
|
204
|
+
sealHeading: "Tamper-evident seal",
|
|
205
|
+
sealNote:
|
|
206
|
+
"{{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.",
|
|
207
|
+
footer:
|
|
208
|
+
"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).",
|
|
209
|
+
badgeCompleted: "Completed",
|
|
210
|
+
badgeInProgress: "In progress",
|
|
211
|
+
colSigner: "Signer",
|
|
212
|
+
colRole: "Role",
|
|
213
|
+
colSigned: "Signed (UTC)",
|
|
214
|
+
colIp: "IP address",
|
|
215
|
+
colAuth: "Auth",
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const CertificateCopySchema = v.object({
|
|
219
|
+
title: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.title),
|
|
220
|
+
signersHeading: v.optional(
|
|
221
|
+
v.string(),
|
|
222
|
+
CERTIFICATE_COPY_DEFAULTS.signersHeading,
|
|
223
|
+
),
|
|
224
|
+
sealHeading: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealHeading),
|
|
225
|
+
sealNote: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealNote),
|
|
226
|
+
footer: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.footer),
|
|
227
|
+
badgeCompleted: v.optional(
|
|
228
|
+
v.string(),
|
|
229
|
+
CERTIFICATE_COPY_DEFAULTS.badgeCompleted,
|
|
230
|
+
),
|
|
231
|
+
badgeInProgress: v.optional(
|
|
232
|
+
v.string(),
|
|
233
|
+
CERTIFICATE_COPY_DEFAULTS.badgeInProgress,
|
|
234
|
+
),
|
|
235
|
+
colSigner: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigner),
|
|
236
|
+
colRole: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colRole),
|
|
237
|
+
colSigned: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigned),
|
|
238
|
+
colIp: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colIp),
|
|
239
|
+
colAuth: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colAuth),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
async function loadCertificateCopy(): Promise<CertificateCopy | null> {
|
|
243
|
+
try {
|
|
244
|
+
return await loadRegistryConfig(
|
|
245
|
+
"config",
|
|
246
|
+
"certificate-of-completion",
|
|
247
|
+
CertificateCopySchema,
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
// Absent signal, not a masking fallback (protective-layers-fail-explicitly):
|
|
251
|
+
// null propagates to CertificateData.copy, and the component renders its
|
|
252
|
+
// FALLBACK_COPY — same default values, but the absence is honest rather
|
|
253
|
+
// than disguised as a successful config load.
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The Certificate of Completion data shape — the court-style audit certificate.
|
|
259
|
+
* One home: {@link buildCertificateData} is consumed by both the
|
|
260
|
+
* `publicAgreement.certificate` ORPC and the SSR agreement gate, so the signing
|
|
261
|
+
* block receives identical data whether it reads the ORPC response or the
|
|
262
|
+
* server-seeded prop. */
|
|
263
|
+
export interface CertificateData {
|
|
264
|
+
ok: boolean;
|
|
265
|
+
envelopeId?: string;
|
|
266
|
+
documentName?: string | null;
|
|
267
|
+
issuedAt?: string;
|
|
268
|
+
complete?: boolean;
|
|
269
|
+
createdAt?: string | null;
|
|
270
|
+
sentAt?: string | null;
|
|
271
|
+
viewedAt?: string | null;
|
|
272
|
+
signedAt?: string | null;
|
|
273
|
+
documentHash?: string | null;
|
|
274
|
+
hashAlgo?: string | null;
|
|
275
|
+
hashedAt?: string | null;
|
|
276
|
+
parties?: {
|
|
277
|
+
role: string;
|
|
278
|
+
name: string | null;
|
|
279
|
+
email: string | null;
|
|
280
|
+
title: string | null;
|
|
281
|
+
company: string | null;
|
|
282
|
+
signedAt: string | null;
|
|
283
|
+
ip: string | null;
|
|
284
|
+
userAgent: string | null;
|
|
285
|
+
authMethod: string | null;
|
|
286
|
+
}[];
|
|
287
|
+
/** DB-driven display copy (config/certificate-of-completion); null when the
|
|
288
|
+
* config row is missing/broken — the component falls back to FALLBACK_COPY. */
|
|
289
|
+
copy?: CertificateCopy | null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Build the Certificate of Completion data for an agreement, gated to a party
|
|
294
|
+
* email — signer evidence (IP/device) is never exposed to a non-party. One data
|
|
295
|
+
* home: the `publicAgreement.certificate` ORPC and the SSR agreement gate
|
|
296
|
+
* (`buildSigningSession` in agreement-gate.ts) both call this. The read is
|
|
297
|
+
* intentionally RLS-unscoped via the raw registry prisma — the certificate is
|
|
298
|
+
* reached without a session (AG cookie only), which context.prisma's RLS would
|
|
299
|
+
* otherwise treat as no-org and hide. Accepts either the agreement record id or
|
|
300
|
+
* its page id (the UUID the signer sees in the URL is the page id).
|
|
301
|
+
*/
|
|
302
|
+
export async function buildCertificateData(
|
|
303
|
+
agreementId: string,
|
|
304
|
+
email: string,
|
|
305
|
+
): Promise<CertificateData> {
|
|
306
|
+
const id = agreementId.trim();
|
|
307
|
+
const mail = email.trim().toLowerCase();
|
|
308
|
+
if (!id || !mail) return { ok: false };
|
|
309
|
+
const { getPrisma } = await import("@teamscala/db/prisma-registry");
|
|
310
|
+
const db = getPrisma();
|
|
311
|
+
const agreement = await db.agreements.findFirst({
|
|
312
|
+
where: { OR: [{ id }, { page_id: id }] },
|
|
313
|
+
select: {
|
|
314
|
+
id: true,
|
|
315
|
+
name: true,
|
|
316
|
+
created_at: true,
|
|
317
|
+
sent_at: true,
|
|
318
|
+
viewed_at: true,
|
|
319
|
+
signed_at: true,
|
|
320
|
+
signature_data: true,
|
|
321
|
+
signer_email: true,
|
|
322
|
+
signer_name: true,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
if (!agreement) return { ok: false };
|
|
326
|
+
const parties = parseParties(agreement.signature_data);
|
|
327
|
+
const evidence = parseEvidence(agreement.signature_data);
|
|
328
|
+
const isParty = parties
|
|
329
|
+
? parties.some((p) => p.email.trim().toLowerCase() === mail)
|
|
330
|
+
: String(agreement.signer_email ?? "")
|
|
331
|
+
.trim()
|
|
332
|
+
.toLowerCase() === mail;
|
|
333
|
+
if (!isParty) return { ok: false };
|
|
334
|
+
const complete =
|
|
335
|
+
!!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
|
|
336
|
+
const iso = (d: Date | null | undefined) => d?.toISOString() ?? null;
|
|
337
|
+
const copy = await loadCertificateCopy();
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
copy,
|
|
341
|
+
envelopeId: agreement.id,
|
|
342
|
+
documentName: agreement.name,
|
|
343
|
+
issuedAt: new Date().toISOString(),
|
|
344
|
+
complete,
|
|
345
|
+
createdAt: iso(agreement.created_at),
|
|
346
|
+
sentAt: iso(agreement.sent_at),
|
|
347
|
+
viewedAt: iso(agreement.viewed_at),
|
|
348
|
+
signedAt: iso(agreement.signed_at),
|
|
349
|
+
documentHash: evidence?.documentHash ?? null,
|
|
350
|
+
hashAlgo: evidence?.hashAlgo ?? null,
|
|
351
|
+
hashedAt: evidence?.hashedAt ?? null,
|
|
352
|
+
parties: parties
|
|
353
|
+
? parties.map((p) => ({
|
|
354
|
+
role: p.key,
|
|
355
|
+
name: p.name,
|
|
356
|
+
email: p.email,
|
|
357
|
+
title: p.title,
|
|
358
|
+
company: p.company,
|
|
359
|
+
signedAt: p.signedAt,
|
|
360
|
+
ip: p.signedIp ?? null,
|
|
361
|
+
userAgent: p.signedUserAgent ?? null,
|
|
362
|
+
authMethod: p.authMethod ?? null,
|
|
363
|
+
}))
|
|
364
|
+
: [
|
|
365
|
+
{
|
|
366
|
+
role: "signer",
|
|
367
|
+
name: agreement.signer_name,
|
|
368
|
+
email: agreement.signer_email,
|
|
369
|
+
title: null,
|
|
370
|
+
company: null,
|
|
371
|
+
signedAt: iso(agreement.signed_at),
|
|
372
|
+
ip: null,
|
|
373
|
+
userAgent: null,
|
|
374
|
+
authMethod: "email-match",
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Self-resetting demo harness. If the signed agreement's `proposal_id` is in
|
|
382
|
+
* the `demo-reset-agreements.proposalIds` allow-list, reset its parties to
|
|
383
|
+
* UNSIGNED (signedAt/signature cleared) + clear signed_at/signed_by + status
|
|
384
|
+
* 'sent', so the next person gets a fresh form to test the UI.
|
|
385
|
+
*
|
|
386
|
+
* Keyed on the stable PROPOSAL id, NOT the agreement id: the demo agreement is
|
|
387
|
+
* regenerated under a fresh id whenever the demo proposal is re-accepted, but
|
|
388
|
+
* its `proposal_id` FK is constant — so the allow-list cannot dangle. This
|
|
389
|
+
* deletes the drift class (prevention, not a patrol): the prior design keyed on
|
|
390
|
+
* `agreementIds`, and when the demo was regenerated the list silently pointed
|
|
391
|
+
* at a dead id and the reset became a no-op for weeks. A `proposal_id`-
|
|
392
|
+
* referenced row that doesn't exist is a real proposal being deleted, not a
|
|
393
|
+
* regeneration — a negligible residual the operator would notice directly.
|
|
394
|
+
*
|
|
395
|
+
* Gated strictly by the allow-list — a real agreement is never reset.
|
|
396
|
+
* Best-effort: any failure is swallowed (a demo-reset must never fail the
|
|
397
|
+
* signer's own response). The current signer already has their response (incl.
|
|
398
|
+
* payUrl) before this runs; only the stored record is reset.
|
|
399
|
+
*/
|
|
400
|
+
async function maybeResetDemoAgreement(
|
|
401
|
+
prisma: any,
|
|
402
|
+
agreementId: string,
|
|
403
|
+
proposalId: string | null,
|
|
404
|
+
parties: AgreementParty[],
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
if (!proposalId) return;
|
|
407
|
+
try {
|
|
408
|
+
const rows = await queryRows`
|
|
409
|
+
SELECT config FROM registry_entries
|
|
410
|
+
WHERE type = 'config' AND slug = 'demo-reset-agreements' AND is_active = true
|
|
411
|
+
LIMIT 1` as { config: { proposalIds?: string[] } }[];
|
|
412
|
+
const ids = rows?.[0]?.config?.proposalIds ?? [];
|
|
413
|
+
if (!Array.isArray(ids) || !ids.includes(proposalId)) return;
|
|
414
|
+
const reset = parties.map((p) => ({
|
|
415
|
+
...p,
|
|
416
|
+
signedAt: null,
|
|
417
|
+
signature: null,
|
|
418
|
+
title: null,
|
|
419
|
+
company: null,
|
|
420
|
+
}));
|
|
421
|
+
await prisma.agreements.update({
|
|
422
|
+
where: { id: agreementId },
|
|
423
|
+
data: {
|
|
424
|
+
signature_data: JSON.stringify({ parties: reset }),
|
|
425
|
+
signed_at: null,
|
|
426
|
+
signed_by: null,
|
|
427
|
+
status: "sent",
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
getAppLogger().info(`[publicAgreement.sign] demo agreement ${agreementId} auto-reset for re-testing`);
|
|
431
|
+
} catch {
|
|
432
|
+
// never fail the signer's response over a demo-reset
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Connect a captured signer to the CRM — every signable document lands in
|
|
438
|
+
* client_database, the same find-or-create capture path proposals / public-GPT /
|
|
439
|
+
* assessments already use (scoped by organisation_id + primary_email). Also
|
|
440
|
+
* links agreement.contact_id when the agreement wasn't generated from a proposal
|
|
441
|
+
* (contact_id NULL), so a standalone signed doc still connects to the CRM.
|
|
442
|
+
* Best-effort: a CRM write must never fail the signature (the signature is the
|
|
443
|
+
* legal act; CRM connection is bookkeeping).
|
|
444
|
+
*/
|
|
445
|
+
async function connectSignerToCrm(
|
|
446
|
+
prisma: any,
|
|
447
|
+
agreement: { id: string; organisation_id?: string | null; contact_id?: string | null },
|
|
448
|
+
email: string,
|
|
449
|
+
signerName?: string | null,
|
|
450
|
+
company?: string | null,
|
|
451
|
+
): Promise<void> {
|
|
452
|
+
const orgId = agreement.organisation_id;
|
|
453
|
+
if (!orgId || !email || !prisma?.client_database) return;
|
|
454
|
+
try {
|
|
455
|
+
const parts = (signerName ?? "").trim().split(/\s+/).filter(Boolean);
|
|
456
|
+
const firstName = parts[0] ?? "";
|
|
457
|
+
const lastName = parts.slice(1).join(" ");
|
|
458
|
+
const existing = await prisma.client_database.findFirst({
|
|
459
|
+
where: { organisation_id: orgId, primary_email: email },
|
|
460
|
+
select: { id: true },
|
|
461
|
+
});
|
|
462
|
+
let contactId: string | null = null;
|
|
463
|
+
if (existing) {
|
|
464
|
+
contactId = existing.id as string;
|
|
465
|
+
await prisma.client_database.update({
|
|
466
|
+
where: { id: existing.id },
|
|
467
|
+
data: {
|
|
468
|
+
...(firstName ? { first_name: firstName } : {}),
|
|
469
|
+
...(lastName ? { last_name: lastName } : {}),
|
|
470
|
+
...(company ? { company_name: company } : {}),
|
|
471
|
+
last_updated_by: "agreement-sign",
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
} else {
|
|
475
|
+
const created = await prisma.client_database.create({
|
|
476
|
+
data: {
|
|
477
|
+
organisation_id: orgId,
|
|
478
|
+
first_name: firstName,
|
|
479
|
+
last_name: lastName,
|
|
480
|
+
primary_email: email,
|
|
481
|
+
...(company ? { company_name: company } : {}),
|
|
482
|
+
lead_source: "agreement-sign",
|
|
483
|
+
data_source: "public-form",
|
|
484
|
+
referral_source: agreement.id,
|
|
485
|
+
lifecycle_stage: "lead",
|
|
486
|
+
created_by: "agreement-sign",
|
|
487
|
+
last_updated_by: "agreement-sign",
|
|
488
|
+
},
|
|
489
|
+
select: { id: true },
|
|
490
|
+
});
|
|
491
|
+
contactId = (created?.id as string) ?? null;
|
|
492
|
+
}
|
|
493
|
+
if (contactId && !agreement.contact_id) {
|
|
494
|
+
await prisma.agreements.update({
|
|
495
|
+
where: { id: agreement.id },
|
|
496
|
+
data: { contact_id: contactId },
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
} catch (e) {
|
|
500
|
+
getAppLogger().warn(
|
|
501
|
+
`[publicAgreement.sign] CRM connect failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface Context {
|
|
507
|
+
prisma: any;
|
|
508
|
+
headers: Headers;
|
|
509
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<any>;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const pub = os.$context<Context>();
|
|
513
|
+
|
|
514
|
+
const verifySchema = v.object({
|
|
515
|
+
agreementId: v.string(),
|
|
516
|
+
email: v.string(),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const signSchema = v.object({
|
|
520
|
+
agreementId: v.string(),
|
|
521
|
+
email: v.string(),
|
|
522
|
+
signatureData: v.optional(v.string()),
|
|
523
|
+
signerName: v.optional(v.string()),
|
|
524
|
+
// Legal signature-block fields, captured in the signing modal.
|
|
525
|
+
title: v.optional(v.string()),
|
|
526
|
+
company: v.optional(v.string()),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
export const publicAgreementRouter = {
|
|
530
|
+
// View-gate: confirms an email matches a party on the agreement AND, only on a
|
|
531
|
+
// match, returns the document content. The content is NOT in the public page
|
|
532
|
+
// SSR (the public route ships only the SignIn gate block) — the document blocks
|
|
533
|
+
// live on the agreement's page_id and are served here, gated by the email
|
|
534
|
+
// match, so an unauthenticated/wrong-email request never receives the bytes.
|
|
535
|
+
verify: pub.input(verifySchema).handler(async ({ input, context }) => {
|
|
536
|
+
const agreementId = input.agreementId.trim();
|
|
537
|
+
const email = input.email.trim().toLowerCase();
|
|
538
|
+
if (!agreementId || !email) return { matched: false as const };
|
|
539
|
+
|
|
540
|
+
const agreement = await context.prisma.agreements.findFirst({
|
|
541
|
+
where: { id: agreementId },
|
|
542
|
+
select: {
|
|
543
|
+
id: true,
|
|
544
|
+
name: true,
|
|
545
|
+
signer_email: true,
|
|
546
|
+
signer_name: true,
|
|
547
|
+
signed_at: true,
|
|
548
|
+
signature_data: true,
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
if (!agreement) return { matched: false as const };
|
|
552
|
+
|
|
553
|
+
const parties = parseParties(agreement.signature_data);
|
|
554
|
+
if (parties) {
|
|
555
|
+
const me = parties.find((p) => p.email.trim().toLowerCase() === email);
|
|
556
|
+
if (!me) return { matched: false as const };
|
|
557
|
+
return {
|
|
558
|
+
matched: true,
|
|
559
|
+
multiParty: true,
|
|
560
|
+
name: agreement.name,
|
|
561
|
+
role: me.key,
|
|
562
|
+
signerName: me.name,
|
|
563
|
+
partySignedAt: me.signedAt ?? null,
|
|
564
|
+
allSigned: parties.every((p) => p.signedAt),
|
|
565
|
+
parties: partySummary(parties),
|
|
566
|
+
// Signed proof for the page loader: only with this token does the SSR
|
|
567
|
+
// include the document. The client sets it as a cookie and reloads;
|
|
568
|
+
// the loader validates it and renders the document server-side.
|
|
569
|
+
accessToken: signAgreementAccessToken(agreementId, email, Date.now()),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Legacy single-signer path.
|
|
574
|
+
const matched =
|
|
575
|
+
String(agreement.signer_email ?? "")
|
|
576
|
+
.trim()
|
|
577
|
+
.toLowerCase() === email;
|
|
578
|
+
if (!matched) return { matched: false as const };
|
|
579
|
+
return {
|
|
580
|
+
matched: true,
|
|
581
|
+
name: agreement.name,
|
|
582
|
+
signerName: agreement.signer_name,
|
|
583
|
+
signedAt: agreement.signed_at,
|
|
584
|
+
accessToken: signAgreementAccessToken(agreementId, email, Date.now()),
|
|
585
|
+
};
|
|
586
|
+
}),
|
|
587
|
+
|
|
588
|
+
// Commit the authed party's signature. Returns { ok, parties } so the block
|
|
589
|
+
// can refresh the roster; on a gate failure returns { ok:false, error } so
|
|
590
|
+
// the block can show the specific message rather than a generic throw.
|
|
591
|
+
sign: pub.input(signSchema).handler(async ({ input, context }) => {
|
|
592
|
+
const log = getAppLogger();
|
|
593
|
+
const agreementId = input.agreementId.trim();
|
|
594
|
+
const email = input.email.trim().toLowerCase();
|
|
595
|
+
if (!agreementId || !email) {
|
|
596
|
+
return { ok: false as const, error: "agreementId and email are required" };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const agreement = await context.prisma.agreements.findFirst({
|
|
600
|
+
where: { id: agreementId },
|
|
601
|
+
select: {
|
|
602
|
+
id: true,
|
|
603
|
+
organisation_id: true,
|
|
604
|
+
signer_email: true,
|
|
605
|
+
signer_name: true,
|
|
606
|
+
contact_id: true,
|
|
607
|
+
signed_at: true,
|
|
608
|
+
signature_data: true,
|
|
609
|
+
page_id: true,
|
|
610
|
+
proposal_id: true,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
if (!agreement) return { ok: false as const, error: "Agreement not found" };
|
|
614
|
+
|
|
615
|
+
const now = new Date().toISOString();
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
// Multi-party path: signature_data holds a parties roster.
|
|
619
|
+
const parties = parseParties(agreement.signature_data);
|
|
620
|
+
if (parties) {
|
|
621
|
+
const idx = parties.findIndex(
|
|
622
|
+
(p) => p.email.trim().toLowerCase() === email,
|
|
623
|
+
);
|
|
624
|
+
if (idx < 0) {
|
|
625
|
+
return {
|
|
626
|
+
ok: false as const,
|
|
627
|
+
error: "This email is not a signer on this agreement.",
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
if (!parties[idx].signedAt) {
|
|
631
|
+
parties[idx].signedAt = now;
|
|
632
|
+
parties[idx].signature = (
|
|
633
|
+
input.signatureData ?? parties[idx].name
|
|
634
|
+
).trim();
|
|
635
|
+
if (input.title != null)
|
|
636
|
+
parties[idx].title = input.title.trim() || null;
|
|
637
|
+
if (input.company != null)
|
|
638
|
+
parties[idx].company = input.company.trim() || null;
|
|
639
|
+
// Signing-evidence capture (DocuSign-style audit trail): the
|
|
640
|
+
// signer's IP + device at the moment of signing, plus how they
|
|
641
|
+
// were authenticated. Stored in signature_data → captured
|
|
642
|
+
// immutably by audit_trail on this update. The agreement gate
|
|
643
|
+
// currently authorises by email-match; authMethod becomes "email-otp"
|
|
644
|
+
// once the gate unifies onto the standard OTP login.
|
|
645
|
+
// unifies onto the standard OTP login.
|
|
646
|
+
parties[idx].signedIp = clientIp(context.headers);
|
|
647
|
+
parties[idx].signedUserAgent =
|
|
648
|
+
context.headers.get("user-agent") ?? null;
|
|
649
|
+
parties[idx].authMethod = "email-match";
|
|
650
|
+
}
|
|
651
|
+
// EXECUTION vs PAYMENT are separate (agreement-signing.md §2):
|
|
652
|
+
// • executed = every party has signed → gates signed_at, signed_by,
|
|
653
|
+
// the tamper-evident seal, and the Certificate of Completion. An
|
|
654
|
+
// agreement is NOT "complete" until all parties have signed.
|
|
655
|
+
// • firePayment = the CLIENT's signature triggers Checkout (operator
|
|
656
|
+
// ruling) — the client pays when they sign, even before the provider
|
|
657
|
+
// has countersigned. This does NOT mark the document executed.
|
|
658
|
+
const allSigned = parties.every((p) => p.signedAt);
|
|
659
|
+
const clientSigned = parties[idx].key === "client";
|
|
660
|
+
const executed = allSigned;
|
|
661
|
+
const firePayment = clientSigned;
|
|
662
|
+
// Tamper-evident seal: at completion, hash the executed document +
|
|
663
|
+
// signed roster and store it alongside the parties. The verification
|
|
664
|
+
// surface re-hashes later to prove the document was not altered.
|
|
665
|
+
let evidence: Record<string, unknown> | undefined;
|
|
666
|
+
if (executed && agreement.page_id) {
|
|
667
|
+
try {
|
|
668
|
+
const doc = await context.prisma.builda_pages.findFirst({
|
|
669
|
+
where: { id: agreement.page_id },
|
|
670
|
+
select: { puck_data: true },
|
|
671
|
+
});
|
|
672
|
+
evidence = {
|
|
673
|
+
documentHash: computeDocumentHash(doc?.puck_data ?? null, parties),
|
|
674
|
+
hashAlgo: "sha256",
|
|
675
|
+
hashedAt: now,
|
|
676
|
+
};
|
|
677
|
+
} catch (e) {
|
|
678
|
+
// A hashing failure must never block the signature itself.
|
|
679
|
+
log.warn(
|
|
680
|
+
`[publicAgreement.sign] document-hash failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const data: Record<string, unknown> = {
|
|
685
|
+
signature_data: JSON.stringify(
|
|
686
|
+
evidence ? { parties, evidence } : { parties },
|
|
687
|
+
),
|
|
688
|
+
viewed_at: now,
|
|
689
|
+
};
|
|
690
|
+
if (executed) {
|
|
691
|
+
data.signed_at = now;
|
|
692
|
+
data.signed_by = agreement.contact_id ?? null;
|
|
693
|
+
}
|
|
694
|
+
const updated = await context.prisma.agreements.update({
|
|
695
|
+
where: { id: agreementId },
|
|
696
|
+
data,
|
|
697
|
+
});
|
|
698
|
+
// Completion delivery: when THIS signature is the one that fully
|
|
699
|
+
// executes the agreement (it was not executed before this call),
|
|
700
|
+
// email every party their executed copy + Certificate of Completion.
|
|
701
|
+
// Fire-and-forget — the helper is best-effort and self-contained, so
|
|
702
|
+
// a mail failure never blocks or undoes the signature.
|
|
703
|
+
if (executed && !agreement.signed_at) {
|
|
704
|
+
void sendAgreementCompletionEmails(
|
|
705
|
+
agreementId,
|
|
706
|
+
originFrom(context.headers),
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
// Every captured identity → CRM: connect this signer to client_database.
|
|
710
|
+
await connectSignerToCrm(
|
|
711
|
+
context.prisma,
|
|
712
|
+
agreement,
|
|
713
|
+
email,
|
|
714
|
+
parties[idx].name,
|
|
715
|
+
input.company ?? parties[idx].company,
|
|
716
|
+
);
|
|
717
|
+
const fresh = parseParties(updated.signature_data) ?? parties;
|
|
718
|
+
const payUrl = firePayment
|
|
719
|
+
? await invoicePayUrl(agreementId, originFrom(context.headers))
|
|
720
|
+
: null;
|
|
721
|
+
const result = {
|
|
722
|
+
ok: true as const,
|
|
723
|
+
role: parties[idx].key,
|
|
724
|
+
signedAt: parties[idx].signedAt,
|
|
725
|
+
allSigned: executed,
|
|
726
|
+
parties: partySummary(fresh),
|
|
727
|
+
payUrl,
|
|
728
|
+
};
|
|
729
|
+
// Self-resetting demo harness: an allow-listed demo agreement
|
|
730
|
+
// auto-unsigns once it reaches payment, so the next person gets a
|
|
731
|
+
// fresh form to test the UI. The current signer's response (above)
|
|
732
|
+
// still shows their signature + payUrl; only the stored record is
|
|
733
|
+
// reset. Real agreements are never in the allow-list, so they never
|
|
734
|
+
// auto-reset. (config: demo-reset-agreements)
|
|
735
|
+
if (firePayment) {
|
|
736
|
+
await maybeResetDemoAgreement(
|
|
737
|
+
context.prisma,
|
|
738
|
+
agreementId,
|
|
739
|
+
agreement.proposal_id ?? null,
|
|
740
|
+
parties,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
return result;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Legacy single-signer path.
|
|
747
|
+
const signerEmail = String(agreement.signer_email ?? "")
|
|
748
|
+
.trim()
|
|
749
|
+
.toLowerCase();
|
|
750
|
+
if (!signerEmail || signerEmail !== email) {
|
|
751
|
+
return {
|
|
752
|
+
ok: false as const,
|
|
753
|
+
error: "This email does not match the agreement's signer.",
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (agreement.signed_at) {
|
|
757
|
+
return {
|
|
758
|
+
ok: true as const,
|
|
759
|
+
alreadySigned: true,
|
|
760
|
+
signedAt: agreement.signed_at,
|
|
761
|
+
signerName: agreement.signer_name,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
const updated = await context.prisma.agreements.update({
|
|
765
|
+
where: { id: agreementId },
|
|
766
|
+
data: {
|
|
767
|
+
signature_data: input.signatureData ?? agreement.signer_name ?? "",
|
|
768
|
+
signed_at: now,
|
|
769
|
+
viewed_at: now,
|
|
770
|
+
signed_by: agreement.contact_id ?? null,
|
|
771
|
+
...(input.signerName ? { signer_name: input.signerName } : {}),
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
// Every captured identity → CRM: connect this signer to client_database.
|
|
775
|
+
await connectSignerToCrm(
|
|
776
|
+
context.prisma,
|
|
777
|
+
agreement,
|
|
778
|
+
email,
|
|
779
|
+
input.signerName ?? agreement.signer_name,
|
|
780
|
+
input.company,
|
|
781
|
+
);
|
|
782
|
+
return {
|
|
783
|
+
ok: true as const,
|
|
784
|
+
signedAt: updated.signed_at,
|
|
785
|
+
signerName: updated.signer_name,
|
|
786
|
+
payUrl: await invoicePayUrl(agreementId, originFrom(context.headers)),
|
|
787
|
+
};
|
|
788
|
+
} catch (err) {
|
|
789
|
+
log.error(
|
|
790
|
+
`[publicAgreement.sign] failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
791
|
+
);
|
|
792
|
+
return { ok: false as const, error: "Could not submit your signature." };
|
|
793
|
+
}
|
|
794
|
+
}),
|
|
795
|
+
|
|
796
|
+
// Certificate of Completion — the court-style audit certificate for an
|
|
797
|
+
// agreement. Delegates to buildCertificateData (the single data home shared
|
|
798
|
+
// with the SSR agreement gate); the email-must-be-a-party gate lives there,
|
|
799
|
+
// so signer evidence (IP/device) is never exposed to a non-party. Read-only;
|
|
800
|
+
// the signing block renders it inline.
|
|
801
|
+
certificate: pub
|
|
802
|
+
.input(v.object({ agreementId: v.string(), email: v.string() }))
|
|
803
|
+
.handler(async ({ input }) =>
|
|
804
|
+
buildCertificateData(input.agreementId, input.email),
|
|
805
|
+
),
|
|
806
|
+
|
|
807
|
+
// Public tamper-verification: re-hash the currently-stored document + roster
|
|
808
|
+
// and compare to the sealed hash captured at completion. Returns only
|
|
809
|
+
// public-safe data (names, roles, signed timestamps, hash match) — never
|
|
810
|
+
// emails or IPs — so it can back a public "verify this document" page.
|
|
811
|
+
verifyDocument: pub
|
|
812
|
+
.input(v.object({ agreementId: v.string() }))
|
|
813
|
+
.handler(async ({ input }) => {
|
|
814
|
+
const agreementId = input.agreementId.trim();
|
|
815
|
+
if (!agreementId) return { ok: false as const };
|
|
816
|
+
// Public verification is intentionally unscoped: anyone holding the id
|
|
817
|
+
// confirms authenticity and gets only public-safe data back. The caller
|
|
818
|
+
// is unauthenticated (no access cookie), so context.prisma's RLS would
|
|
819
|
+
// hide the row — use the raw registry prisma, the same bypass the gate's
|
|
820
|
+
// signer-email resolver uses.
|
|
821
|
+
const { getPrisma } = await import("@teamscala/db/prisma-registry");
|
|
822
|
+
const db = getPrisma();
|
|
823
|
+
const agreement = await db.agreements.findFirst({
|
|
824
|
+
where: { OR: [{ id: agreementId }, { page_id: agreementId }] },
|
|
825
|
+
select: {
|
|
826
|
+
id: true,
|
|
827
|
+
name: true,
|
|
828
|
+
signed_at: true,
|
|
829
|
+
signature_data: true,
|
|
830
|
+
page_id: true,
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
if (!agreement) return { ok: false as const };
|
|
834
|
+
const parties = parseParties(agreement.signature_data);
|
|
835
|
+
const evidence = parseEvidence(agreement.signature_data);
|
|
836
|
+
const complete =
|
|
837
|
+
!!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
|
|
838
|
+
let recomputedHash: string | null = null;
|
|
839
|
+
if (evidence?.documentHash && parties && agreement.page_id) {
|
|
840
|
+
const doc = await db.builda_pages.findFirst({
|
|
841
|
+
where: { id: agreement.page_id },
|
|
842
|
+
select: { puck_data: true },
|
|
843
|
+
});
|
|
844
|
+
recomputedHash = computeDocumentHash(doc?.puck_data ?? null, parties);
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
ok: true as const,
|
|
848
|
+
envelopeId: agreement.id,
|
|
849
|
+
documentName: agreement.name,
|
|
850
|
+
complete,
|
|
851
|
+
signedAt: agreement.signed_at?.toISOString() ?? null,
|
|
852
|
+
sealedHash: evidence?.documentHash ?? null,
|
|
853
|
+
recomputedHash,
|
|
854
|
+
valid:
|
|
855
|
+
!!evidence?.documentHash && recomputedHash === evidence.documentHash,
|
|
856
|
+
parties: parties
|
|
857
|
+
? parties.map((p) => ({
|
|
858
|
+
role: p.key,
|
|
859
|
+
name: p.name,
|
|
860
|
+
signedAt: p.signedAt,
|
|
861
|
+
}))
|
|
862
|
+
: [],
|
|
863
|
+
};
|
|
864
|
+
}),
|
|
865
|
+
};
|