@teamscala/service-boot 0.23.413 → 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 +105 -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/register-registry-page-route/apply-page-branding.ts +207 -0
- package/src/register-registry-page-route/meta-title.ts +15 -0
- package/src/register-registry-page-route/prefetch-org-id.ts +38 -0
- package/src/register-registry-page-route/resolve-panel-page.ts +45 -0
- package/src/register-registry-page-route/resolve-request-graph.ts +170 -0
- package/src/register-registry-page-route/run-content-gates.ts +80 -0
- package/src/register-registry-page-route/stamp-session-context.ts +121 -0
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +64 -0
- package/src/register-registry-page-route.ts +64 -599
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* New-flow signing-shell injection (no pre-placed gate required): agreement
|
|
7
|
+
* meta stashing, body-content discovery, the SignIn gate block builder, and
|
|
8
|
+
* the DB-driven signing shell (config/agreement-signing-shell) with its
|
|
9
|
+
* hardcoded fallback.
|
|
10
|
+
*
|
|
11
|
+
* Split out of agreement-gate.ts (909 lines) — byte-for-byte move; import
|
|
12
|
+
* paths adjusted only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
16
|
+
import * as v from "valibot";
|
|
17
|
+
import { findAgreementGate } from "./signing-session.ts";
|
|
18
|
+
import type { ContentBlock } from "./signing-session.ts";
|
|
19
|
+
import type { AgreementGateSettings } from "./settings.ts";
|
|
20
|
+
|
|
21
|
+
/** Agreement metadata stashed on resolved during getResourceId. */
|
|
22
|
+
export const AGREEMENT_META_KEY = "_agreementMeta";
|
|
23
|
+
|
|
24
|
+
export function stashAgreementMeta(
|
|
25
|
+
resolved: Record<string, unknown>,
|
|
26
|
+
id: string,
|
|
27
|
+
name: string,
|
|
28
|
+
): void {
|
|
29
|
+
resolved[AGREEMENT_META_KEY] = { id, name };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The body content array for the agreement. The public shell composes the body
|
|
34
|
+
* region FROM resolved.page (register-registry-page-route.ts Branch 2:
|
|
35
|
+
* `bodyPuck = extractPuckData(resolved.page)`, returned un-cloned), so this IS
|
|
36
|
+
* the live array whose mutation reaches the SSR render. Header/footer are
|
|
37
|
+
* separate region sources (resolved.header / resolved.footer) — never the body.
|
|
38
|
+
*
|
|
39
|
+
* A prior version scored every region by top-level non-shell block count and
|
|
40
|
+
* picked the highest. That misroutes when the body wraps its clauses inside a
|
|
41
|
+
* Container (body's top-level count is ~2 while its nested clause count is ~40):
|
|
42
|
+
* a /header region with more top-level blocks out-scores the body, so
|
|
43
|
+
* onUnauthorized cleared the HEADER and injected the gate there, leaving the
|
|
44
|
+
* entire agreement body visible past the gate. The body is the resource page —
|
|
45
|
+
* identified, not heuristically guessed.
|
|
46
|
+
*/
|
|
47
|
+
export function getBodyContentArray(
|
|
48
|
+
resolved: Record<string, unknown>,
|
|
49
|
+
): ContentBlock[] | null {
|
|
50
|
+
const pd = (
|
|
51
|
+
resolved.page as { puck_data?: { content?: unknown[] } } | undefined
|
|
52
|
+
)?.puck_data;
|
|
53
|
+
if (!pd || !Array.isArray(pd.content)) return null;
|
|
54
|
+
return pd.content as ContentBlock[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Construct the SignIn gate block for injection. */
|
|
58
|
+
export function buildGateBlock(
|
|
59
|
+
agreementUuid: string,
|
|
60
|
+
title: string,
|
|
61
|
+
settings: AgreementGateSettings,
|
|
62
|
+
): ContentBlock {
|
|
63
|
+
return {
|
|
64
|
+
type: "SignIn",
|
|
65
|
+
props: {
|
|
66
|
+
id: `agreement-gate-${agreementUuid}`,
|
|
67
|
+
mode: "gate",
|
|
68
|
+
title: title || settings.title,
|
|
69
|
+
subtitle: settings.subtitle,
|
|
70
|
+
gateAction: "/api/agreement-access",
|
|
71
|
+
showBackToHome: true,
|
|
72
|
+
gateContextField: "agreementId",
|
|
73
|
+
gateContextValue: agreementUuid,
|
|
74
|
+
gateSubmitLabel: settings.submitLabel,
|
|
75
|
+
// Require OTP is DB-driven (config/content-gate-agreement.requireOtp).
|
|
76
|
+
// String "true"/"false" (not boolean) to match the select-field enum the
|
|
77
|
+
// render validates against — a boolean is stripped before reaching the
|
|
78
|
+
// component (the same coercion that bit the pre-placed gate block).
|
|
79
|
+
gateRequireOtp: settings.requireOtp ? "true" : "false",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Construct the signing shell blocks that wrap the body content when authed:
|
|
86
|
+
* [0] title ({{agreement.name}}), [1] execution clause, [2] signature panel
|
|
87
|
+
* (grid Container holding a per-party SignatureField template), [3] print
|
|
88
|
+
* button, [4] audit footer. The title is unshift'd before body; the rest are
|
|
89
|
+
* pushed after. expandRosterTemplates expands the SignatureField per party;
|
|
90
|
+
* seedSigningBlocks seeds each cell with the verified session.
|
|
91
|
+
*/
|
|
92
|
+
const AGREEMENT_SIGNING_SHELL_DEFAULT: ContentBlock[] = [
|
|
93
|
+
{
|
|
94
|
+
type: "Text",
|
|
95
|
+
props: {
|
|
96
|
+
id: "agreement-title",
|
|
97
|
+
text: "{{agreement.name}}",
|
|
98
|
+
size: "xl",
|
|
99
|
+
weight: "bold",
|
|
100
|
+
align: "left",
|
|
101
|
+
element: "h1",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: "Text",
|
|
106
|
+
props: {
|
|
107
|
+
id: "agreement-execution",
|
|
108
|
+
text: "IN WITNESS WHEREOF, the parties have executed this Agreement as of the date(s) of signature below.",
|
|
109
|
+
size: "sm",
|
|
110
|
+
weight: "normal",
|
|
111
|
+
align: "left",
|
|
112
|
+
element: "p",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: "Container",
|
|
117
|
+
props: {
|
|
118
|
+
id: "agreement-signature-panel",
|
|
119
|
+
layout: "grid",
|
|
120
|
+
columns: 2,
|
|
121
|
+
direction: "column",
|
|
122
|
+
gap: "md",
|
|
123
|
+
padding: "md",
|
|
124
|
+
mobileBehavior: "stack",
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: "SignatureField",
|
|
128
|
+
props: {
|
|
129
|
+
id: "agreement-signature-field",
|
|
130
|
+
label: "",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
// Print button — preset:"action-primary" is the single styling knob: it
|
|
137
|
+
// resolves (button-presets config) to variant:primary → brand-coloured
|
|
138
|
+
// background. NO explicit `variant` prop: button-styles.ts does
|
|
139
|
+
// `variant ?? preset.variant`, so any explicit variant overrides the preset
|
|
140
|
+
// (a stray variant:"secondary" — not a real variant — fell back to a
|
|
141
|
+
// transparent ghost; that was the bare-text bug). icon:"lucide:printer"
|
|
142
|
+
// renders the printer glyph (baseStyle provides the icon↔label gap).
|
|
143
|
+
{
|
|
144
|
+
type: "Button",
|
|
145
|
+
props: {
|
|
146
|
+
id: "agreement-print",
|
|
147
|
+
label: "Print",
|
|
148
|
+
icon: "lucide:printer",
|
|
149
|
+
actionType: "print",
|
|
150
|
+
preset: "action-primary",
|
|
151
|
+
size: "default",
|
|
152
|
+
borderColor: "var(--border-color-default)",
|
|
153
|
+
borderWidth: "var(--border-width-default)",
|
|
154
|
+
borderRadius: "var(--radius-md)",
|
|
155
|
+
fullWidth: false,
|
|
156
|
+
pushRight: false,
|
|
157
|
+
href: "",
|
|
158
|
+
target: "_self",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: "Text",
|
|
163
|
+
props: {
|
|
164
|
+
id: "agreement-audit",
|
|
165
|
+
text: "Secured electronic signature · {{agreement.org}}",
|
|
166
|
+
size: "xs",
|
|
167
|
+
align: "center",
|
|
168
|
+
element: "p",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const SigningShellSchema = v.object({ blocks: v.array(v.unknown()) });
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The signing-shell block tree, DB-driven (config/agreement-signing-shell) so the
|
|
177
|
+
* clauses, labels, and structure are editable without a deploy. Deep-cloned per
|
|
178
|
+
* call because injection (expandRosterTemplates/seedSigningBlocks) mutates the
|
|
179
|
+
* tree. Falls back to AGREEMENT_SIGNING_SHELL_DEFAULT so signing ALWAYS renders.
|
|
180
|
+
*/
|
|
181
|
+
export async function buildSigningShellBlocks(): Promise<ContentBlock[]> {
|
|
182
|
+
try {
|
|
183
|
+
const cfg = await loadRegistryConfig(
|
|
184
|
+
"config",
|
|
185
|
+
"agreement-signing-shell",
|
|
186
|
+
SigningShellSchema,
|
|
187
|
+
);
|
|
188
|
+
const blocks = cfg?.blocks as ContentBlock[] | undefined;
|
|
189
|
+
if (Array.isArray(blocks) && blocks.length > 0) {
|
|
190
|
+
return structuredClone(blocks);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// fall through to the default so the signing shell ALWAYS renders
|
|
194
|
+
}
|
|
195
|
+
return structuredClone(AGREEMENT_SIGNING_SHELL_DEFAULT);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** True if the page has a pre-placed SignIn gate block (old-flow page). */
|
|
199
|
+
export function hasPrePlacedGate(resolved: Record<string, unknown>): boolean {
|
|
200
|
+
return findAgreementGate(resolved).agreementUuid !== "";
|
|
201
|
+
}
|