@teamscala/service-boot 0.23.466 → 0.23.468

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.466",
9
+ "version": "0.23.468",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -11,9 +11,19 @@
11
11
  * Off-the-shelf path (building-applications): typed ORPC procedure, not a
12
12
  * hand-written Hono handler; writes go through context.prisma inside the
13
13
  * handler (orpc-is-the-only-api) so the audit trigger sees the ORPC caller.
14
- * Multi-party model: signature_data holds a parties roster (JSON); each party
15
- * is gated by its own email and the agreement completes only when every party
16
- * has signed; single-signer falls back to signer_email/signer_name.
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.
17
27
  *
18
28
  * This module holds the three procedures (verify/sign/verifyDocument); the
19
29
  * supporting machinery lives as siblings (agreement-signing-evidence,
@@ -114,19 +124,9 @@ export const publicAgreementRouter = {
114
124
  };
115
125
  }
116
126
 
117
- // Legacy single-signer path.
118
- const matched =
119
- String(agreement.signer_email ?? "")
120
- .trim()
121
- .toLowerCase() === email;
122
- if (!matched) return { matched: false as const };
123
- return {
124
- matched: true,
125
- name: agreement.name,
126
- signerName: agreement.signer_name,
127
- signedAt: agreement.signed_at,
128
- accessToken: signAgreementAccessToken(agreementId, email, Date.now()),
129
- };
127
+ // No roster means the agreement was never prepared for signing. It is
128
+ // not viewable, and it is certainly not signable.
129
+ return { matched: false as const };
130
130
  }),
131
131
 
132
132
  // Commit the authed party's signature. Returns { ok, parties } so the block
@@ -156,7 +156,29 @@ export const publicAgreementRouter = {
156
156
  });
157
157
  if (!agreement) return { ok: false as const, error: "Agreement not found" };
158
158
 
159
+ // TWO representations, deliberately, because they have two consumers.
160
+ //
161
+ // `now` is an ISO STRING and belongs in the parties roster, which is
162
+ // serialised to JSON in signature_data — a Temporal.Instant would
163
+ // stringify into that JSON as an object.
164
+ //
165
+ // `nowInstant` is what the timestamptz COLUMNS take. Under Prisma 8 the
166
+ // timestamptz codec ('pg/timestamptz-temporal@1') accepts a
167
+ // Temporal.Instant and NOTHING ELSE. Measured against the live service:
168
+ // a string gives "received a string", a Date gives "received a Date",
169
+ // and only Temporal.Instant writes. Passing `now` therefore threw on
170
+ // EVERY signature, and the handler's catch reported the generic "Could
171
+ // not submit your signature." -- so signing was impossible for every
172
+ // signer with nothing naming the cause.
173
+ //
174
+ // The cast is load-bearing and is NOT laziness: the generated Prisma
175
+ // types declare these fields `string | Date` and reject Instant, so the
176
+ // client's TYPES and its RUNTIME CODEC disagree. Following the types
177
+ // produces code that compiles and always throws, which is exactly how
178
+ // this shipped. The runtime is the authority here; the generated types
179
+ // are the defect, and they are the thing to fix upstream.
159
180
  const now = new Date().toISOString();
181
+ const nowInstant = Temporal.Now.instant() as unknown as Date;
160
182
 
