@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
|
@@ -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
|
-
*
|
|
15
|
-
* is gated by its own email and the
|
|
16
|
-
* has signed
|
|
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
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
254
|
+
viewed_at: nowInstant,
|
|
233
255
|
};
|
|
234
256
|
if (executed) {
|
|
235
|
-
data.signed_at =
|
|
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
|
-
//
|
|
291
|
-
|
|
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:
|
|
328
|
-
|
|
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
|
-
|
|
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(
|