@teamscala/service-boot 0.23.479 → 0.23.492

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 (46) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-registry-page-route/region-slots-page-data.ts +2 -2
  7. package/src/register-ws-routes.ts +1 -1
  8. package/src/fn-routers/agreement-certificate.ts +0 -224
  9. package/src/fn-routers/agreement-completion-email.ts +0 -114
  10. package/src/fn-routers/agreement-send-router.ts +0 -303
  11. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  12. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  13. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  14. package/src/fn-routers/assessment-report-data.ts +0 -485
  15. package/src/fn-routers/assessment-report-email.ts +0 -256
  16. package/src/fn-routers/assessment-report-pair.test.ts +0 -107
  17. package/src/fn-routers/assessment-report-pair.ts +0 -155
  18. package/src/fn-routers/assessment-research-report-data.test.ts +0 -262
  19. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  20. package/src/fn-routers/assessment-scoring.ts +0 -105
  21. package/src/fn-routers/auth-router.ts +0 -65
  22. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  23. package/src/fn-routers/chat-session-router.ts +0 -356
  24. package/src/fn-routers/conversation-ownership.ts +0 -24
  25. package/src/fn-routers/email-defaults.ts +0 -28
  26. package/src/fn-routers/email-link-ttl.ts +0 -28
  27. package/src/fn-routers/index.ts +0 -76
  28. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  29. package/src/fn-routers/linkedin-identity.ts +0 -36
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  31. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  32. package/src/fn-routers/panel-data-router.ts +0 -104
  33. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  34. package/src/fn-routers/persist-chat-messages.ts +0 -98
  35. package/src/fn-routers/proposal-send-router.ts +0 -207
  36. package/src/fn-routers/public-agreement-router.ts +0 -407
  37. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  38. package/src/fn-routers/public-assessment-router.ts +0 -268
  39. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  40. package/src/fn-routers/resend-client.ts +0 -39
  41. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  42. package/src/fn-routers/user-preferences-router.ts +0 -130
  43. package/src/fn-routers/work-items-router.ts +0 -342
  44. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  45. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  46. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,407 +0,0 @@
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
- * Off-the-shelf path (building-applications): typed ORPC procedure, not a
12
- * hand-written Hono handler; writes go through context.prisma inside the
13
- * handler (orpc-is-the-only-api) so the audit trigger sees the ORPC caller.
14
- * ONE signing model, the DocuSign recipients shape: signature_data holds a
15
- * parties roster (JSON), each party is gated by its own email, and the
16
- * agreement completes only when every party has signed. A single signer is a
17
- * roster of one -- there is no separate single-signer path.
18
- *
19
- * A legacy signer_email/signer_name fallback used to sit alongside this and was
20
- * deleted 2026-09-03: measured across every agreement on the platform, 13 of 14
21
- * carried a roster and ZERO used the fallback (the 14th was an unsent draft with
22
- * no signature_data at all). It was never exercised, and it was not inert -- its
23
- * write put a BARE STRING into signature_data, which would have destroyed the
24
- * roster of any agreement that reached it. Keeping it also meant every change to
25
- * this handler had to be made twice: the Prisma 8 timestamptz fix that made
26
- * signing work at all had to be applied to both branches of one function.
27
- *
28
- * This module holds the three procedures (verify/sign/verifyDocument); the
29
- * supporting machinery lives as siblings (agreement-signing-evidence,
30
- * agreement-certificate, agreement-sign-side-effects) — split when the parent
31
- * held them all at 853 lines.
32
- */
33
-
34
- import { os } from "@orpc/server";
35
- import * as v from "valibot";
36
- import { getAppLogger } from "@teamscala/logger/app-loggers";
37
- import { queryRows } from "@teamscala/db/query-rows";
38
- import { recordEvent } from "@teamscala/event-log/record-event";
39
- import type { PrismaClient } from "@teamscala/db/client";
40
- import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
41
- import {
42
- parseAgreementParties as parseParties,
43
- toPublicParties as partySummary,
44
- } from "@teamscala/document-signing/agreement-parties";
45
- import { sendAgreementCompletionEmails } from "./agreement-completion-email.ts";
46
- import {
47
- clientIp,
48
- computeDocumentHash,
49
- originFrom,
50
- parseEvidence,
51
- } from "./agreement-signing-evidence.ts";
52
- import {
53
- connectSignerToCrm,
54
- invoicePayUrl,
55
- maybeResetDemoAgreement,
56
- } from "./agreement-sign-side-effects.ts";
57
-
58
- export { buildCertificateData } from "./agreement-certificate.ts";
59
- export type { CertificateData, CertificateCopy } from "./agreement-certificate.ts";
60
-
61
- interface Context {
62
- prisma: PrismaClient;
63
- headers: Headers;
64
- resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<unknown>;
65
- }
66
-
67
- const pub = os.$context<Context>();
68
-
69
- const verifySchema = v.object({
70
- agreementId: v.string(),
71
- email: v.string(),
72
- });
73
-
74
- const signSchema = v.object({
75
- agreementId: v.string(),
76
- email: v.string(),
77
- signatureData: v.optional(v.string()),
78
- signerName: v.optional(v.string()),
79
- // Legal signature-block fields, captured in the signing modal.
80
- title: v.optional(v.string()),
81
- company: v.optional(v.string()),
82
- });
83
-
84
- export const publicAgreementRouter = {
85
- // View-gate: confirms an email matches a party on the agreement AND, only on a
86
- // match, returns the document content. The content is NOT in the public page
87
- // SSR (the public route ships only the SignIn gate block) — the document blocks
88
- // live on the agreement's page_id and are served here, gated by the email
89
- // match, so an unauthenticated/wrong-email request never receives the bytes.
90
- verify: pub.input(verifySchema).handler(async ({ input, context }) => {
91
- const agreementId = input.agreementId.trim();
92
- const email = input.email.trim().toLowerCase();
93
- if (!agreementId || !email) return { matched: false as const };
94
-
95
- const agreement = await context.prisma.agreements.findFirst({
96
- where: { id: agreementId },
97
- select: {
98
- id: true,
99
- name: true,
100
- signer_email: true,
101
- signer_name: true,
102
- signed_at: true,
103
- signature_data: true,
104
- },
105
- });
106
- if (!agreement) return { matched: false as const };
107
-
108
- const parties = parseParties(agreement.signature_data);
109
- if (parties) {
110
- const me = parties.find((p) => p.email.trim().toLowerCase() === email);
111
- if (!me) return { matched: false as const };
112
- return {
113
- matched: true,
114
- multiParty: true,
115
- name: agreement.name,
116
- role: me.key,
117
- signerName: me.name,
118
- partySignedAt: me.signedAt ?? null,
119
- allSigned: parties.every((p) => p.signedAt),
120
- parties: partySummary(parties),
121
- // Signed proof for the page loader: only with this token does the SSR
122
- // include the document. The client sets it as a cookie and reloads;
123
- // the loader validates it and renders the document server-side.
124
- accessToken: signAgreementAccessToken(agreementId, email, Date.now()),
125
- };
126
- }
127
-
128
- // No roster means the agreement was never prepared for signing. It is
129
- // not viewable, and it is certainly not signable.
130
- return { matched: false as const };
131
- }),
132
-
133
- // Commit the authed party's signature. Returns { ok, parties } so the block
134
- // can refresh the roster; on a gate failure returns { ok:false, error } so
135
- // the block can show the specific message rather than a generic throw.
136
- sign: pub.input(signSchema).handler(async ({ input, context }) => {
137
- const log = getAppLogger();
138
- const agreementId = input.agreementId.trim();
139
- const email = input.email.trim().toLowerCase();
140
- if (!agreementId || !email) {
141
- return { ok: false as const, error: "agreementId and email are required" };
142
- }
143
-
144
- const agreement = await context.prisma.agreements.findFirst({
145
- where: { id: agreementId },
146
- select: {
147
- id: true,
148
- organisation_id: true,
149
- signer_email: true,
150
- signer_name: true,
151
- contact_id: true,
152
- signed_at: true,
153
- signature_data: true,
154
- page_id: true,
155
- proposal_id: true,
156
- },
157
- });
158
- if (!agreement) return { ok: false as const, error: "Agreement not found" };
159
-
160
- // TWO representations, deliberately, because they have two consumers.
161
- //
162
- // `now` is an ISO STRING and belongs in the parties roster, which is
163
- // serialised to JSON in signature_data — a Temporal.Instant would
164
- // stringify into that JSON as an object.
165
- //
166
- // `nowInstant` is what the timestamptz COLUMNS take. Under Prisma 8 the
167
- // timestamptz codec ('pg/timestamptz-temporal@1') accepts a
168
- // Temporal.Instant and NOTHING ELSE. Measured against the live service:
169
- // a string gives "received a string", a Date gives "received a Date",
170
- // and only Temporal.Instant writes. Passing `now` therefore threw on
171
- // EVERY signature, and the handler's catch reported the generic "Could
172
- // not submit your signature." -- so signing was impossible for every
173
- // signer with nothing naming the cause.
174
- //
175
- // The cast is load-bearing and is NOT laziness: the generated Prisma
176
- // types declare these fields `string | Date` and reject Instant, so the
177
- // client's TYPES and its RUNTIME CODEC disagree. Following the types
178
- // produces code that compiles and always throws, which is exactly how
179
- // this shipped. The runtime is the authority here; the generated types
180
- // are the defect, and they are the thing to fix upstream.
181
- const now = new Date().toISOString();
182
- const nowInstant = Temporal.Now.instant() as unknown as Date;
183
-
184
- try {
185
- // Multi-party path: signature_data holds a parties roster.
186
- const parties = parseParties(agreement.signature_data);
187
- if (parties) {
188
- const idx = parties.findIndex(
189
- (p) => p.email.trim().toLowerCase() === email,
190
- );
191
- if (idx < 0) {
192
- return {
193
- ok: false as const,
194
- error: "This email is not a signer on this agreement.",
195
- };
196
- }
197
- if (!parties[idx].signedAt) {
198
- parties[idx].signedAt = now;
199
- parties[idx].signature = (
200
- input.signatureData ?? parties[idx].name
201
- ).trim();
202
- if (input.title != null)
203
- parties[idx].title = input.title.trim() || null;
204
- if (input.company != null)
205
- parties[idx].company = input.company.trim() || null;
206
- // Signing-evidence capture (DocuSign-style audit trail): the
207
- // signer's IP + device at the moment of signing, plus how they
208
- // were authenticated. Stored in signature_data → captured
209
- // immutably by audit_trail on this update. The agreement gate
210
- // currently authorises by email-match; authMethod becomes "email-otp"
211
- // once the gate unifies onto the standard OTP login.
212
- // unifies onto the standard OTP login.
213
- parties[idx].signedIp = clientIp(context.headers);
214
- parties[idx].signedUserAgent =
215
- context.headers.get("user-agent") ?? null;
216
- parties[idx].authMethod = "email-match";
217
- }
218
- // EXECUTION vs PAYMENT are separate (agreement-signing.md §2):
219
- // • executed = every party has signed → gates signed_at, signed_by,
220
- // the tamper-evident seal, and the Certificate of Completion. An
221
- // agreement is NOT "complete" until all parties have signed.
222
- // • firePayment = the CLIENT's signature triggers Checkout (operator
223
- // ruling) — the client pays when they sign, even before the provider
224
- // has countersigned. This does NOT mark the document executed.
225
- const allSigned = parties.every((p) => p.signedAt);
226
- const clientSigned = parties[idx].key === "client";
227
- const executed = allSigned;
228
- const firePayment = clientSigned;
229
- // Tamper-evident seal: at completion, hash the executed document +
230
- // signed roster and store it alongside the parties. The verification
231
- // surface re-hashes later to prove the document was not altered.
232
- let evidence: Record<string, unknown> | undefined;
233
- if (executed && agreement.page_id) {
234
- try {
235
- const doc = await context.prisma.builda_pages.findFirst({
236
- where: { id: agreement.page_id },
237
- select: { puck_data: true },
238
- });
239
- evidence = {
240
- documentHash: computeDocumentHash(doc?.puck_data ?? null, parties),
241
- hashAlgo: "sha256",
242
- hashedAt: now,
243
- };
244
- } catch (e) {
245
- // A hashing failure must never block the signature itself.
246
- log.warn(
247
- `[publicAgreement.sign] document-hash failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
248
- );
249
- }
250
- }
251
- const data: Record<string, unknown> = {
252
- signature_data: JSON.stringify(
253
- evidence ? { parties, evidence } : { parties },
254
- ),
255
- viewed_at: nowInstant,
256
- };
257
- if (executed) {
258
- data.signed_at = nowInstant;
259
- data.signed_by = agreement.contact_id ?? null;
260
- }
261
- const updated = await context.prisma.agreements.update({
262
- where: { id: agreementId },
263
- data,
264
- });
265
- // Completion delivery: when THIS signature is the one that fully
266
- // executes the agreement (it was not executed before this call),
267
- // email every party their executed copy + Certificate of Completion.
268
- // Fire-and-forget — the helper is best-effort and self-contained, so
269
- // a mail failure never blocks or undoes the signature.
270
- if (executed && !agreement.signed_at) {
271
- void sendAgreementCompletionEmails(
272
- agreementId,
273
- originFrom(context.headers),
274
- );
275
- }
276
- // Every captured identity → CRM: connect this signer to client_database.
277
- await connectSignerToCrm(
278
- context.prisma,
279
- agreement,
280
- email,
281
- parties[idx].name,
282
- input.company ?? parties[idx].company,
283
- );
284
- const fresh = parseParties(updated.signature_data) ?? parties;
285
- const payUrl = firePayment
286
- ? await invoicePayUrl(agreementId, originFrom(context.headers))
287
- : null;
288
- const result = {
289
- ok: true as const,
290
- role: parties[idx].key,
291
- signedAt: parties[idx].signedAt,
292
- allSigned: executed,
293
- parties: partySummary(fresh),
294
- payUrl,
295
- };
296
- // Self-resetting demo harness: an allow-listed demo agreement
297
- // auto-unsigns once it reaches payment, so the next person gets a
298
- // fresh form to test the UI. The current signer's response (above)
299
- // still shows their signature + payUrl; only the stored record is
300
- // reset. Real agreements are never in the allow-list, so they never
301
- // auto-reset. (config: demo-reset-agreements)
302
- if (firePayment) {
303
- await maybeResetDemoAgreement(
304
- context.prisma,
305
- agreementId,
306
- agreement.proposal_id ?? null,
307
- parties,
308
- );
309
- }
310
- return result;
311
- }
312
-
313
- // An agreement with no roster was never prepared for signing.
314
- // Refuse rather than invent a signer.
315
- return {
316
- ok: false as const,
317
- error: "This agreement has no signers recorded and cannot be signed.",
318
- };
319
- } catch (err) {
320
- const detail = err instanceof Error ? err.message : String(err);
321
- log.error(`[publicAgreement.sign] failed: ${detail}`);
322
- // A LOG LINE IS NOT A RECORD. Measured 2026-09-03: signing was broken
323
- // for every signer for hours -- first a 404 on the procedure, then a
324
- // Prisma 8 codec rejecting the timestamp write -- and error_logs held
325
- // ZERO rows the whole time. The page returned 200, the service was
326
- // running, the deploy was green, and the signer saw only "Could not
327
- // submit your signature.", which reads as transient and invites a
328
- // retry that fails identically. The failure was discovered by a human
329
- // being unable to sign, which is the same shape as
330
- // page-server.render.failed above and takes the same cure: emit a
331
- // durable event so a surface can answer "is signing failing" without
332
- // somebody attempting a signature.
333
- //
334
- // Fire-and-forget and last: telemetry may never change what the signer
335
- // is served, and this path is already handling a failure
336
- // (observability-is-passive). The agreement id and the signer role
337
- // make a recorded event lead to the specific failure.
338
- recordEvent({
339
- kind: "agreement.sign.failed",
340
- payload: {
341
- agreementId,
342
- error: detail.slice(0, 500),
343
- errorName: err instanceof Error ? err.name : "unknown",
344
- },
345
- });
346
- return { ok: false as const, error: "Could not submit your signature." };
347
- }
348
- }),
349
-
350
- // Public tamper-verification: re-hash the currently-stored document + roster
351
- // and compare to the sealed hash captured at completion. Returns only
352
- // public-safe data (names, roles, signed timestamps, hash match) — never
353
- // emails or IPs — so it can back a public "verify this document" page.
354
- verifyDocument: pub
355
- .input(v.object({ agreementId: v.string() }))
356
- .handler(async ({ input }) => {
357
- const agreementId = input.agreementId.trim();
358
- if (!agreementId) return { ok: false as const };
359
- // Public verification is intentionally unscoped: anyone holding the id
360
- // confirms authenticity and gets only public-safe data back. The caller
361
- // is unauthenticated (no access cookie), so context.prisma's RLS would
362
- // hide the row — use the raw registry prisma, the same bypass the gate's
363
- // signer-email resolver uses.
364
- const [agreement] = await queryRows<
365
- {
366
- id: string;
367
- name: string;
368
- signed_at: Date | null;
369
- signature_data: unknown;
370
- page_id: string | null;
371
- }
372
- >`
373
- SELECT id, name, signed_at, signature_data, page_id
374
- FROM agreements
375
- WHERE (id = ${agreementId} OR page_id = ${agreementId})
376
- LIMIT 1`;
377
- if (!agreement) return { ok: false as const };
378
- const parties = parseParties(agreement.signature_data);
379
- const evidence = parseEvidence(agreement.signature_data);
380
- const complete =
381
- !!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
382
- let recomputedHash: string | null = null;
383
- if (evidence?.documentHash && parties && agreement.page_id) {
384
- const [doc] = await queryRows<{ puck_data: unknown }>`
385
- SELECT puck_data FROM builda_pages WHERE id = ${agreement.page_id} LIMIT 1`;
386
- recomputedHash = computeDocumentHash(doc?.puck_data ?? null, parties);
387
- }
388
- return {
389
- ok: true as const,
390
- envelopeId: agreement.id,
391
- documentName: agreement.name,
392
- complete,
393
- signedAt: agreement.signed_at?.toISOString() ?? null,
394
- sealedHash: evidence?.documentHash ?? null,
395
- recomputedHash,
396
- valid:
397
- !!evidence?.documentHash && recomputedHash === evidence.documentHash,
398
- parties: parties
399
- ? parties.map((p) => ({
400
- role: p.key,
401
- name: p.name,
402
- signedAt: p.signedAt,
403
- }))
404
- : [],
405
- };
406
- }),
407
- };