@teamscala/service-boot 0.23.414 → 0.23.415
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 +49 -1
- package/src/agreement-gate/agreement-lookup.ts +127 -0
- package/src/agreement-gate/authorized-emails.ts +85 -0
- package/src/agreement-gate/placeholder-strip.ts +42 -0
- package/src/agreement-gate/settings.ts +59 -0
- package/src/agreement-gate/signing-session.ts +254 -0
- package/src/agreement-gate/signing-shell.ts +201 -0
- package/src/agreement-gate.ts +26 -686
package/src/agreement-gate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The AGREEMENT content gate, expressed as config for the content-gate primitive
|
|
@@ -13,621 +13,42 @@
|
|
|
13
13
|
* The signing logic (buildSigningSession, seedSigningBlocks, roster expansion,
|
|
14
14
|
* placeholder strip) is preserved verbatim from the former gate-agreement-
|
|
15
15
|
* content.ts — only the cookie/token orchestration moved into the shared engine.
|
|
16
|
+
*
|
|
17
|
+
* This module holds the two exported configs (agreementGateConfig +
|
|
18
|
+
* agreementAccessConfig). The machinery lives as siblings under
|
|
19
|
+
* ./agreement-gate/ (settings, agreement-lookup, signing-session,
|
|
20
|
+
* placeholder-strip, signing-shell, authorized-emails) — split when the
|
|
21
|
+
* parent held them all at 909 lines.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
19
|
-
import { queryRows } from "@teamscala/db/query-rows";
|
|
20
|
-
import { createCache } from "@teamscala/cache/create-cache";
|
|
21
|
-
import { createNotifyListener, getDirectDatabaseUrl } from "@teamscala/db/notify-listener/index";
|
|
22
24
|
import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
|
|
23
25
|
import {
|
|
24
26
|
signAgreementAccessToken,
|
|
25
27
|
verifyAgreementAccessToken,
|
|
26
28
|
} from "@teamscala/document-signing/agreement-access-token";
|
|
27
|
-
import {
|
|
28
|
-
type PublicAgreementParty,
|
|
29
|
-
parseAgreementParties,
|
|
30
|
-
toPublicParties,
|
|
31
|
-
} from "@teamscala/document-signing/agreement-parties";
|
|
32
29
|
import { expandRosterTemplates } from "./expand-roster.ts";
|
|
33
30
|
import { injectResourceChatLauncher } from "./resource-chat-inject.ts";
|
|
34
31
|
import { agTtlMs } from "./gate-ttls.ts";
|
|
32
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
33
|
+
import { AG_COOKIE, AGREEMENT_GATE_DEFAULTS, loadAgreementGateSettings } from "./agreement-gate/settings.ts";
|
|
34
|
+
import { findFirstAgreement, pageAgreementCache } from "./agreement-gate/agreement-lookup.ts";
|
|
35
35
|
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return client.agreements.findFirst(input);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Agreement gate display + behaviour, DB-driven (config/content-gate-agreement),
|
|
57
|
-
* never hardcoded — mirrors proposal-gate's loadProposalGateSettings. requireOtp
|
|
58
|
-
* is the per-gate OTP switch (agreements default ON: a signer verifies identity
|
|
59
|
-
* before signing). title is the fallback used when no agreement name resolves. A
|
|
60
|
-
* missing/invalid row falls back to these defaults so the gate UI ALWAYS renders.
|
|
61
|
-
*/
|
|
62
|
-
interface AgreementGateSettings {
|
|
63
|
-
title: string;
|
|
64
|
-
subtitle: string;
|
|
65
|
-
submitLabel: string;
|
|
66
|
-
requireOtp: boolean;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const AGREEMENT_GATE_DEFAULTS: AgreementGateSettings = {
|
|
70
|
-
title: "Private document",
|
|
71
|
-
subtitle: "Verify your email to view and sign this document.",
|
|
72
|
-
submitLabel: "Access document",
|
|
73
|
-
requireOtp: true,
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const AgreementGateSettingsSchema = v.object({
|
|
77
|
-
title: v.optional(v.string(), AGREEMENT_GATE_DEFAULTS.title),
|
|
78
|
-
subtitle: v.optional(v.string(), AGREEMENT_GATE_DEFAULTS.subtitle),
|
|
79
|
-
submitLabel: v.optional(v.string(), AGREEMENT_GATE_DEFAULTS.submitLabel),
|
|
80
|
-
requireOtp: v.optional(v.boolean(), AGREEMENT_GATE_DEFAULTS.requireOtp),
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
/** Gate copy/behaviour, or null when the config row is absent/broken. Null is
|
|
84
|
-
* the absent signal — callers default explicitly so a missing row neither 500s
|
|
85
|
-
* the page nor masks as a populated value (protective-layers-fail-explicitly). */
|
|
86
|
-
async function loadAgreementGateSettings(): Promise<AgreementGateSettings | null> {
|
|
87
|
-
try {
|
|
88
|
-
return await loadRegistryConfig(
|
|
89
|
-
"config",
|
|
90
|
-
"content-gate-agreement",
|
|
91
|
-
AgreementGateSettingsSchema,
|
|
92
|
-
);
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Cache for the page→agreement association (new-flow pages that have no
|
|
100
|
-
* pre-placed gate block). The association changes rarely (only when a new
|
|
101
|
-
* agreement is created or a page is rebound), so a 30s TTL avoids a DB query
|
|
102
|
-
* per request while staying fresh enough for just-created agreements to appear.
|
|
103
|
-
* Negative results are cached too (id="") so non-agreement pages never re-query.
|
|
104
|
-
*/
|
|
105
|
-
const pageAgreementCache = createCache<{ id: string; name: string }>(
|
|
106
|
-
"agreement-gate:page-association",
|
|
107
|
-
{ maxSize: 256, ttlMs: 30_000 },
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Cache for the owning org's member directory (org id → lowercased email → name),
|
|
112
|
-
* used to grant VIEW-ONLY access to an agreement to any member of the org that
|
|
113
|
-
* owns it (signing stays roster-bound). Membership changes on human timescales,
|
|
114
|
-
* so a 30s TTL keeps the per-request authorize cheap (hot-path-resolutions-are-cached).
|
|
115
|
-
*/
|
|
116
|
-
const orgMemberCache = createCache<Map<string, string>>(
|
|
117
|
-
"agreement-gate:org-members",
|
|
118
|
-
{ maxSize: 256, ttlMs: 30_000 },
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* The owning org's member directory: lowercased email → display name, for every
|
|
123
|
-
* member of `organisationId`. Empty when the org is unknown or has no members.
|
|
124
|
-
*
|
|
125
|
-
* Resolved via a parameterized raw join over better_auth_member + "user" rather
|
|
126
|
-
* rather than a service-local Prisma `member` model: `member` is a shared
|
|
127
|
-
* @@map'd Prisma model, and each
|
|
128
|
-
* service's per-service client is generated by live DB introspection from only
|
|
129
|
-
* the tables its service_schema declares — so that model is undefined
|
|
130
|
-
* on a service (scala-sign, scala-proposals) that doesn't list better_auth_member.
|
|
131
|
-
* A read-only raw query needs no model declaration and works on every client,
|
|
132
|
-
* which is what makes org-member VIEW access actually resolve (the typed access
|
|
133
|
-
* silently crashed → caught → empty directory → org members wrongly challenged).
|
|
134
|
-
*/
|
|
135
|
-
async function resolveOrgMemberDirectory(
|
|
136
|
-
organisationId: string | null,
|
|
137
|
-
): Promise<Map<string, string>> {
|
|
138
|
-
if (!organisationId) return new Map();
|
|
139
|
-
const cached = orgMemberCache.get(organisationId);
|
|
140
|
-
if (cached !== undefined) return cached;
|
|
141
|
-
const directory = new Map<string, string>();
|
|
142
|
-
try {
|
|
143
|
-
const rows = await queryRows`
|
|
144
|
-
SELECT u.email AS email, u.name AS name
|
|
145
|
-
FROM better_auth_member m
|
|
146
|
-
JOIN "user" u ON u.id = m.user_id
|
|
147
|
-
WHERE m.organization_id = ${organisationId}
|
|
148
|
-
` as
|
|
149
|
-
Array<{ email: string | null; name: string | null }>
|
|
150
|
-
;
|
|
151
|
-
for (const r of rows) {
|
|
152
|
-
const email = typeof r.email === "string" ? r.email.trim().toLowerCase() : "";
|
|
153
|
-
if (email) directory.set(email, String(r.name ?? r.email));
|
|
154
|
-
}
|
|
155
|
-
orgMemberCache.set(organisationId, directory);
|
|
156
|
-
return directory;
|
|
157
|
-
} catch (err) {
|
|
158
|
-
getAppLogger().error(
|
|
159
|
-
`[agreement-gate] org-member resolve failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
160
|
-
);
|
|
161
|
-
return directory;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* The combined member directory for EVERY org an agreement's parties represent
|
|
167
|
-
* (each party's `organisationId`) plus the agreement's owning org. Any of these
|
|
168
|
-
* members may VIEW the agreement; signing stays restricted to the designated
|
|
169
|
-
* signer (the party email). Usually 1 org (one of our orgs + an external party),
|
|
170
|
-
* so the loop is over a 1-2 element set, each entry cached.
|
|
171
|
-
*/
|
|
172
|
-
async function resolveAgreementViewerDirectory(
|
|
173
|
-
orgIds: Iterable<string | null | undefined>,
|
|
174
|
-
): Promise<Map<string, string>> {
|
|
175
|
-
const out = new Map<string, string>();
|
|
176
|
-
const unique = new Set(
|
|
177
|
-
[...orgIds].filter((id): id is string => typeof id === "string" && id.length > 0),
|
|
178
|
-
);
|
|
179
|
-
for (const orgId of unique) {
|
|
180
|
-
const dir = await resolveOrgMemberDirectory(orgId);
|
|
181
|
-
for (const [email, name] of dir) if (!out.has(email)) out.set(email, name);
|
|
182
|
-
}
|
|
183
|
-
return out;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
interface ContentBlock {
|
|
187
|
-
type?: string;
|
|
188
|
-
props?: Record<string, unknown>;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
interface SigningSession {
|
|
192
|
-
authedRole: string;
|
|
193
|
-
authedName: string;
|
|
194
|
-
authedEmail: string;
|
|
195
|
-
agreementUuid: string;
|
|
196
|
-
parties: PublicAgreementParty[];
|
|
197
|
-
agreementName: string;
|
|
198
|
-
agreementRef: string;
|
|
199
|
-
completedAt: string | null;
|
|
200
|
-
orgName: string | null;
|
|
201
|
-
organisationId: string | null;
|
|
202
|
-
/** Certificate of Completion data (built for a party signer via
|
|
203
|
-
* buildCertificateData); null for view-only recipients. Seeded into the
|
|
204
|
-
* SignatureField so the certificate renders inline, immediately. */
|
|
205
|
-
certificate: CertificateData | null;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Every resolved region's content[] array, paired with its puck_data holder. */
|
|
209
|
-
function* regionContents(
|
|
210
|
-
resolved: Record<string, unknown>,
|
|
211
|
-
): Generator<{ pd: { content?: unknown[] }; content: ContentBlock[] }> {
|
|
212
|
-
for (const value of Object.values(resolved)) {
|
|
213
|
-
const pd = (value as { puck_data?: { content?: unknown[] } } | null)?.puck_data;
|
|
214
|
-
const content = pd?.content;
|
|
215
|
-
if (pd && Array.isArray(content)) {
|
|
216
|
-
yield { pd, content: content as ContentBlock[] };
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Locate the agreement gate block(s) across regions. A gate is the SignIn block
|
|
223
|
-
* in `gate` mode whose field is NOT "proposalId" (that field marks a proposal
|
|
224
|
-
* gate); the agreement id lives in `gateContextValue`.
|
|
225
|
-
*/
|
|
226
|
-
function findAgreementGate(resolved: Record<string, unknown>): {
|
|
227
|
-
agreementUuid: string;
|
|
228
|
-
gateRegions: { pd: { content?: unknown[] }; content: ContentBlock[]; gateIdx: number }[];
|
|
229
|
-
} {
|
|
230
|
-
let agreementUuid = "";
|
|
231
|
-
const gateRegions: {
|
|
232
|
-
pd: { content?: unknown[] };
|
|
233
|
-
content: ContentBlock[];
|
|
234
|
-
gateIdx: number;
|
|
235
|
-
}[] = [];
|
|
236
|
-
for (const { pd, content } of regionContents(resolved)) {
|
|
237
|
-
const gateIdx = content.findIndex(
|
|
238
|
-
(b) =>
|
|
239
|
-
b?.type === "SignIn" &&
|
|
240
|
-
b?.props?.mode === "gate" &&
|
|
241
|
-
b?.props?.gateContextField !== "proposalId",
|
|
242
|
-
);
|
|
243
|
-
if (gateIdx < 0) continue;
|
|
244
|
-
const uuid = content[gateIdx].props?.gateContextValue;
|
|
245
|
-
if (typeof uuid === "string" && uuid) agreementUuid = uuid;
|
|
246
|
-
gateRegions.push({ pd, content, gateIdx });
|
|
247
|
-
}
|
|
248
|
-
return { agreementUuid, gateRegions };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Build the verified signer's session from the agreement's parties roster. */
|
|
252
|
-
async function buildSigningSession(
|
|
253
|
-
agreementUuid: string,
|
|
254
|
-
verifiedEmail: string,
|
|
255
|
-
): Promise<SigningSession | null> {
|
|
256
|
-
const log = getAppLogger();
|
|
257
|
-
const lower = verifiedEmail.trim().toLowerCase();
|
|
258
|
-
try {
|
|
259
|
-
const agreement = await findFirstAgreement<{
|
|
260
|
-
name: string | null;
|
|
261
|
-
signer_name: string | null;
|
|
262
|
-
signed_at: Date | string | null;
|
|
263
|
-
signature_data: unknown;
|
|
264
|
-
organisation_id: string | null;
|
|
265
|
-
}>({
|
|
266
|
-
where: { id: agreementUuid },
|
|
267
|
-
select: {
|
|
268
|
-
name: true,
|
|
269
|
-
signer_name: true,
|
|
270
|
-
signed_at: true,
|
|
271
|
-
signature_data: true,
|
|
272
|
-
organisation_id: true,
|
|
273
|
-
},
|
|
274
|
-
});
|
|
275
|
-
if (!agreement) return null;
|
|
276
|
-
|
|
277
|
-
const meta = {
|
|
278
|
-
agreementName: String(agreement.name ?? "Agreement"),
|
|
279
|
-
agreementRef: agreementUuid.split("-")[0] ?? agreementUuid,
|
|
280
|
-
completedAt: agreement.signed_at
|
|
281
|
-
? new Date(agreement.signed_at as unknown as string).toISOString()
|
|
282
|
-
: null,
|
|
283
|
-
orgName: null as string | null,
|
|
284
|
-
organisationId:
|
|
285
|
-
typeof agreement.organisation_id === "string"
|
|
286
|
-
? agreement.organisation_id
|
|
287
|
-
: null,
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
const parties = parseAgreementParties(agreement.signature_data);
|
|
291
|
-
if (parties) {
|
|
292
|
-
const me = parties.find((p) => p.email.trim().toLowerCase() === lower);
|
|
293
|
-
if (me) {
|
|
294
|
-
// Only a "signer" recipient gets a signing session (authedRole = its
|
|
295
|
-
// party key, which the SignatureField matches to render its sign slot).
|
|
296
|
-
// A named "viewer"/"cc" recipient — like an org member below — is
|
|
297
|
-
// view-only: authedRole "viewer" matches no party key, so no slot.
|
|
298
|
-
// Certificate data is built only for a party signer (same email-must-
|
|
299
|
-
// be-a-party gate) and seeded for inline
|
|
300
|
-
// render; a view-only recipient gets null (and the cell never shows it).
|
|
301
|
-
const isSigner = me.recipientRole === "signer";
|
|
302
|
-
const cert = isSigner
|
|
303
|
-
? await buildCertificateData(agreementUuid, verifiedEmail)
|
|
304
|
-
: null;
|
|
305
|
-
return {
|
|
306
|
-
authedRole: isSigner ? me.key : "viewer",
|
|
307
|
-
authedName: me.name,
|
|
308
|
-
authedEmail: verifiedEmail,
|
|
309
|
-
agreementUuid,
|
|
310
|
-
parties: toPublicParties(parties),
|
|
311
|
-
certificate: cert && cert.ok ? cert : null,
|
|
312
|
-
...meta,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
// Not a roster party — admit any member of an org this agreement's parties
|
|
316
|
-
// represent (each party's organisationId ∪ the owning org) as a VIEW-ONLY
|
|
317
|
-
// viewer. authedRole "viewer" matches no party key, so every SignatureField
|
|
318
|
-
// renders read-only "Awaiting <name>" — no signature slot. Only the
|
|
319
|
-
// designated signer (the party email matched above) ever signs.
|
|
320
|
-
const directory = await resolveAgreementViewerDirectory([
|
|
321
|
-
meta.organisationId,
|
|
322
|
-
...parties.map((p) => p.organisationId),
|
|
323
|
-
]);
|
|
324
|
-
const viewerName = directory.get(lower);
|
|
325
|
-
if (viewerName !== undefined) {
|
|
326
|
-
return {
|
|
327
|
-
authedRole: "viewer",
|
|
328
|
-
authedName: viewerName,
|
|
329
|
-
authedEmail: verifiedEmail,
|
|
330
|
-
agreementUuid,
|
|
331
|
-
parties: toPublicParties(parties),
|
|
332
|
-
certificate: null,
|
|
333
|
-
...meta,
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const name = String(agreement.signer_name ?? "");
|
|
340
|
-
const cert = await buildCertificateData(agreementUuid, verifiedEmail);
|
|
341
|
-
return {
|
|
342
|
-
authedRole: "signer",
|
|
343
|
-
authedName: name,
|
|
344
|
-
authedEmail: verifiedEmail,
|
|
345
|
-
agreementUuid,
|
|
346
|
-
parties: [
|
|
347
|
-
{
|
|
348
|
-
key: "signer",
|
|
349
|
-
role: name || "Signer",
|
|
350
|
-
recipientRole: "signer",
|
|
351
|
-
name,
|
|
352
|
-
title: null,
|
|
353
|
-
company: null,
|
|
354
|
-
signedAt: null,
|
|
355
|
-
signature: null,
|
|
356
|
-
},
|
|
357
|
-
],
|
|
358
|
-
certificate: cert && cert.ok ? cert : null,
|
|
359
|
-
...meta,
|
|
360
|
-
};
|
|
361
|
-
} catch (err) {
|
|
362
|
-
log.error(
|
|
363
|
-
`[agreement-gate] session build failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
364
|
-
);
|
|
365
|
-
return null;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const SIGNING_BLOCK_TYPES = new Set(["SignatureField"]);
|
|
370
|
-
|
|
371
|
-
/** Seed the verified session into every signing block across all regions. */
|
|
372
|
-
function seedSigningBlocks(
|
|
373
|
-
resolved: Record<string, unknown>,
|
|
374
|
-
session: SigningSession,
|
|
375
|
-
): void {
|
|
376
|
-
const seed = {
|
|
377
|
-
authed: true,
|
|
378
|
-
authedRole: session.authedRole,
|
|
379
|
-
authedName: session.authedName,
|
|
380
|
-
authedEmail: session.authedEmail,
|
|
381
|
-
agreementUuid: session.agreementUuid,
|
|
382
|
-
parties: session.parties,
|
|
383
|
-
agreementName: session.agreementName,
|
|
384
|
-
agreementRef: session.agreementRef,
|
|
385
|
-
completedAt: session.completedAt,
|
|
386
|
-
orgName: session.orgName,
|
|
387
|
-
certificate: session.certificate,
|
|
388
|
-
};
|
|
389
|
-
const seedBlock = (block: ContentBlock): void => {
|
|
390
|
-
if (block?.type && SIGNING_BLOCK_TYPES.has(block.type)) {
|
|
391
|
-
block.props = { ...(block.props ?? {}), ...seed };
|
|
392
|
-
}
|
|
393
|
-
const props = block?.props;
|
|
394
|
-
if (!props) return;
|
|
395
|
-
for (const propVal of Object.values(props)) {
|
|
396
|
-
if (!Array.isArray(propVal)) continue;
|
|
397
|
-
for (const child of propVal) {
|
|
398
|
-
if (child && typeof child === "object") seedBlock(child as ContentBlock);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
};
|
|
402
|
-
for (const { content } of regionContents(resolved)) {
|
|
403
|
-
for (const block of content) seedBlock(block);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/** True if any of the block's OWN string props carries an {{agreement.*}} token. */
|
|
408
|
-
function blockHasAgreementPlaceholder(block: ContentBlock): boolean {
|
|
409
|
-
const props = block.props;
|
|
410
|
-
if (!props) return false;
|
|
411
|
-
for (const propVal of Object.values(props)) {
|
|
412
|
-
if (typeof propVal === "string" && /\{\{\s*agreement\./.test(propVal)) return true;
|
|
413
|
-
}
|
|
414
|
-
return false;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/** Drop blocks whose own text still holds an {{agreement.*}} token, recursively. */
|
|
418
|
-
function stripAgreementPlaceholderBlocks(content: ContentBlock[]): ContentBlock[] {
|
|
419
|
-
const out: ContentBlock[] = [];
|
|
420
|
-
for (const block of content) {
|
|
421
|
-
if (blockHasAgreementPlaceholder(block)) continue;
|
|
422
|
-
const props = block.props;
|
|
423
|
-
if (props) {
|
|
424
|
-
for (const [k, propVal] of Object.entries(props)) {
|
|
425
|
-
if (Array.isArray(propVal)) {
|
|
426
|
-
props[k] = stripAgreementPlaceholderBlocks(propVal as ContentBlock[]);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
out.push(block);
|
|
431
|
-
}
|
|
432
|
-
return out;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** The org's display name (for the footer audit line), from the resolved brand. */
|
|
436
|
-
function brandOrgName(resolved: Record<string, unknown>): string | null {
|
|
437
|
-
const brand = resolved.brand as
|
|
438
|
-
| { companyFriendlyName?: unknown; companyName?: unknown }
|
|
439
|
-
| undefined;
|
|
440
|
-
const name = brand?.companyFriendlyName ?? brand?.companyName;
|
|
441
|
-
return typeof name === "string" && name ? name : null;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ─── New-flow signing-shell injection (no pre-placed gate required) ──────────
|
|
445
|
-
// When a Puck page has no pre-placed SignIn gate block, the machinery resolves
|
|
446
|
-
// the agreement from the page→agreement association and injects the full
|
|
447
|
-
// signing shell (gate when unauthed; title + execution + signature panel +
|
|
448
|
-
// print + audit when authed). The Puck page holds ONLY document content.
|
|
449
|
-
|
|
450
|
-
/** Agreement metadata stashed on resolved during getResourceId. */
|
|
451
|
-
const AGREEMENT_META_KEY = "_agreementMeta";
|
|
452
|
-
|
|
453
|
-
function stashAgreementMeta(
|
|
454
|
-
resolved: Record<string, unknown>,
|
|
455
|
-
id: string,
|
|
456
|
-
name: string,
|
|
457
|
-
): void {
|
|
458
|
-
resolved[AGREEMENT_META_KEY] = { id, name };
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* The body content array for the agreement. The public shell composes the body
|
|
463
|
-
* region FROM resolved.page (register-registry-page-route.ts Branch 2:
|
|
464
|
-
* `bodyPuck = extractPuckData(resolved.page)`, returned un-cloned), so this IS
|
|
465
|
-
* the live array whose mutation reaches the SSR render. Header/footer are
|
|
466
|
-
* separate region sources (resolved.header / resolved.footer) — never the body.
|
|
467
|
-
*
|
|
468
|
-
* A prior version scored every region by top-level non-shell block count and
|
|
469
|
-
* picked the highest. That misroutes when the body wraps its clauses inside a
|
|
470
|
-
* Container (body's top-level count is ~2 while its nested clause count is ~40):
|
|
471
|
-
* a /header region with more top-level blocks out-scores the body, so
|
|
472
|
-
* onUnauthorized cleared the HEADER and injected the gate there, leaving the
|
|
473
|
-
* entire agreement body visible past the gate. The body is the resource page —
|
|
474
|
-
* identified, not heuristically guessed.
|
|
475
|
-
*/
|
|
476
|
-
function getBodyContentArray(
|
|
477
|
-
resolved: Record<string, unknown>,
|
|
478
|
-
): ContentBlock[] | null {
|
|
479
|
-
const pd = (
|
|
480
|
-
resolved.page as { puck_data?: { content?: unknown[] } } | undefined
|
|
481
|
-
)?.puck_data;
|
|
482
|
-
if (!pd || !Array.isArray(pd.content)) return null;
|
|
483
|
-
return pd.content as ContentBlock[];
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** Construct the SignIn gate block for injection. */
|
|
487
|
-
function buildGateBlock(
|
|
488
|
-
agreementUuid: string,
|
|
489
|
-
title: string,
|
|
490
|
-
settings: AgreementGateSettings,
|
|
491
|
-
): ContentBlock {
|
|
492
|
-
return {
|
|
493
|
-
type: "SignIn",
|
|
494
|
-
props: {
|
|
495
|
-
id: `agreement-gate-${agreementUuid}`,
|
|
496
|
-
mode: "gate",
|
|
497
|
-
title: title || settings.title,
|
|
498
|
-
subtitle: settings.subtitle,
|
|
499
|
-
gateAction: "/api/agreement-access",
|
|
500
|
-
showBackToHome: true,
|
|
501
|
-
gateContextField: "agreementId",
|
|
502
|
-
gateContextValue: agreementUuid,
|
|
503
|
-
gateSubmitLabel: settings.submitLabel,
|
|
504
|
-
// Require OTP is DB-driven (config/content-gate-agreement.requireOtp).
|
|
505
|
-
// String "true"/"false" (not boolean) to match the select-field enum the
|
|
506
|
-
// render validates against — a boolean is stripped before reaching the
|
|
507
|
-
// component (the same coercion that bit the pre-placed gate block).
|
|
508
|
-
gateRequireOtp: settings.requireOtp ? "true" : "false",
|
|
509
|
-
},
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Construct the signing shell blocks that wrap the body content when authed:
|
|
515
|
-
* [0] title ({{agreement.name}}), [1] execution clause, [2] signature panel
|
|
516
|
-
* (grid Container holding a per-party SignatureField template), [3] print
|
|
517
|
-
* button, [4] audit footer. The title is unshift'd before body; the rest are
|
|
518
|
-
* pushed after. expandRosterTemplates expands the SignatureField per party;
|
|
519
|
-
* seedSigningBlocks seeds each cell with the verified session.
|
|
520
|
-
*/
|
|
521
|
-
const AGREEMENT_SIGNING_SHELL_DEFAULT: ContentBlock[] = [
|
|
522
|
-
{
|
|
523
|
-
type: "Text",
|
|
524
|
-
props: {
|
|
525
|
-
id: "agreement-title",
|
|
526
|
-
text: "{{agreement.name}}",
|
|
527
|
-
size: "xl",
|
|
528
|
-
weight: "bold",
|
|
529
|
-
align: "left",
|
|
530
|
-
element: "h1",
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
{
|
|
534
|
-
type: "Text",
|
|
535
|
-
props: {
|
|
536
|
-
id: "agreement-execution",
|
|
537
|
-
text: "IN WITNESS WHEREOF, the parties have executed this Agreement as of the date(s) of signature below.",
|
|
538
|
-
size: "sm",
|
|
539
|
-
weight: "normal",
|
|
540
|
-
align: "left",
|
|
541
|
-
element: "p",
|
|
542
|
-
},
|
|
543
|
-
},
|
|
544
|
-
{
|
|
545
|
-
type: "Container",
|
|
546
|
-
props: {
|
|
547
|
-
id: "agreement-signature-panel",
|
|
548
|
-
layout: "grid",
|
|
549
|
-
columns: 2,
|
|
550
|
-
direction: "column",
|
|
551
|
-
gap: "md",
|
|
552
|
-
padding: "md",
|
|
553
|
-
mobileBehavior: "stack",
|
|
554
|
-
content: [
|
|
555
|
-
{
|
|
556
|
-
type: "SignatureField",
|
|
557
|
-
props: {
|
|
558
|
-
id: "agreement-signature-field",
|
|
559
|
-
label: "",
|
|
560
|
-
},
|
|
561
|
-
},
|
|
562
|
-
],
|
|
563
|
-
},
|
|
564
|
-
},
|
|
565
|
-
// Print button — preset:"action-primary" is the single styling knob: it
|
|
566
|
-
// resolves (button-presets config) to variant:primary → brand-coloured
|
|
567
|
-
// background. NO explicit `variant` prop: button-styles.ts does
|
|
568
|
-
// `variant ?? preset.variant`, so any explicit variant overrides the preset
|
|
569
|
-
// (a stray variant:"secondary" — not a real variant — fell back to a
|
|
570
|
-
// transparent ghost; that was the bare-text bug). icon:"lucide:printer"
|
|
571
|
-
// renders the printer glyph (baseStyle provides the icon↔label gap).
|
|
572
|
-
{
|
|
573
|
-
type: "Button",
|
|
574
|
-
props: {
|
|
575
|
-
id: "agreement-print",
|
|
576
|
-
label: "Print",
|
|
577
|
-
icon: "lucide:printer",
|
|
578
|
-
actionType: "print",
|
|
579
|
-
preset: "action-primary",
|
|
580
|
-
size: "default",
|
|
581
|
-
borderColor: "var(--border-color-default)",
|
|
582
|
-
borderWidth: "var(--border-width-default)",
|
|
583
|
-
borderRadius: "var(--radius-md)",
|
|
584
|
-
fullWidth: false,
|
|
585
|
-
pushRight: false,
|
|
586
|
-
href: "",
|
|
587
|
-
target: "_self",
|
|
588
|
-
},
|
|
589
|
-
},
|
|
590
|
-
{
|
|
591
|
-
type: "Text",
|
|
592
|
-
props: {
|
|
593
|
-
id: "agreement-audit",
|
|
594
|
-
text: "Secured electronic signature · {{agreement.org}}",
|
|
595
|
-
size: "xs",
|
|
596
|
-
align: "center",
|
|
597
|
-
element: "p",
|
|
598
|
-
},
|
|
599
|
-
},
|
|
600
|
-
];
|
|
601
|
-
|
|
602
|
-
const SigningShellSchema = v.object({ blocks: v.array(v.unknown()) });
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* The signing-shell block tree, DB-driven (config/agreement-signing-shell) so the
|
|
606
|
-
* clauses, labels, and structure are editable without a deploy. Deep-cloned per
|
|
607
|
-
* call because injection (expandRosterTemplates/seedSigningBlocks) mutates the
|
|
608
|
-
* tree. Falls back to AGREEMENT_SIGNING_SHELL_DEFAULT so signing ALWAYS renders.
|
|
609
|
-
*/
|
|
610
|
-
async function buildSigningShellBlocks(): Promise<ContentBlock[]> {
|
|
611
|
-
try {
|
|
612
|
-
const cfg = await loadRegistryConfig(
|
|
613
|
-
"config",
|
|
614
|
-
"agreement-signing-shell",
|
|
615
|
-
SigningShellSchema,
|
|
616
|
-
);
|
|
617
|
-
const blocks = cfg?.blocks as ContentBlock[] | undefined;
|
|
618
|
-
if (Array.isArray(blocks) && blocks.length > 0) {
|
|
619
|
-
return structuredClone(blocks);
|
|
620
|
-
}
|
|
621
|
-
} catch {
|
|
622
|
-
// fall through to the default so the signing shell ALWAYS renders
|
|
623
|
-
}
|
|
624
|
-
return structuredClone(AGREEMENT_SIGNING_SHELL_DEFAULT);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/** True if the page has a pre-placed SignIn gate block (old-flow page). */
|
|
628
|
-
function hasPrePlacedGate(resolved: Record<string, unknown>): boolean {
|
|
629
|
-
return findAgreementGate(resolved).agreementUuid !== "";
|
|
630
|
-
}
|
|
36
|
+
brandOrgName,
|
|
37
|
+
buildSigningSession,
|
|
38
|
+
findAgreementGate,
|
|
39
|
+
seedSigningBlocks,
|
|
40
|
+
type ContentBlock,
|
|
41
|
+
} from "./agreement-gate/signing-session.ts";
|
|
42
|
+
import { stripAgreementPlaceholderBlocks } from "./agreement-gate/placeholder-strip.ts";
|
|
43
|
+
import {
|
|
44
|
+
AGREEMENT_META_KEY,
|
|
45
|
+
buildGateBlock,
|
|
46
|
+
buildSigningShellBlocks,
|
|
47
|
+
getBodyContentArray,
|
|
48
|
+
hasPrePlacedGate,
|
|
49
|
+
stashAgreementMeta,
|
|
50
|
+
} from "./agreement-gate/signing-shell.ts";
|
|
51
|
+
import { resolveAgreementAuthorizedEmails } from "./agreement-gate/authorized-emails.ts";
|
|
631
52
|
|
|
632
53
|
export const agreementGateConfig: ContentGateConfig = {
|
|
633
54
|
cookieName: AG_COOKIE,
|
|
@@ -814,76 +235,6 @@ export const agreementGateConfig: ContentGateConfig = {
|
|
|
814
235
|
},
|
|
815
236
|
};
|
|
816
237
|
|
|
817
|
-
/** The agreement's authorized email set (its signer roster) for the access endpoint. */
|
|
818
|
-
async function resolveAgreementSignerEmails(
|
|
819
|
-
agreementId: string,
|
|
820
|
-
): Promise<Set<string>> {
|
|
821
|
-
const emails = new Set<string>();
|
|
822
|
-
try {
|
|
823
|
-
const agreement = await findFirstAgreement<{
|
|
824
|
-
signer_email: string | null;
|
|
825
|
-
signature_data: unknown;
|
|
826
|
-
}>({
|
|
827
|
-
where: { id: agreementId },
|
|
828
|
-
select: { signer_email: true, signature_data: true },
|
|
829
|
-
});
|
|
830
|
-
if (!agreement) return emails;
|
|
831
|
-
const parties = parseAgreementParties(agreement.signature_data);
|
|
832
|
-
if (parties) {
|
|
833
|
-
for (const p of parties) {
|
|
834
|
-
if (p.email) emails.add(p.email.trim().toLowerCase());
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
if (agreement.signer_email) {
|
|
838
|
-
emails.add(String(agreement.signer_email).trim().toLowerCase());
|
|
839
|
-
}
|
|
840
|
-
return emails;
|
|
841
|
-
} catch (err) {
|
|
842
|
-
getAppLogger().error(
|
|
843
|
-
`[agreement-gate] signer-email resolve failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
844
|
-
);
|
|
845
|
-
return emails;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
/**
|
|
850
|
-
* Every email allowed PAST the gate: the signer roster (who may sign) UNION every
|
|
851
|
-
* member of the owning org (who may VIEW). Signing authority stays roster-only —
|
|
852
|
-
* buildSigningSession gives a non-party org member a view-only session. This is
|
|
853
|
-
* what the access endpoint and session-authorize check.
|
|
854
|
-
*/
|
|
855
|
-
async function resolveAgreementAuthorizedEmails(
|
|
856
|
-
agreementId: string,
|
|
857
|
-
): Promise<Set<string>> {
|
|
858
|
-
const emails = await resolveAgreementSignerEmails(agreementId);
|
|
859
|
-
try {
|
|
860
|
-
const agreement = await findFirstAgreement<{
|
|
861
|
-
organisation_id: string | null;
|
|
862
|
-
signature_data: unknown;
|
|
863
|
-
}>({
|
|
864
|
-
where: { id: agreementId },
|
|
865
|
-
select: { organisation_id: true, signature_data: true },
|
|
866
|
-
});
|
|
867
|
-
// View-orgs = each party's organisationId (self-describing model) ∪ the
|
|
868
|
-
// agreement's owning org (also included for agreements that predate
|
|
869
|
-
// per-party org). Members of any of these may view; signing stays designated-signer-only.
|
|
870
|
-
const orgIds = new Set<string | null | undefined>();
|
|
871
|
-
if (typeof agreement?.organisation_id === "string") {
|
|
872
|
-
orgIds.add(agreement.organisation_id);
|
|
873
|
-
}
|
|
874
|
-
for (const p of parseAgreementParties(agreement?.signature_data) ?? []) {
|
|
875
|
-
if (p.organisationId) orgIds.add(p.organisationId);
|
|
876
|
-
}
|
|
877
|
-
const directory = await resolveAgreementViewerDirectory(orgIds);
|
|
878
|
-
for (const email of directory.keys()) emails.add(email);
|
|
879
|
-
} catch (err) {
|
|
880
|
-
getAppLogger().error(
|
|
881
|
-
`[agreement-gate] authorized-emails resolve failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
882
|
-
);
|
|
883
|
-
}
|
|
884
|
-
return emails;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
238
|
export const agreementAccessConfig: AccessConfig = {
|
|
888
239
|
path: "/api/agreement-access",
|
|
889
240
|
cookieName: AG_COOKIE,
|
|
@@ -896,14 +247,3 @@ export const agreementAccessConfig: AccessConfig = {
|
|
|
896
247
|
return signAgreementAccessToken(resourceId, email, nowMs);
|
|
897
248
|
},
|
|
898
249
|
};
|
|
899
|
-
|
|
900
|
-
// Event-driven invalidation via LISTEN on registry_config_changed
|
|
901
|
-
{
|
|
902
|
-
const _url = getDirectDatabaseUrl();
|
|
903
|
-
if (_url) {
|
|
904
|
-
createNotifyListener(_url).listen("registry_config_changed", () => {
|
|
905
|
-
pageAgreementCache.clear();
|
|
906
|
-
orgMemberCache.clear();
|
|
907
|
-
}).catch((e: unknown) => console.error("[cache-invalidation] subscribe failed: " + e));
|
|
908
|
-
}
|
|
909
|
-
}
|