@teamscala/service-boot 0.23.284

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.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Completion delivery. When an agreement is fully EXECUTED (every party has
7
+ * signed), email each party their executed copy + a note to download the
8
+ * Certificate of Completion. Each link is a per-party magic-link (no email gate,
9
+ * no OTP — see attach-agreement-magic.ts). Best-effort and self-contained: it
10
+ * loads its own data and swallows every error, so a send failure is logged and
11
+ * NEVER undoes the signature (the payment-setup non-fatal rule, applied to mail).
12
+ * Fire-and-forget from the sign handler: `void sendAgreementCompletionEmails(...)`.
13
+ */
14
+
15
+ import * as v from "valibot";
16
+ import { getServerClient } from "@teamscala/orpc/server-client-registry";
17
+ import type { PrismaClient } from "@teamscala/db/client";
18
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
19
+ import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
20
+ import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
21
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
22
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
23
+ import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
24
+ import { parseAgreementParties } from "@teamscala/document-signing/agreement-parties";
25
+ import { getDefaultEmailSender } from "./email-defaults.ts";
26
+ import { getEmailLinkTtlMs } from "./email-link-ttl.ts";
27
+
28
+ // Emailed links are opened hours/days later — they must outlive the 30-min
29
+ // in-session token, or the recipient lands on the unauthed gate ("white page").
30
+
31
+
32
+ export async function sendAgreementCompletionEmails(
33
+ agreementId: string,
34
+ baseUrl: string,
35
+ ): Promise<void> {
36
+ const log = getAppLogger();
37
+ try {
38
+ const prisma = (await getServerClient()) as unknown as PrismaClient;
39
+ const agreement = await prisma.agreements.findFirst({
40
+ where: { id: agreementId },
41
+ select: { name: true, page_id: true, signature_data: true },
42
+ });
43
+ if (!agreement) return;
44
+ const parties = parseAgreementParties(agreement.signature_data) ?? [];
45
+ if (parties.length === 0) return;
46
+
47
+ const page = agreement.page_id
48
+ ? await prisma.builda_pages.findFirst({
49
+ where: { id: agreement.page_id },
50
+ select: { path: true },
51
+ })
52
+ : null;
53
+ if (!page?.path) return;
54
+
55
+ const rawSecret = await loadRegistryConfig(
56
+ "secret",
57
+ "resend-api-key",
58
+ v.looseObject({ token: v.optional(v.string()) }),
59
+ ).catch(() => null);
60
+ const decrypted = rawSecret
61
+ ? (decryptSecretConfig(rawSecret as never) as { token?: string })
62
+ : null;
63
+ if (!decrypted?.token) {
64
+ log.warn("[completion-email] Resend token unavailable — skipping");
65
+ return;
66
+ }
67
+ initResendClient(decrypted.token);
68
+ const from = await getDefaultEmailSender();
69
+
70
+ const base = baseUrl.replace(/\/+$/, "");
71
+ log.info(
72
+ `[completion-email] ${agreementId} executed — emailing ${parties.length} ${parties.length === 1 ? "party" : "parties"}`,
73
+ );
74
+ for (const p of parties) {
75
+ if (!p.email) continue;
76
+ const token = signAgreementAccessToken(
77
+ agreementId,
78
+ p.email,
79
+ Date.now(),
80
+ await getEmailLinkTtlMs(),
81
+ );
82
+ const url = token
83
+ ? `${base}/api/agreement-magic?a=${encodeURIComponent(agreementId)}&t=${encodeURIComponent(token)}&to=${encodeURIComponent(page.path)}`
84
+ : `${base}${page.path}`;
85
+ // Subject + body from the `agreement-completion` email_template row, not
86
+ // literals here (email-template-registry-type): copy changes are a
87
+ // registry_edit, not a deploy, and an org can override the row.
88
+ const tpl = applyTemplateVars(await lookupTemplate("agreement-completion"), {
89
+ recipientName: p.name || "there",
90
+ agreementName: agreement.name,
91
+ url,
92
+ });
93
+ const res = await sendEmail({
94
+ from,
95
+ to: p.email,
96
+ subject: tpl.subject,
97
+ html: tpl.bodyHtml,
98
+ text: tpl.bodyText,
99
+ });
100
+ if (res.success) {
101
+ log.info(
102
+ `[completion-email] sent to ${p.email}${res.id ? ` (id ${res.id})` : ""}`,
103
+ );
104
+ } else {
105
+ log.error(
106
+ `[completion-email] failed to ${p.email}: ${res.error ?? "unknown"}`,
107
+ );
108
+ }
109
+ }
110
+ } catch (e) {
111
+ log.error(
112
+ `[completion-email] non-fatal: ${e instanceof Error ? e.message : String(e)}`,
113
+ );
114
+ }
115
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * fn.agreementSend.send — authenticated "send for signature" procedure. Emails
7
+ * each party of an agreement a link to its signing page, stamps the lifecycle
8
+ * (sent_to / sent_at, status draft→sent), and returns a per-recipient result.
9
+ *
10
+ * Off-the-shelf path per building-applications: this is server logic beyond
11
+ * CRUD, so it is a typed ORPC procedure (not a bespoke endpoint), and it sends
12
+ * through the shared Resend adapter (@teamscala/api-resend) — the same surface
13
+ * @teamscala/auth uses for OTP — never a new email integration.
14
+ *
15
+ * Per-org sender: emails go FROM the org's display name on the platform's
16
+ * verified Resend domain ("<Org> via Sign <verified-addr>"), with reply_to set
17
+ * to the org's contact_email, so replies reach the org without each org needing
18
+ * its own verified domain. When an org later verifies its own sending domain,
19
+ * resolveSender is the single place to switch the FROM address.
20
+ *
21
+ * Sending is privileged (it reaches real inboxes), so the procedure requires an
22
+ * authenticated caller — the localhost super-admin bypass authorises operator /
23
+ * portal-SSR callers; the public signing page cannot invoke it.
24
+ */
25
+
26
+ import { os } from "@orpc/server";
27
+ import * as v from "valibot";
28
+ import {
29
+ decryptSecretConfig,
30
+ type SecretConfigLike,
31
+ } from "@teamscala/encryption/crypto/secret-config";
32
+ import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
33
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
34
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
35
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
36
+ import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
37
+ import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
38
+ import { getDefaultEmailSender } from "./email-defaults.ts";
39
+ import { getEmailLinkTtlMs } from "./email-link-ttl.ts";
40
+
41
+ // Emailed links must outlive the 30-min in-session token (opened hours/days
42
+ // later), else the recipient lands on the unauthed gate ("white page").
43
+
44
+ interface User {
45
+ id: string;
46
+ organisationId: string | null;
47
+ }
48
+
49
+ interface Context {
50
+ user: User | null | undefined;
51
+ headers: Headers;
52
+ resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
53
+ prisma: any;
54
+ }
55
+
56
+ const authed = os.$context<Context>().use(async (opt) => {
57
+ const { context } = opt;
58
+ let user = context.user;
59
+ if (!user && context.headers && context.resolveUser) {
60
+ user = await context.resolveUser(context.headers, context.prisma);
61
+ }
62
+ if (!user) {
63
+ const { ORPCError } = await import("@orpc/server");
64
+ throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
65
+ }
66
+ // Audit attribution: set the audit session context so writes (agreements.update)
67
+ // record the actor (app.current_user_name) — see auth-router.ts for the rationale.
68
+ const u = user as { id: string; name?: string | null; email?: string };
69
+ await setRlsContext(context.prisma, {
70
+ userId: u.id,
71
+ userName: u.name || u.email,
72
+ organisationId: user.organisationId,
73
+ });
74
+ try {
75
+ return await opt.next({ context: { ...context, user } });
76
+ } finally {
77
+ await resetRlsContext(context.prisma);
78
+ }
79
+ });
80
+
81
+ interface Recipient {
82
+ email: string;
83
+ name: string;
84
+ }
85
+
86
+ /** Parse the multi-party roster, or fall back to the single signer columns. */
87
+ function recipientsFor(agreement: {
88
+ signature_data: unknown;
89
+ signer_email: unknown;
90
+ signer_name: unknown;
91
+ }): Recipient[] {
92
+ const raw = agreement.signature_data;
93
+ if (typeof raw === "string" && raw.trim().startsWith("{")) {
94
+ try {
95
+ const o = JSON.parse(raw) as { parties?: Array<Record<string, unknown>> };
96
+ if (Array.isArray(o.parties)) {
97
+ return o.parties
98
+ .map((p) => ({
99
+ email: String(p.email ?? "").trim(),
100
+ name: String(p.name ?? "").trim(),
101
+ }))
102
+ .filter((r) => r.email.length > 0);
103
+ }
104
+ } catch {
105
+ /* fall through to single-signer */
106
+ }
107
+ }
108
+ const email = String(agreement.signer_email ?? "").trim();
109
+ return email ? [{ email, name: String(agreement.signer_name ?? "").trim() }] : [];
110
+ }
111
+
112
+ /** Extract the bare address from a "Display <addr@domain>" sender string. */
113
+ function bareAddress(sender: string): string {
114
+ const m = sender.match(/<([^>]+)>/);
115
+ return (m?.[1] ?? sender).trim();
116
+ }
117
+
118
+ /**
119
+ * Resolve the per-org FROM + reply_to. FROM uses the org display name on the
120
+ * platform's verified Resend address; reply_to is the org's contact email.
121
+ */
122
+ async function resolveSender(
123
+ prisma: any,
124
+ organisationId: string,
125
+ ): Promise<{ from: string; replyTo?: string; orgName: string }> {
126
+ const templates = await loadRegistryConfig(
127
+ "config",
128
+ "email-templates",
129
+ v.looseObject({ sender: v.optional(v.string()) }),
130
+ ).catch(() => ({ sender: undefined }) as { sender?: string });
131
+ const verifiedAddr = bareAddress(
132
+ templates.sender ?? (await getDefaultEmailSender()),
133
+ );
134
+
135
+ const profile = await prisma.organisation_profile.findFirst({
136
+ where: { id: organisationId },
137
+ select: {
138
+ company_friendly_name: true,
139
+ company_name: true,
140
+ contact_email: true,
141
+ },
142
+ });
143
+ const orgName =
144
+ (profile?.company_friendly_name ||
145
+ profile?.company_name ||
146
+ "") as string;
147
+ const display = orgName ? `${orgName} via Sign` : "Sign";
148
+ return {
149
+ from: `${display} <${verifiedAddr}>`,
150
+ replyTo: (profile?.contact_email as string) || undefined,
151
+ orgName: orgName || "the sender",
152
+ };
153
+ }
154
+
155
+
156
+ const sendSchema = v.object({
157
+ agreementId: v.string(),
158
+ /** Public base URL of the signing site, e.g. https://sign-dev.scala.business (no trailing slash). */
159
+ baseUrl: v.string(),
160
+ });
161
+
162
+ export const agreementSendRouter = {
163
+ send: authed.input(sendSchema).handler(async ({ input, context }) => {
164
+ const log = getAppLogger();
165
+ const prisma = context.prisma;
166
+ const agreementId = input.agreementId.trim();
167
+ const baseUrl = input.baseUrl.trim().replace(/\/+$/, "");
168
+ if (!agreementId || !baseUrl) {
169
+ return { ok: false as const, error: "agreementId and baseUrl are required" };
170
+ }
171
+
172
+ try {
173
+ return await runSend(prisma, agreementId, baseUrl, log);
174
+ } catch (err) {
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ log.error(`[agreementSend] unexpected failure: ${message}`);
177
+ return { ok: false as const, error: message };
178
+ }
179
+ }),
180
+ };
181
+
182
+ async function runSend(
183
+ prisma: any,
184
+ agreementId: string,
185
+ baseUrl: string,
186
+ log: ReturnType<typeof getAppLogger>,
187
+ ) {
188
+ const agreement = await prisma.agreements.findFirst({
189
+ where: { id: agreementId },
190
+ select: {
191
+ id: true,
192
+ name: true,
193
+ status: true,
194
+ organisation_id: true,
195
+ page_id: true,
196
+ signer_email: true,
197
+ signer_name: true,
198
+ signature_data: true,
199
+ },
200
+ });
201
+ if (!agreement) return { ok: false as const, error: "Agreement not found" };
202
+
203
+ const page = agreement.page_id
204
+ ? await prisma.builda_pages.findFirst({
205
+ where: { id: agreement.page_id },
206
+ select: { path: true },
207
+ })
208
+ : null;
209
+ if (!page?.path) {
210
+ return { ok: false as const, error: "Agreement has no signing page" };
211
+ }
212
+ const url = `${baseUrl}${page.path}`;
213
+
214
+ const recipients = recipientsFor(agreement);
215
+ if (recipients.length === 0) {
216
+ return { ok: false as const, error: "Agreement has no signer emails" };
217
+ }
218
+
219
+ const sender = await resolveSender(prisma, agreement.organisation_id);
220
+
221
+ // Initialise the Resend client from the encrypted secret row, read live
222
+ // via the registry helper (registry-via-helper) then decrypted.
223
+ const rawSecret = await loadRegistryConfig(
224
+ "secret",
225
+ "resend-api-key",
226
+ v.looseObject({ token: v.optional(v.string()) }),
227
+ ).catch(() => null);
228
+ if (!rawSecret) {
229
+ return { ok: false as const, error: "Resend API key not configured" };
230
+ }
231
+ const decrypted = decryptSecretConfig(rawSecret as SecretConfigLike) as { token?: string };
232
+ if (!decrypted.token) {
233
+ return { ok: false as const, error: "Resend API key missing token" };
234
+ }
235
+ initResendClient(decrypted.token);
236
+
237
+ const sent: Array<{ email: string; ok: boolean; id?: string; error?: string }> =
238
+ [];
239
+ for (const r of recipients) {
240
+ // Magic-link: a per-party signed token so this recipient opens the
241
+ // document directly — no email-entry gate, no OTP. The token is scoped
242
+ // to {agreementId, this email}; /api/agreement-magic verifies it, sets
243
+ // the ag_token cookie, and redirects into the page. Falls back to the
244
+ // plain page URL when the token secret is unavailable.
245
+ const magicToken = signAgreementAccessToken(
246
+ agreementId,
247
+ r.email,
248
+ Date.now(),
249
+ await getEmailLinkTtlMs(),
250
+ );
251
+ const recipientUrl = magicToken
252
+ ? `${baseUrl}/api/agreement-magic?a=${encodeURIComponent(agreementId)}&t=${encodeURIComponent(magicToken)}&to=${encodeURIComponent(page.path)}`
253
+ : url;
254
+ // Subject + body come from the `agreement-signature-request`
255
+ // email_template row, never from literals here
256
+ // (email-template-registry-type): changing the copy is a registry_edit,
257
+ // not a deploy, and an org can override the row per tenant. The `from`
258
+ // stays computed — it is the verified sender identity, not copy.
259
+ const tpl = applyTemplateVars(
260
+ await lookupTemplate("agreement-signature-request"),
261
+ {
262
+ hello: r.name ? `Hi ${r.name},` : "Hello,",
263
+ orgName: sender.orgName,
264
+ agreementName: agreement.name,
265
+ url: recipientUrl,
266
+ },
267
+ );
268
+ const res = await sendEmail({
269
+ from: sender.from,
270
+ to: r.email,
271
+ reply_to: sender.replyTo,
272
+ subject: tpl.subject,
273
+ html: tpl.bodyHtml,
274
+ text: tpl.bodyText,
275
+ });
276
+ sent.push({
277
+ email: r.email,
278
+ ok: res.success,
279
+ id: res.id,
280
+ error: res.error,
281
+ });
282
+ if (!res.success) {
283
+ log.error(
284
+ `[agreementSend] failed to ${r.email}: ${res.error ?? "unknown"}`,
285
+ );
286
+ }
287
+ }
288
+
289
+ const anySent = sent.some((s) => s.ok);
290
+ const now = new Date().toISOString();
291
+ if (anySent) {
292
+ await prisma.agreements.update({
293
+ where: { id: agreementId },
294
+ data: {
295
+ sent_to: recipients.map((r) => r.email).join(", "),
296
+ sent_at: now,
297
+ ...(agreement.status === "draft" ? { status: "sent" } : {}),
298
+ },
299
+ });
300
+ }
301
+
302
+ return { ok: anySent, sentAt: anySent ? now : null, url, sent };
303
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * `fn.aiHealth` — the AI-availability probe the shared chat hook
7
+ * (`useAIHealthCheck`) polls before showing the chat's health dot. It resolves
8
+ * the same DB-driven default model the chat engine uses (`ai-default-model`
9
+ * config → an active `llm_model` row) and reports `{status, mcp, gateway}`;
10
+ * `status:"ok"` means the chat has a usable model configured.
11
+ *
12
+ * Unauthed (a public GPT surface polls it too) and a LIGHTWEIGHT registry read
13
+ * via the request `prisma` — deliberately NO `@teamscala/chat-completion` import,
14
+ * so wiring this universal procedure never pulls the AI stack into every
15
+ * service. Registered into the `fn` namespace by registerFnRouters, so any
16
+ * chat-capable service answers it instead of 404-ing.
17
+ */
18
+
19
+ import { os } from "@orpc/server";
20
+
21
+ interface Context {
22
+ prisma: {
23
+ registry_entries: {
24
+ findFirst: (args: unknown) => Promise<{ config?: unknown; id?: string } | null>;
25
+ };
26
+ };
27
+ }
28
+
29
+ export interface AiHealthResponse {
30
+ status: "ok" | "error";
31
+ mcp: boolean;
32
+ gateway: boolean;
33
+ error?: string;
34
+ }
35
+
36
+ export const aiHealthHandler = os
37
+ .$context<Context>()
38
+ .handler(async ({ context }): Promise<AiHealthResponse> => {
39
+ try {
40
+ const cfg = await context.prisma.registry_entries.findFirst({
41
+ where: { type: "config", slug: "ai-default-model", is_active: true },
42
+ select: { config: true },
43
+ });
44
+ const modelKey = (cfg?.config as { model?: string } | null)?.model;
45
+ if (!modelKey) {
46
+ return { status: "error", mcp: false, gateway: false, error: "No default AI model configured" };
47
+ }
48
+ const model = await context.prisma.registry_entries.findFirst({
49
+ where: { type: "llm_model", slug: modelKey, is_active: true },
50
+ select: { id: true },
51
+ });
52
+ if (!model) {
53
+ return {
54
+ status: "error",
55
+ mcp: false,
56
+ gateway: false,
57
+ error: `Default AI model '${modelKey}' has no active llm_model row`,
58
+ };
59
+ }
60
+ return { status: "ok", mcp: true, gateway: true };
61
+ } catch (err) {
62
+ return { status: "error", mcp: false, gateway: false, error: (err as Error).message };
63
+ }
64
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for auth checks (isOrgAdmin, resolveAdminOverride).
7
+ * Extracted from @teamscala/orpc/fn-router — primitives tier must not
8
+ * contain application-specific auth logic.
9
+ */
10
+
11
+ import { os } from "@orpc/server";
12
+ import { createCache } from "@teamscala/cache/create-cache";
13
+ import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
14
+
15
+ interface User {
16
+ id: string;
17
+ name?: string | null;
18
+ email?: string;
19
+ organisationId: string | null;
20
+ }
21
+
22
+ interface Context {
23
+ user: User | null | undefined;
24
+ headers: Headers;
25
+ resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
26
+ prisma: any;
27
+ orgId?: string | null;
28
+ }
29
+
30
+ const authed = os.$context<Context>().use(async (opt) => {
31
+ const { context } = opt;
32
+ let user: User | null | undefined = context.user;
33
+ if (!user && context.headers && context.resolveUser) {
34
+ user = await context.resolveUser(context.headers, context.prisma);
35
+ }
36
+ if (!user) {
37
+ const { ORPCError } = await import("@orpc/server");
38
+ throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
39
+ }
40
+ const orgId = user.organisationId || context.orgId;
41
+ // Audit attribution: fn procedures are custom-named writes/actions
42
+ // (deleteEmpty, etc.), so detecting "is write" by method name is unreliable —
43
+ // set the audit session context (app.current_user_name) for every authed call
44
+ // so the DB audit trigger records the actor. Same bridge the generated CRUD
45
+ // protectedProcedure uses; reset in finally to avoid leaking onto the pooled
46
+ // connection. Without this, fn.* writes record an empty actor ("by .").
47
+ await setRlsContext(context.prisma, {
48
+ userId: user.id,
49
+ userName: user.name || user.email,
50
+ organisationId: orgId,
51
+ });
52
+ try {
53
+ return await opt.next({ context: { ...context, user, orgId } });
54
+ } finally {
55
+ await resetRlsContext(context.prisma);
56
+ }
57
+ });
58
+
59
+ const adminCache = createCache<boolean>("service-boot:org-admin", {
60
+ ttlMs: 5 * 60 * 1000,
61
+ maxSize: 2000,
62
+ });
63
+
64
+ async function isOrgAdminCached(
65
+ userId: string,
66
+ organisationId: string,
67
+ prisma: any,
68
+ ): Promise<boolean> {
69
+ const cacheKey = `${userId}:${organisationId}`;
70
+ const cached = adminCache.get(cacheKey);
71
+ if (cached !== undefined) return cached;
72
+
73
+ try {
74
+ const membership = await prisma.user_organisation_memberships.findFirst({
75
+ where: {
76
+ user_id: userId,
77
+ organisation_id: organisationId,
78
+ role: { in: ["admin", "owner"] },
79
+ },
80
+ select: { id: true },
81
+ });
82
+ const result = !!membership;
83
+ adminCache.set(cacheKey, result);
84
+ return result;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export const authRouter = {
91
+ isOrgAdmin: authed.handler(async ({ input, context }) => {
92
+ const data = input as { userId: string; organisationId: string };
93
+ if (!data.userId || !data.organisationId) return false;
94
+ return isOrgAdminCached(data.userId, data.organisationId, context.prisma);
95
+ }),
96
+ resolveAdminOverride: authed.handler(async ({ input, context }) => {
97
+ const data = input as {
98
+ targetUserId?: string;
99
+ adminOverride?: boolean;
100
+ };
101
+ const targetUserId = data.targetUserId as string | undefined;
102
+ if (!targetUserId) return null;
103
+ if (targetUserId === (context.user as any)?.id) return null;
104
+ if (!data.adminOverride) {
105
+ throw new Error(
106
+ `This tool does not support admin override. Remove targetUserId from the request.`,
107
+ );
108
+ }
109
+ const userId = (context.user as any)?.id;
110
+ const organisationId = (context as any).orgId;
111
+ if (!userId || !organisationId) {
112
+ throw new Error(
113
+ `Admin override requires both user and organisation context.`,
114
+ );
115
+ }
116
+ const admin = await isOrgAdminCached(
117
+ userId,
118
+ organisationId,
119
+ context.prisma,
120
+ );
121
+ if (!admin) {
122
+ throw new Error(
123
+ `Access denied: you must be an org admin or owner to view another user's data.`,
124
+ );
125
+ }
126
+ return targetUserId;
127
+ }),
128
+ };
129
+
130
+ export type { Context, User };
131
+ export { authed };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * `fn.cacheAdmin` — ORPC procedures for in-process cache enable/disable/flush.
7
+ * Replaces the former `/api/data/cache-monitor/*` HANDLER_MAP entries
8
+ * (orpc-is-the-only-api: every API surface is a typed ORPC procedure, never a
9
+ * hand-written Hono route). Calls through @teamscala/cache/registry — the
10
+ * platform-singleton cache surface (cache-is-the-only-cache).
11
+ */
12
+
13
+ import { os } from "@orpc/server";
14
+ import * as v from "valibot";
15
+ import { authed } from "./auth-router.ts";
16
+
17
+ const nameSchema = v.object({
18
+ name: v.pipe(v.string(), v.minLength(1)),
19
+ });
20
+
21
+ export const cacheAdminRouter = {
22
+ enable: authed
23
+ .input(nameSchema)
24
+ .handler(async ({ input }) => {
25
+ const { cacheRegistry } = await import("@teamscala/cache/registry");
26
+ cacheRegistry.enable(input.name);
27
+ return { ok: true, name: input.name };
28
+ }),
29
+
30
+ disable: authed
31
+ .input(nameSchema)
32
+ .handler(async ({ input }) => {
33
+ const { cacheRegistry } = await import("@teamscala/cache/registry");
34
+ cacheRegistry.disable(input.name);
35
+ return { ok: true, name: input.name };
36
+ }),
37
+
38
+ flush: authed
39
+ .input(v.object({ name: v.optional(v.string()) }))
40
+ .handler(async ({ input }) => {
41
+ const { cacheRegistry } = await import("@teamscala/cache/registry");
42
+ if (input.name) {
43
+ cacheRegistry.flush(input.name);
44
+ return { flushed: 1 };
45
+ }
46
+ const count = cacheRegistry.getAll().length;
47
+ cacheRegistry.flush();
48
+ return { flushed: count };
49
+ }),
50
+ };