@teamscala/service-boot 0.23.388 → 0.23.391

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.388",
9
+ "version": "0.23.391",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -3,20 +3,26 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Cached reader for the `email-defaults` config row's `default_sender` — the
7
- * from-address for outbound transactional email (agreement/proposal/assessment
8
- * senders). Replaces the former process.env.DEFAULT_EMAIL_SENDER read
9
- * (configured-primitives: the registry row is the source of truth, not env).
6
+ * Hot snapshot of `email-defaults.default_sender` (outbound transactional
7
+ * from-address). Replaces the former process.env.DEFAULT_EMAIL_SENDER read.
8
+ * Invalidated by NOTIFY on `registry_config_changed`; `staleAfterMs` is the
9
+ * safety ceiling for an upstream regression that drops notifications.
10
10
  */
11
+ import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
11
12
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
12
13
  import * as v from "valibot";
13
14
 
14
15
  const FALLBACK_SENDER = "noreply@gateway.scala.business";
15
- let cached: string | undefined;
16
+ const defaultSenderCache = createNotifyCache<string>({
17
+ name: "service-boot:default-email-sender",
18
+ load: async () => {
19
+ const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
20
+ return (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
21
+ },
22
+ invalidateOn: ["registry_config_changed"],
23
+ staleAfterMs: 5 * 60 * 1000,
24
+ });
16
25
 
17
26
  export async function getDefaultEmailSender(): Promise<string> {
18
- if (cached !== undefined) return cached;
19
- const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
20
- cached = (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
21
- return cached;
27
+ return defaultSenderCache.get();
22
28
  }
@@ -3,21 +3,26 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Cached reader for the `email-links` config row's `email_link_ttl_ms` — the
7
- * validity window for agreement/proposal email-access magic links (dedup'd
8
- * across the 3 fn-routers that mint them). Replaces the former per-file
9
- * hardcoded EMAIL_LINK_TTL_MS constant (config-constants: the registry row is
10
- * the source of truth, not the literal).
6
+ * Hot snapshot of `email-links.email_link_ttl_ms` (magic-link validity
7
+ * window, dedup'd across the 3 fn-routers that mint them). Replaces the
8
+ * former per-file EMAIL_LINK_TTL_MS constant. Invalidated by NOTIFY on
9
+ * `registry_config_changed`.
11
10
  */
11
+ import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
12
12
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
13
13
  import * as v from "valibot";
14
14
 
15
15
  const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
16
- let cached: number | undefined;
16
+ const emailLinkTtlCache = createNotifyCache<number>({
17
+ name: "service-boot:email-link-ttl",
18
+ load: async () => {
19
+ const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
20
+ return (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
21
+ },
22
+ invalidateOn: ["registry_config_changed"],
23
+ staleAfterMs: 5 * 60 * 1000,
24
+ });
17
25
 
18
26
  export async function getEmailLinkTtlMs(): Promise<number> {
19
- if (cached !== undefined) return cached;
20
- const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
21
- cached = (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
22
- return cached;
27
+ return emailLinkTtlCache.get();
23
28
  }
@@ -25,10 +25,11 @@ import * as v from "valibot";
25
25
  import { getAppLogger } from "@teamscala/logger/app-loggers";
26
26
  import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
27
27
  import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
28
+ import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-sender";
28
29
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
29
30
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
31
+ import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
30
32
  import { recordEvent } from "@teamscala/event-log/record-event";
31
- import { getDefaultEmailSender } from "./email-defaults.ts";
32
33
 
33
34
  interface Context {
34
35
  prisma: any;
@@ -106,9 +107,9 @@ function bandForScore(
106
107
 
107
108
  /**
108
109
  * Best-effort branded Authority Index Report email. Self-contained: loads its
109
- * own Resend secret, swallows every error — a send failure is logged and NEVER
110
- * undoes the submit (the agreement-completion-email pattern). Fire-and-forget
111
- * from the submit handler.
110
+ * own Resend secret, swallows every error. Sender identity + brand tokens
111
+ * come from resolveEmailSender (per-org email_sender row). On Resend refusal
112
+ * the call falls back to the platform sender and logs the reason.
112
113
  */
113
114
  async function sendAuthorityReportEmail(opts: {
114
115
  to: string;
@@ -117,6 +118,9 @@ async function sendAuthorityReportEmail(opts: {
117
118
  bandLabel: string;
118
119
  categoryScores: Record<string, number>;
119
120
  ctaUrl?: string;
121
+ organisationId?: string | null;
122
+ brand?: import("@teamscala/email/identity/resolve-email-sender").OrgBrandTokens;
123
+ prisma: any;
120
124
  }): Promise<void> {
121
125
  const log = getAppLogger();
122
126
  const rawSecret = await loadRegistryConfig(
@@ -132,17 +136,26 @@ async function sendAuthorityReportEmail(opts: {
132
136
  return;
133
137
  }
134
138
  initResendClient(decrypted.token);
135
- const from = await getDefaultEmailSender();
139
+
140
+ const sender = await resolveEmailSender(opts.organisationId ?? null);
141
+ const primary = sender.brand.primaryColor;
142
+ const muted = "#6b7280"; // structural neutral for body labels
143
+ const logoUrl = sender.brand.logoUrl;
144
+ const logoHtml =
145
+ logoUrl && /^https?:\/\//.test(logoUrl)
146
+ ? `<p style="margin:0 0 20px"><img src="${logoUrl}" alt="${sender.brand.orgName || ""}" style="max-height:56px;display:block"></p>`
147
+ : "";
148
+
136
149
  // Subject + body from the `authority-index-report` email_template row, not
137
- // literals here (email-template-registry-type). The per-pillar rows and the
138
- // optional CTA are the only DYNAMIC markup, so they are rendered here and
139
- // passed as merge vars — the row still owns all the copy and the layout.
150
+ // literals here (email-template-registry-type). Dynamic markup is rendered
151
+ // here and passed as merge vars — the row owns copy + layout; colours +
152
+ // logo come from the org brand tokens, NOT inline literals.
140
153
  const pillarRows = (
141
154
  Object.entries(opts.categoryScores) as Array<[string, number]>
142
155
  )
143
156
  .map(
144
157
  ([name, score]) =>
145
- `<tr><td style="padding:6px 0;color:#374151">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:#111827">${score} / 100</td></tr>`,
158
+ `<tr><td style="padding:6px 0;color:${muted}">${name}</td><td style="padding:6px 0;text-align:right;font-weight:600;color:${primary}">${score} / 100</td></tr>`,
146
159
  )
147
160
  .join("");
148
161
  const pillarsText = (
@@ -156,27 +169,80 @@ async function sendAuthorityReportEmail(opts: {
156
169
  bandLabel: opts.bandLabel,
157
170
  pillarRows,
158
171
  pillarsText,
172
+ brandPrimaryColor: primary,
173
+ brandAccentColor: sender.brand.accentColor ?? primary,
174
+ brandLogoHtml: logoHtml,
175
+ orgName: sender.brand.orgName,
159
176
  ctaHtml: opts.ctaUrl
160
- ? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:#111827;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
177
+ ? `<p style="margin:24px 0 0"><a href="${opts.ctaUrl}" style="display:inline-block;background:${primary};color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px">Book your Authority Growth Session</a></p>`
161
178
  : "",
162
179
  ctaText: opts.ctaUrl ? `\nBook your Authority Growth Session: ${opts.ctaUrl}` : "",
163
180
  });
164
- const res = await sendEmail({
165
- from,
166
- to: opts.to,
167
- subject: tpl.subject,
168
- html: tpl.bodyHtml,
169
- text: tpl.bodyText,
170
- });
181
+
182
+ const sendOnce = async (fromHeader: string, replyToHeader: string | undefined) =>
183
+ sendEmail({
184
+ from: fromHeader,
185
+ to: opts.to,
186
+ subject: tpl.subject,
187
+ html: tpl.bodyHtml,
188
+ text: tpl.bodyText,
189
+ reply_to: replyToHeader,
190
+ });
191
+
192
+ let res = await sendOnce(sender.from, sender.replyTo);
193
+ let usedFrom = sender.from;
194
+ if (!res.success) {
195
+ // Resend refused the org sender (likely unverified domain). Fall back
196
+ // to the platform sender; never fail because a verification lookup
197
+ // failed — the resolver returns the row, the failure is at Resend.
198
+ const platformRow = await loadRegistryConfig(
199
+ "config",
200
+ "email-defaults",
201
+ v.looseObject({ default_sender: v.optional(v.string()) }),
202
+ ).catch(() => null);
203
+ const fallbackFrom =
204
+ (platformRow?.default_sender as string | undefined)?.trim() ||
205
+ "scala <noreply@gateway.scala.business>";
206
+ log.warn(
207
+ `[authority-report-email] org sender ${sender.from} refused by Resend (${res.error ?? "unknown"}); falling back to platform ${fallbackFrom}`,
208
+ );
209
+ res = await sendOnce(fallbackFrom, undefined);
210
+ usedFrom = fallbackFrom;
211
+ }
212
+
171
213
  if (res.success) {
172
214
  log.info(
173
- `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""}`,
215
+ `[authority-report-email] sent to ${opts.to}${res.id ? ` (id ${res.id})` : ""} from=${usedFrom}${sender.isOrgIdentity ? "" : " (platform fallback)"}`,
174
216
  );
175
217
  } else {
176
218
  log.error(
177
219
  `[authority-report-email] failed to ${opts.to}: ${res.error ?? "unknown"}`,
178
220
  );
179
221
  }
222
+
223
+ // email-send-audited: best-effort audit row. Guarded so a stale prisma
224
+ // client (scala-quiz schema predates email_send_log) skips cleanly.
225
+ try {
226
+ const auditPrisma = opts.prisma as {
227
+ email_send_log?: { create: (args: unknown) => Promise<unknown> };
228
+ };
229
+ if (auditPrisma?.email_send_log) {
230
+ await auditPrisma.email_send_log.create({
231
+ data: {
232
+ to_address: opts.to,
233
+ template_slug: "authority-index-report",
234
+ status: res.success ? "sent" : "failed",
235
+ provider_message_id: res.id ?? null,
236
+ error: res.success ? null : (res.error ?? "unknown"),
237
+ from_address: usedFrom,
238
+ },
239
+ });
240
+ }
241
+ } catch (auditErr) {
242
+ log.warn(
243
+ `[authority-report-email] audit row write failed: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
244
+ );
245
+ }
180
246
  }
181
247
 
182
248
  function scoreAnswers(
@@ -375,6 +441,21 @@ export const publicAssessmentRouter = {
375
441
  (typeof assessment.cta_url === "string" && assessment.cta_url) ||
376
442
  undefined;
377
443
  if (band) {
444
+ const prisma = (context as { prisma: unknown }).prisma;
445
+ const brandRow = opts.organisationId
446
+ ? await fetchOrgBrand(
447
+ prisma as Parameters<typeof fetchOrgBrand>[0],
448
+ opts.organisationId,
449
+ ).catch(() => null)
450
+ : null;
451
+ const brand = brandRow
452
+ ? {
453
+ orgName: brandRow.companyName ?? "",
454
+ primaryColor: brandRow.primaryColor,
455
+ accentColor: brandRow.accentColor ?? null,
456
+ logoUrl: brandRow.logo.light ?? null,
457
+ }
458
+ : undefined;
378
459
  void sendAuthorityReportEmail({
379
460
  to: email,
380
461
  firstName,
@@ -382,6 +463,9 @@ export const publicAssessmentRouter = {
382
463
  bandLabel: band.label,
383
464
  categoryScores,
384
465
  ctaUrl,
466
+ organisationId: opts.organisationId,
467
+ brand,
468
+ prisma,
385
469
  }).catch((e) =>
386
470
  log.error(
387
471
  `[publicAssessment.submit] report email non-fatal: ${e instanceof Error ? e.message : String(e)}`,
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Locks the mount candidate-chain regression (2026-08-24): scala-agents-ui
7
+ * crash-looped at boot because @teamscala/cli-session exports ./boot/mount.ts
8
+ * and not ./mount.ts. The chain already listed boot/mount.ts as a fallback,
9
+ * but Bun names an unexported subpath by its BARE package, so the first
10
+ * candidate's miss was classified as a real error and boot died before the
11
+ * fallback ran. The whole value of the chain is that candidate 1 may be absent.
12
+ */
13
+
14
+ import { describe, expect, it } from "bun:test";
15
+ import { barePackageOf, isModuleNotFound } from "./module-not-found";
16
+
17
+ describe("barePackageOf", () => {
18
+ it("keeps the scope for scoped packages", () => {
19
+ expect(barePackageOf("@teamscala/cli-session/mount.ts")).toBe(
20
+ "@teamscala/cli-session",
21
+ );
22
+ expect(barePackageOf("@teamscala/cli-session")).toBe(
23
+ "@teamscala/cli-session",
24
+ );
25
+ });
26
+
27
+ it("takes the first segment for unscoped packages", () => {
28
+ expect(barePackageOf("hono/mount.ts")).toBe("hono");
29
+ expect(barePackageOf("hono")).toBe("hono");
30
+ });
31
+ });
32
+
33
+ describe("isModuleNotFound", () => {
34
+ // The exact shape Bun emits for an unexported subpath: it names the
35
+ // PACKAGE, never the subpath that was asked for.
36
+ const bunSubpathMiss = {
37
+ message:
38
+ "Cannot find package '@teamscala/cli-session' imported from /srv/node_modules/@teamscala/service-boot/src/mount-server-packages.ts",
39
+ };
40
+
41
+ it("treats an unexported subpath as not-found so the chain falls through", () => {
42
+ expect(
43
+ isModuleNotFound(bunSubpathMiss, "@teamscala/cli-session/mount.ts"),
44
+ ).toBe(true);
45
+ });
46
+
47
+ it("still matches when the error names the full specifier", () => {
48
+ expect(
49
+ isModuleNotFound(
50
+ { message: "Cannot find module '@teamscala/x/mount.ts'" },
51
+ "@teamscala/x/mount.ts",
52
+ ),
53
+ ).toBe(true);
54
+ });
55
+
56
+ it("does NOT swallow a transitive miss of a DIFFERENT module", () => {
57
+ // A file inside the candidate failing to resolve something else must
58
+ // surface as a real error, or the true cause hides behind "not found".
59
+ expect(
60
+ isModuleNotFound(
61
+ { message: "Cannot find package '@teamscala/some-other-dep'" },
62
+ "@teamscala/cli-session/mount.ts",
63
+ ),
64
+ ).toBe(false);
65
+ });
66
+
67
+ it("does NOT swallow a syntax or runtime error", () => {
68
+ expect(
69
+ isModuleNotFound(
70
+ new SyntaxError("Unexpected token"),
71
+ "@teamscala/cli-session/mount.ts",
72
+ ),
73
+ ).toBe(false);
74
+ });
75
+
76
+ it("reads non-Error throwables defensively (Bun ResolveMessage is not an Error)", () => {
77
+ expect(
78
+ isModuleNotFound(
79
+ "Cannot find package '@teamscala/cli-session'",
80
+ "@teamscala/cli-session/mount.ts",
81
+ ),
82
+ ).toBe(true);
83
+ });
84
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Classifies a failed dynamic import as "this candidate is absent" vs a real
7
+ * error, for the server_package mount candidate chain. Extracted from
8
+ * mount-server-packages so the classification is testable: as a closure it
9
+ * could not be exercised, and it shipped wrong.
10
+ */
11
+
12
+ /** `@scope/name/sub/path.ts` -> `@scope/name`; `name/sub` -> `name`. */
13
+ export function barePackageOf(specifier: string): string {
14
+ const parts = specifier.split("/");
15
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
16
+ }
17
+
18
+ /**
19
+ * True when the import failed because `requestedPath` itself is absent.
20
+ *
21
+ * Bun reports an unexported SUBPATH as `Cannot find package '<bare>'`, naming
22
+ * the package rather than the subpath, so the bare name of THIS candidate
23
+ * counts as not-found. Without that the first candidate's miss is
24
+ * misclassified as fatal and the fallbacks never run (measured 2026-08-24:
25
+ * scala-agents-ui crash-looped because cli-session exports ./boot/mount.ts and
26
+ * not ./mount.ts). A transitive miss naming a DIFFERENT module still surfaces.
27
+ */
28
+ export function isModuleNotFound(err: unknown, requestedPath: string): boolean {
29
+ const msg =
30
+ (err as { message?: string })?.message ??
31
+ (typeof err === "string" ? err : String(err));
32
+ const bare = barePackageOf(requestedPath);
33
+ return (
34
+ msg.includes(`Cannot find module '${requestedPath}'`) ||
35
+ msg.includes(`Cannot find package '${requestedPath}'`) ||
36
+ msg.includes(`Cannot find module '${bare}'`) ||
37
+ msg.includes(`Cannot find package '${bare}'`)
38
+ );
39
+ }
@@ -20,6 +20,7 @@ import {
20
20
  type MountRegistry,
21
21
  } from "@teamscala/os/runtime-contracts/mount-context";
22
22
  import type { Hono } from "hono";
23
+ import { isModuleNotFound } from "./module-not-found";
23
24
 
24
25
  type ServerPackageMount = (ctx: MountContext) => Promise<void> | void;
25
26
 
@@ -68,19 +69,7 @@ export async function mountServerPackages(
68
69
  return mountRegistry;
69
70
  }
70
71
 
71
- const isModuleNotFound = (err: unknown, requestedPath: string): boolean => {
72
- // Bun's ResolveMessage is not an Error subclass — read message defensively.
73
- // Match ONLY when the missing module is the candidate path itself; transitive
74
- // not-found errors (e.g. a file inside the candidate fails to resolve a
75
- // different module) must surface, not be swallowed as "not found here".
76
- const msg =
77
- (err as { message?: string })?.message ??
78
- (typeof err === "string" ? err : String(err));
79
- return (
80
- msg.includes(`Cannot find module '${requestedPath}'`) ||
81
- msg.includes(`Cannot find package '${requestedPath}'`)
82
- );
83
- };
72
+
84
73
 
85
74
  for (const pkg of serverPackages) {
86
75
  try {