@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.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. 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
+ };