@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
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/*",
|
|
@@ -886,6 +886,62 @@
|
|
|
886
886
|
"types": "./src/register-registry-page-route/resolve-page-data.d.ts",
|
|
887
887
|
"default": "./src/register-registry-page-route/resolve-page-data.ts"
|
|
888
888
|
},
|
|
889
|
+
"./register-registry-page-route/resolve-request-graph": {
|
|
890
|
+
"types": "./src/register-registry-page-route/resolve-request-graph.d.ts",
|
|
891
|
+
"default": "./src/register-registry-page-route/resolve-request-graph.ts"
|
|
892
|
+
},
|
|
893
|
+
"./register-registry-page-route/resolve-request-graph.ts": {
|
|
894
|
+
"types": "./src/register-registry-page-route/resolve-request-graph.d.ts",
|
|
895
|
+
"default": "./src/register-registry-page-route/resolve-request-graph.ts"
|
|
896
|
+
},
|
|
897
|
+
"./register-registry-page-route/resolve-panel-page": {
|
|
898
|
+
"types": "./src/register-registry-page-route/resolve-panel-page.d.ts",
|
|
899
|
+
"default": "./src/register-registry-page-route/resolve-panel-page.ts"
|
|
900
|
+
},
|
|
901
|
+
"./register-registry-page-route/resolve-panel-page.ts": {
|
|
902
|
+
"types": "./src/register-registry-page-route/resolve-panel-page.d.ts",
|
|
903
|
+
"default": "./src/register-registry-page-route/resolve-panel-page.ts"
|
|
904
|
+
},
|
|
905
|
+
"./register-registry-page-route/stamp-session-context": {
|
|
906
|
+
"types": "./src/register-registry-page-route/stamp-session-context.d.ts",
|
|
907
|
+
"default": "./src/register-registry-page-route/stamp-session-context.ts"
|
|
908
|
+
},
|
|
909
|
+
"./register-registry-page-route/stamp-session-context.ts": {
|
|
910
|
+
"types": "./src/register-registry-page-route/stamp-session-context.d.ts",
|
|
911
|
+
"default": "./src/register-registry-page-route/stamp-session-context.ts"
|
|
912
|
+
},
|
|
913
|
+
"./register-registry-page-route/run-content-gates": {
|
|
914
|
+
"types": "./src/register-registry-page-route/run-content-gates.d.ts",
|
|
915
|
+
"default": "./src/register-registry-page-route/run-content-gates.ts"
|
|
916
|
+
},
|
|
917
|
+
"./register-registry-page-route/run-content-gates.ts": {
|
|
918
|
+
"types": "./src/register-registry-page-route/run-content-gates.d.ts",
|
|
919
|
+
"default": "./src/register-registry-page-route/run-content-gates.ts"
|
|
920
|
+
},
|
|
921
|
+
"./register-registry-page-route/apply-page-branding": {
|
|
922
|
+
"types": "./src/register-registry-page-route/apply-page-branding.d.ts",
|
|
923
|
+
"default": "./src/register-registry-page-route/apply-page-branding.ts"
|
|
924
|
+
},
|
|
925
|
+
"./register-registry-page-route/apply-page-branding.ts": {
|
|
926
|
+
"types": "./src/register-registry-page-route/apply-page-branding.d.ts",
|
|
927
|
+
"default": "./src/register-registry-page-route/apply-page-branding.ts"
|
|
928
|
+
},
|
|
929
|
+
"./register-registry-page-route/warn-uncovered-blocks": {
|
|
930
|
+
"types": "./src/register-registry-page-route/warn-uncovered-blocks.d.ts",
|
|
931
|
+
"default": "./src/register-registry-page-route/warn-uncovered-blocks.ts"
|
|
932
|
+
},
|
|
933
|
+
"./register-registry-page-route/warn-uncovered-blocks.ts": {
|
|
934
|
+
"types": "./src/register-registry-page-route/warn-uncovered-blocks.d.ts",
|
|
935
|
+
"default": "./src/register-registry-page-route/warn-uncovered-blocks.ts"
|
|
936
|
+
},
|
|
937
|
+
"./register-registry-page-route/prefetch-org-id": {
|
|
938
|
+
"types": "./src/register-registry-page-route/prefetch-org-id.d.ts",
|
|
939
|
+
"default": "./src/register-registry-page-route/prefetch-org-id.ts"
|
|
940
|
+
},
|
|
941
|
+
"./register-registry-page-route/prefetch-org-id.ts": {
|
|
942
|
+
"types": "./src/register-registry-page-route/prefetch-org-id.d.ts",
|
|
943
|
+
"default": "./src/register-registry-page-route/prefetch-org-id.ts"
|
|
944
|
+
},
|
|
889
945
|
"./register-static-assets": {
|
|
890
946
|
"types": "./src/register-static-assets.d.ts",
|
|
891
947
|
"default": "./src/register-static-assets.ts"
|
|
@@ -1077,6 +1133,54 @@
|
|
|
1077
1133
|
"./ws-boot-self-test.ts": {
|
|
1078
1134
|
"types": "./src/ws-boot-self-test.d.ts",
|
|
1079
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"
|
|
1080
1184
|
}
|
|
1081
1185
|
},
|
|
1082
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
|
+
}
|