161
183
  try {
162
184
  // Multi-party path: signature_data holds a parties roster.
@@ -229,10 +251,10 @@ export const publicAgreementRouter = {
229
251
  signature_data: JSON.stringify(
230
252
  evidence ? { parties, evidence } : { parties },
231
253
  ),
232
- viewed_at: now,
254
+ viewed_at: nowInstant,
233
255
  };
234
256
  if (executed) {
235
- data.signed_at = now;
257
+ data.signed_at = nowInstant;
236
258
  data.signed_by = agreement.contact_id ?? null;
237
259
  }
238
260
  const updated = await context.prisma.agreements.update({
@@ -287,47 +309,11 @@ export const publicAgreementRouter = {
287
309
  return result;
288
310
  }
289
311
 
290
- // Legacy single-signer path.
291
- const signerEmail = String(agreement.signer_email ?? "")
292
- .trim()
293
- .toLowerCase();
294
- if (!signerEmail || signerEmail !== email) {
295
- return {
296
- ok: false as const,
297
- error: "This email does not match the agreement's signer.",
298
- };
299
- }
300
- if (agreement.signed_at) {
301
- return {
302
- ok: true as const,
303
- alreadySigned: true,
304
- signedAt: agreement.signed_at,
305
- signerName: agreement.signer_name,
306
- };
307
- }
308
- const updated = await context.prisma.agreements.update({
309
- where: { id: agreementId },
310
- data: {
311
- signature_data: input.signatureData ?? agreement.signer_name ?? "",
312
- signed_at: now,
313
- viewed_at: now,
314
- signed_by: agreement.contact_id ?? null,
315
- ...(input.signerName ? { signer_name: input.signerName } : {}),
316
- },
317
- });
318
- // Every captured identity → CRM: connect this signer to client_database.
319
- await connectSignerToCrm(
320
- context.prisma,
321
- agreement,
322
- email,
323
- input.signerName ?? agreement.signer_name,
324
- input.company,
325
- );
312
+ // An agreement with no roster was never prepared for signing.
313
+ // Refuse rather than invent a signer.
326
314
  return {
327
- ok: true as const,
328
- signedAt: updated.signed_at,
329
- signerName: updated.signer_name,
330
- payUrl: await invoicePayUrl(agreementId, originFrom(context.headers)),
315
+ ok: false as const,
316
+ error: "This agreement has no signers recorded and cannot be signed.",
331
317
  };
332
318
  } catch (err) {
333
319
  log.error(
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * applySessionOrgBranding must never repaint a TENANT document in the
7
+ * VIEWER's brand.
8
+ *
9
+ * A public document belongs to the org that PUBLISHED it and must look
10
+ * identical to every reader. Personalising to the reader's org is right for a
11
+ * portal (you are looking at your own workspace) and wrong for a document.
12
+ *
13
+ * The regression this locks: sign.scala.business's website is owned by the
14
+ * PLATFORM org, so the website-level test passed and the session override
15
+ * fired on agreements owned by tenants. Every signed-in staff viewer saw a
16
+ * GMG APAC agreement in their own org's brand while an anonymous client saw
17
+ * it correctly — so the only people who could not see what the client sees
18
+ * were the people reviewing it before sending.
19
+ */
20
+
21
+ import { describe, expect, mock, test } from "bun:test";
22
+
23
+ const PLATFORM_ORG = "c503629a-85d9-4d25-9431-f6fa59504868";
24
+ const TENANT_ORG = "5b742740-0f37-494c-a059-d8399ae4b350";
25
+ const VIEWER_ORG = PLATFORM_ORG;
26
+
27
+ mock.module("@teamscala/db/registry/platform", () => ({
28
+ getPlatformOrgId: () => PLATFORM_ORG,
29
+ }));
30
+
31
+ // The override's only way to change anything is to fetch the viewer org's
32
+ // branding. If it is never called, no personalisation happened.
33
+ const fetchBranding = mock(async () => ({
34
+ company_name: "Viewer Org",
35
+ company_friendly_name: "Viewer Org",
36
+ brand_primary_color: "#000080",
37
+ brand_heading_font: null,
38
+ brand_body_font: null,
39
+ company_logo_for_light_theme: "/viewer-light.png",
40
+ company_logo_for_dark_theme: "/viewer-dark.png",
41
+ }));
42
+ mock.module("@teamscala/db/fetch-organisation-page-branding", () => ({
43
+ fetchOrganisationPageBranding: fetchBranding,
44
+ }));
45
+ mock.module("../page-server-orpc/server-client.ts", () => ({
46
+ getServerClient: () => ({}),
47
+ }));
48
+
49
+ const { applySessionOrgBranding } = await import("./apply-page-branding.ts");
50
+
51
+ function pageOwnedBy(pageOrgId: string, websiteOrgId: string) {
52
+ return {
53
+ orgId: pageOrgId,
54
+ website: { organisation_id: websiteOrgId },
55
+ brand: { companyName: "Publisher", primaryColor: "#FF4500" },
56
+ } as Record<string, unknown>;
57
+ }
58
+
59
+ describe("applySessionOrgBranding — a document is never repainted for its reader", () => {
60
+ test("a TENANT document on a PLATFORM-owned website keeps the publisher's brand", async () => {
61
+ fetchBranding.mockClear();
62
+ // Exactly the sign.scala.business shape: the website is the platform's,
63
+ // the agreement belongs to the tenant, the viewer is platform staff.
64
+ const pageData = pageOwnedBy(TENANT_ORG, PLATFORM_ORG);
65
+
66
+ await applySessionOrgBranding(pageData, undefined, VIEWER_ORG);
67
+
68
+ expect(fetchBranding).not.toHaveBeenCalled();
69
+ expect(pageData.orgId).toBe(TENANT_ORG);
70
+ expect((pageData.brand as { primaryColor: string }).primaryColor).toBe(
71
+ "#FF4500",
72
+ );
73
+ });
74
+
75
+ test("a TENANT-owned website is untouched too", async () => {
76
+ fetchBranding.mockClear();
77
+ const pageData = pageOwnedBy(TENANT_ORG, TENANT_ORG);
78
+
79
+ await applySessionOrgBranding(pageData, undefined, VIEWER_ORG);
80
+
81
+ expect(fetchBranding).not.toHaveBeenCalled();
82
+ expect(pageData.orgId).toBe(TENANT_ORG);
83
+ });
84
+
85
+ test("an anonymous reader is never personalised", async () => {
86
+ fetchBranding.mockClear();
87
+ const pageData = pageOwnedBy(TENANT_ORG, PLATFORM_ORG);
88
+
89
+ await applySessionOrgBranding(pageData, undefined, undefined);
90
+
91
+ expect(fetchBranding).not.toHaveBeenCalled();
92
+ expect(pageData.orgId).toBe(TENANT_ORG);
93
+ });
94
+
95
+ test("a public render mode is never personalised", async () => {
96
+ fetchBranding.mockClear();
97
+ const pageData = pageOwnedBy(TENANT_ORG, PLATFORM_ORG);
98
+
99
+ await applySessionOrgBranding(pageData, "public", VIEWER_ORG);
100
+
101
+ expect(fetchBranding).not.toHaveBeenCalled();
102
+ });
103
+
104
+ test("a genuine PLATFORM page still personalises to the viewer's org", async () => {
105
+ // The behaviour the override exists for: a shared platform portal page,
106
+ // viewed by a member of another org, shows that member their own brand.
107
+ fetchBranding.mockClear();
108
+ const pageData = pageOwnedBy(PLATFORM_ORG, PLATFORM_ORG);
109
+
110
+ await applySessionOrgBranding(pageData, undefined, TENANT_ORG);
111
+
112
+ expect(fetchBranding).toHaveBeenCalledTimes(1);
113
+ expect(pageData.orgId).toBe(TENANT_ORG);
114
+ expect((pageData.brand as { companyName: string }).companyName).toBe(
115
+ "Viewer Org",
116
+ );
117
+ });
118
+ });
@@ -135,6 +135,7 @@ export async function applySessionOrgBranding(
135
135
  renderMode: string | undefined,
136
136
  sessionOrgId: string | undefined,
137
137
  ): Promise<void> {
138
+ const platformOrgId = getPlatformOrgId();
138
139
  if (
139
140
  renderMode !== "public" &&
140
141
  sessionOrgId &&
@@ -144,9 +145,27 @@ export async function applySessionOrgBranding(
144
145
  // (super-admin org). A TENANT-owned site (a customer GPT/quiz/portal)
145
146
  // keeps its page-org brand regardless of the viewer session org. The
146
147
  // old pageData.orgId===website.org mis-fired for a tenant whose page org equals their own website org.
147
- getPlatformOrgId() ===
148
+ platformOrgId ===
148
149
  ((pageData.website as { organisation_id?: string | null } | undefined)
149
- ?.organisation_id ?? undefined)
150
+ ?.organisation_id ?? undefined) &&
151
+ // ...AND the PAGE itself must be platform-owned. A platform-owned
152
+ // WEBSITE also hosts tenant DOCUMENTS — an agreement, a proposal, a
153
+ // signed contract — and those belong to the org that published them.
154
+ // Testing only the website let the viewer's own org repaint a tenant
155
+ // document, which is the same defect this rule already fixed once at
156
+ // the website level (2026-08-17, the Launcha Authority Blueprint).
157
+ //
158
+ // It recurred on sign.scala.business, whose website IS owned by the
159
+ // platform org (c503629a), so the website test passed and every
160
+ // signed-in staff viewer saw a GMG APAC agreement painted in their own
161
+ // org's brand — while an anonymous client saw it correctly. That is
162
+ // the worst possible shape: the only people who cannot see what the
163
+ // client sees are the people reviewing it before it is sent.
164
+ //
165
+ // A public document belongs to the org that PUBLISHED it and must look
166
+ // identical to every reader, so a page whose org is not the platform's
167
+ // is never personalised.
168
+ platformOrgId === (pageData.orgId as string | undefined)
150
169
  ) {
151
170
  try {
152
171
  const branding = await fetchOrganisationPageBranding(