@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/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.415",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -1133,6 +1133,54 @@
|
|
|
1133
1133
|
"./ws-boot-self-test.ts": {
|
|
1134
1134
|
"types": "./src/ws-boot-self-test.d.ts",
|
|
1135
1135
|
"default": "./src/ws-boot-self-test.ts"
|
|
1136
|
+
},
|
|
1137
|
+
"./agreement-gate/settings": {
|
|
1138
|
+
"types": "./src/agreement-gate/settings.d.ts",
|
|
1139
|
+
"default": "./src/agreement-gate/settings.ts"
|
|
1140
|
+
},
|
|
1141
|
+
"./agreement-gate/settings.ts": {
|
|
1142
|
+
"types": "./src/agreement-gate/settings.d.ts",
|
|
1143
|
+
"default": "./src/agreement-gate/settings.ts"
|
|
1144
|
+
},
|
|
1145
|
+
"./agreement-gate/agreement-lookup": {
|
|
1146
|
+
"types": "./src/agreement-gate/agreement-lookup.d.ts",
|
|
1147
|
+
"default": "./src/agreement-gate/agreement-lookup.ts"
|
|
1148
|
+
},
|
|
1149
|
+
"./agreement-gate/agreement-lookup.ts": {
|
|
1150
|
+
"types": "./src/agreement-gate/agreement-lookup.d.ts",
|
|
1151
|
+
"default": "./src/agreement-gate/agreement-lookup.ts"
|
|
1152
|
+
},
|
|
1153
|
+
"./agreement-gate/signing-session": {
|
|
1154
|
+
"types": "./src/agreement-gate/signing-session.d.ts",
|
|
1155
|
+
"default": "./src/agreement-gate/signing-session.ts"
|
|
1156
|
+
},
|
|
1157
|
+
"./agreement-gate/signing-session.ts": {
|
|
1158
|
+
"types": "./src/agreement-gate/signing-session.d.ts",
|
|
1159
|
+
"default": "./src/agreement-gate/signing-session.ts"
|
|
1160
|
+
},
|
|
1161
|
+
"./agreement-gate/placeholder-strip": {
|
|
1162
|
+
"types": "./src/agreement-gate/placeholder-strip.d.ts",
|
|
1163
|
+
"default": "./src/agreement-gate/placeholder-strip.ts"
|
|
1164
|
+
},
|
|
1165
|
+
"./agreement-gate/placeholder-strip.ts": {
|
|
1166
|
+
"types": "./src/agreement-gate/placeholder-strip.d.ts",
|
|
1167
|
+
"default": "./src/agreement-gate/placeholder-strip.ts"
|
|
1168
|
+
},
|
|
1169
|
+
"./agreement-gate/signing-shell": {
|
|
1170
|
+
"types": "./src/agreement-gate/signing-shell.d.ts",
|
|
1171
|
+
"default": "./src/agreement-gate/signing-shell.ts"
|
|
1172
|
+
},
|
|
1173
|
+
"./agreement-gate/signing-shell.ts": {
|
|
1174
|
+
"types": "./src/agreement-gate/signing-shell.d.ts",
|
|
1175
|
+
"default": "./src/agreement-gate/signing-shell.ts"
|
|
1176
|
+
},
|
|
1177
|
+
"./agreement-gate/authorized-emails": {
|
|
1178
|
+
"types": "./src/agreement-gate/authorized-emails.d.ts",
|
|
1179
|
+
"default": "./src/agreement-gate/authorized-emails.ts"
|
|
1180
|
+
},
|
|
1181
|
+
"./agreement-gate/authorized-emails.ts": {
|
|
1182
|
+
"types": "./src/agreement-gate/authorized-emails.d.ts",
|
|
1183
|
+
"default": "./src/agreement-gate/authorized-emails.ts"
|
|
1136
1184
|
}
|
|
1137
1185
|
},
|
|
1138
1186
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Agreement lookups + the gate's resolution caches: the page→agreement
|
|
7
|
+
* association cache, the owning/party org member-directory caches, and the
|
|
8
|
+
* event-driven invalidation (LISTEN registry_config_changed) that clears them.
|
|
9
|
+
*
|
|
10
|
+
* Split out of agreement-gate.ts (909 lines) — byte-for-byte move; import
|
|
11
|
+
* paths adjusted only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
|
+
import { queryRows } from "@teamscala/db/query-rows";
|
|
16
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
17
|
+
import { createNotifyListener, getDirectDatabaseUrl } from "@teamscala/db/notify-listener/index";
|
|
18
|
+
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
19
|
+
|
|
20
|
+
export async function findFirstAgreement<T>(input: {
|
|
21
|
+
where: Record<string, unknown>;
|
|
22
|
+
select: Record<string, boolean>;
|
|
23
|
+
}): Promise<T | null> {
|
|
24
|
+
const client = getServerClient() as unknown as {
|
|
25
|
+
agreements: { findFirst: (args: typeof input) => Promise<T | null> };
|
|
26
|
+
};
|
|
27
|
+
return client.agreements.findFirst(input);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Cache for the page→agreement association (new-flow pages that have no
|
|
32
|
+
* pre-placed gate block). The association changes rarely (only when a new
|
|
33
|
+
* agreement is created or a page is rebound), so a 30s TTL avoids a DB query
|
|
34
|
+
* per request while staying fresh enough for just-created agreements to appear.
|
|
35
|
+
* Negative results are cached too (id="") so non-agreement pages never re-query.
|
|
36
|
+
*/
|
|
37
|
+
export const pageAgreementCache = createCache<{ id: string; name: string }>(
|
|
38
|
+
"agreement-gate:page-association",
|
|
39
|
+
{ maxSize: 256, ttlMs: 30_000 },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cache for the owning org's member directory (org id → lowercased email → name),
|
|
44
|
+
* used to grant VIEW-ONLY access to an agreement to any member of the org that
|
|
45
|
+
* owns it (signing stays roster-bound). Membership changes on human timescales,
|
|
46
|
+
* so a 30s TTL keeps the per-request authorize cheap (hot-path-resolutions-are-cached).
|
|
47
|
+
*/
|
|
48
|
+
export const orgMemberCache = createCache<Map<string, string>>(
|
|
49
|
+
"agreement-gate:org-members",
|
|
50
|
+
{ maxSize: 256, ttlMs: 30_000 },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The owning org's member directory: lowercased email → display name, for every
|
|
55
|
+
* member of `organisationId`. Empty when the org is unknown or has no members.
|
|
56
|
+
*
|
|
57
|
+
* Resolved via a parameterized raw join over better_auth_member + "user" rather
|
|
58
|
+
* rather than a service-local Prisma `member` model: `member` is a shared
|
|
59
|
+
* @@map'd Prisma model, and each
|
|
60
|
+
* service's per-service client is generated by live DB introspection from only
|
|
61
|
+
* the tables its service_schema declares — so that model is undefined
|
|
62
|
+
* on a service (scala-sign, scala-proposals) that doesn't list better_auth_member.
|
|
63
|
+
* A read-only raw query needs no model declaration and works on every client,
|
|
64
|
+
* which is what makes org-member VIEW access actually resolve (the typed access
|
|
65
|
+
* silently crashed → caught → empty directory → org members wrongly challenged).
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveOrgMemberDirectory(
|
|
68
|
+
organisationId: string | null,
|
|
69
|
+
): Promise<Map<string, string>> {
|
|
70
|
+
if (!organisationId) return new Map();
|
|
71
|
+
const cached = orgMemberCache.get(organisationId);
|
|
72
|
+
if (cached !== undefined) return cached;
|
|
73
|
+
const directory = new Map<string, string>();
|
|
74
|
+
try {
|
|
75
|
+
const rows = await queryRows`
|
|
76
|
+
SELECT u.email AS email, u.name AS name
|
|
77
|
+
FROM better_auth_member m
|
|
78
|
+
JOIN "user" u ON u.id = m.user_id
|
|
79
|
+
WHERE m.organization_id = ${organisationId}
|
|
80
|
+
` as
|
|
81
|
+
Array<{ email: string | null; name: string | null }>
|
|
82
|
+
;
|
|
83
|
+
for (const r of rows) {
|
|
84
|
+
const email = typeof r.email === "string" ? r.email.trim().toLowerCase() : "";
|
|
85
|
+
if (email) directory.set(email, String(r.name ?? r.email));
|
|
86
|
+
}
|
|
87
|
+
orgMemberCache.set(organisationId, directory);
|
|
88
|
+
return directory;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
getAppLogger().error(
|
|
91
|
+
`[agreement-gate] org-member resolve failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
);
|
|
93
|
+
return directory;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The combined member directory for EVERY org an agreement's parties represent
|
|
99
|
+
* (each party's `organisationId`) plus the agreement's owning org. Any of these
|
|
100
|
+
* members may VIEW the agreement; signing stays restricted to the designated
|
|
101
|
+
* signer (the party email). Usually 1 org (one of our orgs + an external party),
|
|
102
|
+
* so the loop is over a 1-2 element set, each entry cached.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveAgreementViewerDirectory(
|
|
105
|
+
orgIds: Iterable<string | null | undefined>,
|
|
106
|
+
): Promise<Map<string, string>> {
|
|
107
|
+
const out = new Map<string, string>();
|
|
108
|
+
const unique = new Set(
|
|
109
|
+
[...orgIds].filter((id): id is string => typeof id === "string" && id.length > 0),
|
|
110
|
+
);
|
|
111
|
+
for (const orgId of unique) {
|
|
112
|
+
const dir = await resolveOrgMemberDirectory(orgId);
|
|
113
|
+
for (const [email, name] of dir) if (!out.has(email)) out.set(email, name);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Event-driven invalidation via LISTEN on registry_config_changed
|
|
119
|
+
{
|
|
120
|
+
const _url = getDirectDatabaseUrl();
|
|
121
|
+
if (_url) {
|
|
122
|
+
createNotifyListener(_url).listen("registry_config_changed", () => {
|
|
123
|
+
pageAgreementCache.clear();
|
|
124
|
+
orgMemberCache.clear();
|
|
125
|
+
}).catch((e: unknown) => console.error("[cache-invalidation] subscribe failed: " + e));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The agreement's authorized email sets: the signer roster (who may sign) and
|
|
7
|
+
* the roster ∪ owning/party-org members (who may view past the gate).
|
|
8
|
+
*
|
|
9
|
+
* Split out of agreement-gate.ts (909 lines) — byte-for-byte move; import
|
|
10
|
+
* paths adjusted only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
|
+
import { parseAgreementParties } from "@teamscala/document-signing/agreement-parties";
|
|
15
|
+
import { findFirstAgreement, resolveAgreementViewerDirectory } from "./agreement-lookup.ts";
|
|
16
|
+
|
|
17
|
+
/** The agreement's authorized email set (its signer roster) for the access endpoint. */
|
|
18
|
+
export async function resolveAgreementSignerEmails(
|
|
19
|
+
agreementId: string,
|
|
20
|
+
): Promise<Set<string>> {
|
|
21
|
+
const emails = new Set<string>();
|
|
22
|
+
try {
|
|
23
|
+
const agreement = await findFirstAgreement<{
|
|
24
|
+
signer_email: string | null;
|
|
25
|
+
signature_data: unknown;
|
|
26
|
+
}>({
|
|
27
|
+
where: { id: agreementId },
|
|
28
|
+
select: { signer_email: true, signature_data: true },
|
|
29
|
+
});
|
|
30
|
+
if (!agreement) return emails;
|
|
31
|
+
const parties = parseAgreementParties(agreement.signature_data);
|
|
32
|
+
if (parties) {
|
|
33
|
+
for (const p of parties) {
|
|
34
|
+
if (p.email) emails.add(p.email.trim().toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (agreement.signer_email) {
|
|
38
|
+
emails.add(String(agreement.signer_email).trim().toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
return emails;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
getAppLogger().error(
|
|
43
|
+
`[agreement-gate] signer-email resolve failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
44
|
+
);
|
|
45
|
+
return emails;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every email allowed PAST the gate: the signer roster (who may sign) UNION every
|
|
51
|
+
* member of the owning org (who may VIEW). Signing authority stays roster-only —
|
|
52
|
+
* buildSigningSession gives a non-party org member a view-only session. This is
|
|
53
|
+
* what the access endpoint and session-authorize check.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveAgreementAuthorizedEmails(
|
|
56
|
+
agreementId: string,
|
|
57
|
+
): Promise<Set<string>> {
|
|
58
|
+
const emails = await resolveAgreementSignerEmails(agreementId);
|
|
59
|
+
try {
|
|
60
|
+
const agreement = await findFirstAgreement<{
|
|
61
|
+
organisation_id: string | null;
|
|
62
|
+
signature_data: unknown;
|
|
63
|
+
}>({
|
|
64
|
+
where: { id: agreementId },
|
|
65
|
+
select: { organisation_id: true, signature_data: true },
|
|
66
|
+
});
|
|
67
|
+
// View-orgs = each party's organisationId (self-describing model) ∪ the
|
|
68
|
+
// agreement's owning org (also included for agreements that predate
|
|
69
|
+
// per-party org). Members of any of these may view; signing stays designated-signer-only.
|
|
70
|
+
const orgIds = new Set<string | null | undefined>();
|
|
71
|
+
if (typeof agreement?.organisation_id === "string") {
|
|
72
|
+
orgIds.add(agreement.organisation_id);
|
|
73
|
+
}
|
|
74
|
+
for (const p of parseAgreementParties(agreement?.signature_data) ?? []) {
|
|
75
|
+
if (p.organisationId) orgIds.add(p.organisationId);
|
|
76
|
+
}
|
|
77
|
+
const directory = await resolveAgreementViewerDirectory(orgIds);
|
|
78
|
+
for (const email of directory.keys()) emails.add(email);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
getAppLogger().error(
|
|
81
|
+
`[agreement-gate] authorized-emails resolve failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return emails;
|
|
85
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The withholding transform: drop blocks whose own text still carries an
|
|
7
|
+
* {{agreement.*}} token, recursively, so unauthed viewers never see raw
|
|
8
|
+
* placeholders.
|
|
9
|
+
*
|
|
10
|
+
* Split out of agreement-gate.ts (909 lines) — byte-for-byte move; import
|
|
11
|
+
* paths adjusted only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ContentBlock } from "./signing-session.ts";
|
|
15
|
+
|
|
16
|
+
/** True if any of the block's OWN string props carries an {{agreement.*}} token. */
|
|
17
|
+
function blockHasAgreementPlaceholder(block: ContentBlock): boolean {
|
|
18
|
+
const props = block.props;
|
|
19
|
+
if (!props) return false;
|
|
20
|
+
for (const propVal of Object.values(props)) {
|
|
21
|
+
if (typeof propVal === "string" && /\{\{\s*agreement\./.test(propVal)) return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Drop blocks whose own text still holds an {{agreement.*}} token, recursively. */
|
|
27
|
+
export function stripAgreementPlaceholderBlocks(content: ContentBlock[]): ContentBlock[] {
|
|
28
|
+
const out: ContentBlock[] = [];
|
|
29
|
+
for (const block of content) {
|
|
30
|
+
if (blockHasAgreementPlaceholder(block)) continue;
|
|
31
|
+
const props = block.props;
|
|
32
|
+
if (props) {
|
|
33
|
+
for (const [k, propVal] of Object.entries(props)) {
|
|
34
|
+
if (Array.isArray(propVal)) {
|
|
35
|
+
props[k] = stripAgreementPlaceholderBlocks(propVal as ContentBlock[]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
out.push(block);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* DB-driven display + behaviour settings for the AGREEMENT content gate
|
|
7
|
+
* (config/content-gate-agreement), plus its cookie name.
|
|
8
|
+
*
|
|
9
|
+
* Split out of agreement-gate.ts (909 lines) — byte-for-byte move; import
|
|
10
|
+
* paths adjusted only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
|
|
16
|
+
export const AG_COOKIE = "ag_token";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Agreement gate display + behaviour, DB-driven (config/content-gate-agreement),
|
|
20
|
+
* never hardcoded — mirrors proposal-gate's loadProposalGateSettings. requireOtp
|
|
21
|
+
* is the per-gate OTP switch (agreements default ON: a signer verifies identity
|
|
22
|
+
* before signing). title is the fallback used when no agreement name resolves. A
|
|
23
|
+
* missing/invalid row falls back to these defaults so the gate UI ALWAYS renders.
|
|
24
|
+
*/
|
|
25
|
+
export interface AgreementGateSettings {
|
|
26
|
+
title: string;
|
|
27
|
+
subtitle: string;
|
|
28
|
+
submitLabel: string;
|
|
29
|
+
requireOtp: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const AGREEMENT_GATE_DEFAULTS: AgreementGateSettings = {
|
|
33
|
+
title: "Private document",
|
|
34
|
+
subtitle: "Verify your email to view and sign this document.",
|
|
35
|
+
submitLabel: "Access document",
|
|
36
|
+
requireOtp: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const AgreementGateSettingsSchema = v.object({
|
|
40
|
+
title: v.optional(v.string(), AGREEMENT_GATE_DEFAULTS.title),
|
|
41
|
+
subtitle: v.optional(v.string(), AGREEMENT_GATE_DEFAULTS.subtitle),
|
|
42
|
+
submitLabel: v.optional(v.string(), AGREEMENT_GATE_DEFAULTS.submitLabel),
|
|
43
|
+
requireOtp: v.optional(v.boolean(), AGREEMENT_GATE_DEFAULTS.requireOtp),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Gate copy/behaviour, or null when the config row is absent/broken. Null is
|
|
47
|
+
* the absent signal — callers default explicitly so a missing row neither 500s
|
|
48
|
+
* the page nor masks as a populated value (protective-layers-fail-explicitly). */
|
|
49
|
+
export async function loadAgreementGateSettings(): Promise<AgreementGateSettings | null> {
|
|
50
|
+
try {
|
|
51
|
+
return await loadRegistryConfig(
|
|
52
|
+
"config",
|
|
53
|
+
"content-gate-agreement",
|
|
54
|
+
AgreementGateSettingsSchema,
|
|
55
|
+
);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The verified signer's signing session: gate-block discovery across regions,
|
|
7
|
+
* roster/party session construction (signer vs view-only org member), and
|
|
8
|
+
* seeding of the session into every SignatureField.
|
|
9
|
+
*
|
|
10
|
+
* Split out of agreement-gate.ts (909 lines) — byte-for-byte move; import
|
|
11
|
+
* paths adjusted only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
|
+
import {
|
|
16
|
+
type PublicAgreementParty,
|
|
17
|
+
parseAgreementParties,
|
|
18
|
+
toPublicParties,
|
|
19
|
+
} from "@teamscala/document-signing/agreement-parties";
|
|
20
|
+
import {
|
|
21
|
+
buildCertificateData,
|
|
22
|
+
type CertificateData,
|
|
23
|
+
} from "../fn-routers/public-agreement-router.ts";
|
|
24
|
+
import { findFirstAgreement, resolveAgreementViewerDirectory } from "./agreement-lookup.ts";
|
|
25
|
+
|
|
26
|
+
export interface ContentBlock {
|
|
27
|
+
type?: string;
|
|
28
|
+
props?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SigningSession {
|
|
32
|
+
authedRole: string;
|
|
33
|
+
authedName: string;
|
|
34
|
+
authedEmail: string;
|
|
35
|
+
agreementUuid: string;
|
|
36
|
+
parties: PublicAgreementParty[];
|
|
37
|
+
agreementName: string;
|
|
38
|
+
agreementRef: string;
|
|
39
|
+
completedAt: string | null;
|
|
40
|
+
orgName: string | null;
|
|
41
|
+
organisationId: string | null;
|
|
42
|
+
/** Certificate of Completion data (built for a party signer via
|
|
43
|
+
* buildCertificateData); null for view-only recipients. Seeded into the
|
|
44
|
+
* SignatureField so the certificate renders inline, immediately. */
|
|
45
|
+
certificate: CertificateData | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every resolved region's content[] array, paired with its puck_data holder. */
|
|
49
|
+
export function* regionContents(
|
|
50
|
+
resolved: Record<string, unknown>,
|
|
51
|
+
): Generator<{ pd: { content?: unknown[] }; content: ContentBlock[] }> {
|
|
52
|
+
for (const value of Object.values(resolved)) {
|
|
53
|
+
const pd = (value as { puck_data?: { content?: unknown[] } } | null)?.puck_data;
|
|
54
|
+
const content = pd?.content;
|
|
55
|
+
if (pd && Array.isArray(content)) {
|
|
56
|
+
yield { pd, content: content as ContentBlock[] };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Locate the agreement gate block(s) across regions. A gate is the SignIn block
|
|
63
|
+
* in `gate` mode whose field is NOT "proposalId" (that field marks a proposal
|
|
64
|
+
* gate); the agreement id lives in `gateContextValue`.
|
|
65
|
+
*/
|
|
66
|
+
export function findAgreementGate(resolved: Record<string, unknown>): {
|
|
67
|
+
agreementUuid: string;
|
|
68
|
+
gateRegions: { pd: { content?: unknown[] }; content: ContentBlock[]; gateIdx: number }[];
|
|
69
|
+
} {
|
|
70
|
+
let agreementUuid = "";
|
|
71
|
+
const gateRegions: {
|
|
72
|
+
pd: { content?: unknown[] };
|
|
73
|
+
content: ContentBlock[];
|
|
74
|
+
gateIdx: number;
|
|
75
|
+
}[] = [];
|
|
76
|
+
for (const { pd, content } of regionContents(resolved)) {
|
|
77
|
+
const gateIdx = content.findIndex(
|
|
78
|
+
(b) =>
|
|
79
|
+
b?.type === "SignIn" &&
|
|
80
|
+
b?.props?.mode === "gate" &&
|
|
81
|
+
b?.props?.gateContextField !== "proposalId",
|
|
82
|
+
);
|
|
83
|
+
if (gateIdx < 0) continue;
|
|
84
|
+
const uuid = content[gateIdx].props?.gateContextValue;
|
|
85
|
+
if (typeof uuid === "string" && uuid) agreementUuid = uuid;
|
|
86
|
+
gateRegions.push({ pd, content, gateIdx });
|
|
87
|
+
}
|
|
88
|
+
return { agreementUuid, gateRegions };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Build the verified signer's session from the agreement's parties roster. */
|
|
92
|
+
export async function buildSigningSession(
|
|
93
|
+
agreementUuid: string,
|
|
94
|
+
verifiedEmail: string,
|
|
95
|
+
): Promise<SigningSession | null> {
|
|
96
|
+
const log = getAppLogger();
|
|
97
|
+
const lower = verifiedEmail.trim().toLowerCase();
|
|
98
|
+
try {
|
|
99
|
+
const agreement = await findFirstAgreement<{
|
|
100
|
+
name: string | null;
|
|
101
|
+
signer_name: string | null;
|
|
102
|
+
signed_at: Date | string | null;
|
|
103
|
+
signature_data: unknown;
|
|
104
|
+
organisation_id: string | null;
|
|
105
|
+
}>({
|
|
106
|
+
where: { id: agreementUuid },
|
|
107
|
+
select: {
|
|
108
|
+
name: true,
|
|
109
|
+
signer_name: true,
|
|
110
|
+
signed_at: true,
|
|
111
|
+
signature_data: true,
|
|
112
|
+
organisation_id: true,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (!agreement) return null;
|
|
116
|
+
|
|
117
|
+
const meta = {
|
|
118
|
+
agreementName: String(agreement.name ?? "Agreement"),
|
|
119
|
+
agreementRef: agreementUuid.split("-")[0] ?? agreementUuid,
|
|
120
|
+
completedAt: agreement.signed_at
|
|
121
|
+
? new Date(agreement.signed_at as unknown as string).toISOString()
|
|
122
|
+
: null,
|
|
123
|
+
orgName: null as string | null,
|
|
124
|
+
organisationId:
|
|
125
|
+
typeof agreement.organisation_id === "string"
|
|
126
|
+
? agreement.organisation_id
|
|
127
|
+
: null,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const parties = parseAgreementParties(agreement.signature_data);
|
|
131
|
+
if (parties) {
|
|
132
|
+
const me = parties.find((p) => p.email.trim().toLowerCase() === lower);
|
|
133
|
+
if (me) {
|
|
134
|
+
// Only a "signer" recipient gets a signing session (authedRole = its
|
|
135
|
+
// party key, which the SignatureField matches to render its sign slot).
|
|
136
|
+
// A named "viewer"/"cc" recipient — like an org member below — is
|
|
137
|
+
// view-only: authedRole "viewer" matches no party key, so no slot.
|
|
138
|
+
// Certificate data is built only for a party signer (same email-must-
|
|
139
|
+
// be-a-party gate) and seeded for inline
|
|
140
|
+
// render; a view-only recipient gets null (and the cell never shows it).
|
|
141
|
+
const isSigner = me.recipientRole === "signer";
|
|
142
|
+
const cert = isSigner
|
|
143
|
+
? await buildCertificateData(agreementUuid, verifiedEmail)
|
|
144
|
+
: null;
|
|
145
|
+
return {
|
|
146
|
+
authedRole: isSigner ? me.key : "viewer",
|
|
147
|
+
authedName: me.name,
|
|
148
|
+
authedEmail: verifiedEmail,
|
|
149
|
+
agreementUuid,
|
|
150
|
+
parties: toPublicParties(parties),
|
|
151
|
+
certificate: cert && cert.ok ? cert : null,
|
|
152
|
+
...meta,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// Not a roster party — admit any member of an org this agreement's parties
|
|
156
|
+
// represent (each party's organisationId ∪ the owning org) as a VIEW-ONLY
|
|
157
|
+
// viewer. authedRole "viewer" matches no party key, so every SignatureField
|
|
158
|
+
// renders read-only "Awaiting <name>" — no signature slot. Only the
|
|
159
|
+
// designated signer (the party email matched above) ever signs.
|
|
160
|
+
const directory = await resolveAgreementViewerDirectory([
|
|
161
|
+
meta.organisationId,
|
|
162
|
+
...parties.map((p) => p.organisationId),
|
|
163
|
+
]);
|
|
164
|
+
const viewerName = directory.get(lower);
|
|
165
|
+
if (viewerName !== undefined) {
|
|
166
|
+
return {
|
|
167
|
+
authedRole: "viewer",
|
|
168
|
+
authedName: viewerName,
|
|
169
|
+
authedEmail: verifiedEmail,
|
|
170
|
+
agreementUuid,
|
|
171
|
+
parties: toPublicParties(parties),
|
|
172
|
+
certificate: null,
|
|
173
|
+
...meta,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const name = String(agreement.signer_name ?? "");
|
|
180
|
+
const cert = await buildCertificateData(agreementUuid, verifiedEmail);
|
|
181
|
+
return {
|
|
182
|
+
authedRole: "signer",
|
|
183
|
+
authedName: name,
|
|
184
|
+
authedEmail: verifiedEmail,
|
|
185
|
+
agreementUuid,
|
|
186
|
+
parties: [
|
|
187
|
+
{
|
|
188
|
+
key: "signer",
|
|
189
|
+
role: name || "Signer",
|
|
190
|
+
recipientRole: "signer",
|
|
191
|
+
name,
|
|
192
|
+
title: null,
|
|
193
|
+
company: null,
|
|
194
|
+
signedAt: null,
|
|
195
|
+
signature: null,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
certificate: cert && cert.ok ? cert : null,
|
|
199
|
+
...meta,
|
|
200
|
+
};
|
|
201
|
+
} catch (err) {
|
|
202
|
+
log.error(
|
|
203
|
+
`[agreement-gate] session build failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
204
|
+
);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const SIGNING_BLOCK_TYPES = new Set(["SignatureField"]);
|
|
210
|
+
|
|
211
|
+
/** Seed the verified session into every signing block across all regions. */
|
|
212
|
+
export function seedSigningBlocks(
|
|
213
|
+
resolved: Record<string, unknown>,
|
|
214
|
+
session: SigningSession,
|
|
215
|
+
): void {
|
|
216
|
+
const seed = {
|
|
217
|
+
authed: true,
|
|
218
|
+
authedRole: session.authedRole,
|
|
219
|
+
authedName: session.authedName,
|
|
220
|
+
authedEmail: session.authedEmail,
|
|
221
|
+
agreementUuid: session.agreementUuid,
|
|
222
|
+
parties: session.parties,
|
|
223
|
+
agreementName: session.agreementName,
|
|
224
|
+
agreementRef: session.agreementRef,
|
|
225
|
+
completedAt: session.completedAt,
|
|
226
|
+
orgName: session.orgName,
|
|
227
|
+
certificate: session.certificate,
|
|
228
|
+
};
|
|
229
|
+
const seedBlock = (block: ContentBlock): void => {
|
|
230
|
+
if (block?.type && SIGNING_BLOCK_TYPES.has(block.type)) {
|
|
231
|
+
block.props = { ...(block.props ?? {}), ...seed };
|
|
232
|
+
}
|
|
233
|
+
const props = block?.props;
|
|
234
|
+
if (!props) return;
|
|
235
|
+
for (const propVal of Object.values(props)) {
|
|
236
|
+
if (!Array.isArray(propVal)) continue;
|
|
237
|
+
for (const child of propVal) {
|
|
238
|
+
if (child && typeof child === "object") seedBlock(child as ContentBlock);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
for (const { content } of regionContents(resolved)) {
|
|
243
|
+
for (const block of content) seedBlock(block);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The org's display name (for the footer audit line), from the resolved brand. */
|
|
248
|
+
export function brandOrgName(resolved: Record<string, unknown>): string | null {
|
|
249
|
+
const brand = resolved.brand as
|
|
250
|
+
| { companyFriendlyName?: unknown; companyName?: unknown }
|
|
251
|
+
| undefined;
|
|
252
|
+
const name = brand?.companyFriendlyName ?? brand?.companyName;
|
|
253
|
+
return typeof name === "string" && name ? name : null;
|
|
254
|
+
}
|