@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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.413",
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
+ }