@teamscala/service-boot 0.23.391 → 0.23.395

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.391",
9
+ "version": "0.23.395",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -34,9 +34,11 @@
34
34
  "@teamscala/generic-registry": "*",
35
35
  "@teamscala/http": "*",
36
36
  "@teamscala/logger": "*",
37
+ "@teamscala/notify-cache": "*",
37
38
  "@teamscala/object-storage": "*",
38
39
  "@teamscala/orpc": "*",
39
40
  "@teamscala/os": "*",
41
+ "@teamscala/publish-registry": "*",
40
42
  "@teamscala/realtime-utils": "*",
41
43
  "@teamscala/resolver-engine": "*",
42
44
  "@teamscala/timing": "*",
@@ -562,6 +564,14 @@
562
564
  "types": "./src/load-service-prisma-client.d.ts",
563
565
  "default": "./src/load-service-prisma-client.ts"
564
566
  },
567
+ "./module-not-found": {
568
+ "types": "./src/module-not-found.d.ts",
569
+ "default": "./src/module-not-found.ts"
570
+ },
571
+ "./module-not-found.ts": {
572
+ "types": "./src/module-not-found.d.ts",
573
+ "default": "./src/module-not-found.ts"
574
+ },
565
575
  "./mount": {
566
576
  "types": "./src/mount.d.ts",
567
577
  "default": "./src/mount.ts"
@@ -119,7 +119,6 @@ async function sendAuthorityReportEmail(opts: {
119
119
  categoryScores: Record<string, number>;
120
120
  ctaUrl?: string;
121
121
  organisationId?: string | null;
122
- brand?: import("@teamscala/email/identity/resolve-email-sender").OrgBrandTokens;
123
122
  prisma: any;
124
123
  }): Promise<void> {
125
124
  const log = getAppLogger();
@@ -137,7 +136,23 @@ async function sendAuthorityReportEmail(opts: {
137
136
  }
138
137
  initResendClient(decrypted.token);
139
138
 
140
- const sender = await resolveEmailSender(opts.organisationId ?? null);
139
+ const brandRow =
140
+ opts.organisationId && opts.prisma
141
+ ? await fetchOrgBrand(
142
+ opts.prisma as Parameters<typeof fetchOrgBrand>[0],
143
+ opts.organisationId,
144
+ ).catch(() => null)
145
+ : null;
146
+ const sender = await resolveEmailSender(opts.organisationId ?? null, {
147
+ brand: brandRow
148
+ ? {
149
+ orgName: brandRow.companyName ?? "",
150
+ primaryColor: brandRow.primaryColor,
151
+ accentColor: brandRow.accentColor ?? null,
152
+ logoUrl: brandRow.logo.light ?? null,
153
+ }
154
+ : undefined,
155
+ });
141
156
  const primary = sender.brand.primaryColor;
142
157
  const muted = "#6b7280"; // structural neutral for body labels
143
158
  const logoUrl = sender.brand.logoUrl;
@@ -441,21 +456,6 @@ export const publicAssessmentRouter = {
441
456
  (typeof assessment.cta_url === "string" && assessment.cta_url) ||
442
457
  undefined;
443
458
  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;
459
459
  void sendAuthorityReportEmail({
460
460
  to: email,
461
461
  firstName,
@@ -463,8 +463,7 @@ export const publicAssessmentRouter = {
463
463
  bandLabel: band.label,
464
464
  categoryScores,
465
465
  ctaUrl,
466
- organisationId: opts.organisationId,
467
- brand,
466
+ organisationId: orgId,
468
467
  prisma,
469
468
  }).catch((e) =>
470
469
  log.error(
@@ -24,9 +24,10 @@
24
24
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
25
25
  import { recordEvent } from "@teamscala/event-log/record-event";
26
26
  import { getAppLogger } from "@teamscala/logger/app-loggers";
27
+ import { getDistTagVersion } from "@teamscala/publish-registry";
27
28
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
28
29
 
29
- const BUN_NPM_LATEST = "https://registry.npmjs.org/bun/latest";
30
+ const NPM_REGISTRY = "https://registry.npmjs.org";
30
31
  const TARGET_STABLE = "1.4.0"; // flip back when a stable at or above this ships
31
32
 
32
33
  interface BunLatestManifest {
@@ -54,23 +55,19 @@ function isStableAtOrAbove(version: string, target: string): boolean {
54
55
 
55
56
  async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
56
57
  const log = getAppLogger();
57
- let manifest: BunLatestManifest;
58
- try {
59
- const res = await fetch(BUN_NPM_LATEST, { headers: { accept: "application/json" } });
60
- if (!res.ok) throw new Error(`npm bun/latest HTTP ${res.status}`);
61
- manifest = (await res.json()) as BunLatestManifest;
62
- } catch (err) {
63
- log.warn(`[bun-stable-watch] failed to fetch npm bun/latest: ${err}`);
58
+ const version = await getDistTagVersion("bun", "latest", NPM_REGISTRY);
59
+ if (!version) {
60
+ log.warn("[bun-stable-watch] failed to fetch npm bun/latest");
64
61
  return;
65
62
  }
66
- if (isStableAtOrAbove(manifest.version, TARGET_STABLE)) {
63
+ if (isStableAtOrAbove(version, TARGET_STABLE)) {
67
64
  log.warn(
68
- `[bun-stable-watch] Bun stable ${manifest.version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
65
+ `[bun-stable-watch] Bun stable ${version} (>=${TARGET_STABLE}) shipped — escape hatch 'bun-14-lock-resolution-pin' is ready to retire. Run the atomic flip-back: config/scala-tools-deps.lockResolutionBun -> "bun" + railpack_config rows -> 1.4.0 stable + delete /usr/local/bin/bun-1.3.14 + a green deploy.`,
69
66
  );
70
67
  recordEvent({
71
68
  kind: "bun.stable.shipped",
72
69
  payload: {
73
- latest: manifest.version,
70
+ latest: version,
74
71
  targetStable: TARGET_STABLE,
75
72
  note: "escape hatch bun-14-lock-resolution-pin ready to retire; verify railpack 1.4.0 support then run the atomic flip-back",
76
73
  },
@@ -78,11 +75,11 @@ async function bunStableWatch(_ctx: { config: ScheduledJobConfig }): Promise<voi
78
75
  return;
79
76
  }
80
77
  log.info(
81
- `[bun-stable-watch] npm bun/latest = ${manifest.version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
78
+ `[bun-stable-watch] npm bun/latest = ${version} — not yet stable ${TARGET_STABLE}; escape hatch remains`,
82
79
  );
83
80
  recordEvent({
84
81
  kind: "bun.watch.tick",
85
- payload: { latest: manifest.version, targetStable: TARGET_STABLE },
82
+ payload: { latest: version, targetStable: TARGET_STABLE },
86
83
  });
87
84
  }
88
85
 
@@ -28,6 +28,7 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
28
28
  import { recordEvent } from "@teamscala/event-log/record-event";
29
29
  import { getAppLogger } from "@teamscala/logger/app-loggers";
30
30
  import { spawn } from "@teamscala/os/spawn";
31
+ import { getDistTagVersion } from "@teamscala/publish-registry";
31
32
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
32
33
 
33
34
  /**
@@ -180,7 +181,7 @@ async function fetchLatestGithubRelease(repo: string): Promise<string | null> {
180
181
  async function fetchLatestVersion(pkg: string, distTag: string): Promise<string | null> {
181
182
  try {
182
183
  // @teamscala/* packages use the internal dist-tag tool; public npm packages
183
- // fetch directly from the npm registry (same pattern as bun-stable-watch).
184
+ // fetch directly from the npm registry via the typed primitive.
184
185
  if (pkg.startsWith("@teamscala/")) {
185
186
  const result = await spawn({
186
187
  name: "singleton-tools-currency:dist-tag",
@@ -196,12 +197,7 @@ async function fetchLatestVersion(pkg: string, distTag: string): Promise<string
196
197
  if (!latest || !parseSemver(latest)) return null;
197
198
  return latest;
198
199
  }
199
- // Public npm package: fetch the dist-tag manifest directly.
200
- const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/${encodeURIComponent(distTag)}`;
201
- const res = await fetch(url, { headers: { accept: "application/json" } });
202
- if (!res.ok) return null;
203
- const manifest = (await res.json()) as { version: string };
204
- return manifest.version ?? null;
200
+ return getDistTagVersion(pkg, distTag, "https://registry.npmjs.org");
205
201
  } catch {
206
202
  return null;
207
203
  }
@@ -39,6 +39,7 @@
39
39
  * bytes across restarts).
40
40
  */
41
41
 
42
+ import { createCache } from "@teamscala/cache/create-cache";
42
43
  import { readClientBuildStatus } from "./build-client-assets.ts";
43
44
  import { join } from "node:path";
44
45
 
@@ -70,7 +71,15 @@ const PAGE_DOCUMENT = `<!doctype html>
70
71
  </body>
71
72
  </html>`;
72
73
 
73
- let cached: { html: string; manifestMtime: number } | null = null;
74
+ interface CachedHtml {
75
+ html: string;
76
+ manifestMtime: number;
77
+ }
78
+
79
+ const htmlCache = createCache<CachedHtml>("service-boot:html-template", {
80
+ ttlMs: Number.POSITIVE_INFINITY,
81
+ maxSize: 1,
82
+ });
74
83
 
75
84
  function manifestPath(): string {
76
85
  return join(process.cwd(), "dist", "client", "asset-manifest.json");
@@ -98,8 +107,9 @@ async function loadManifest(): Promise<AssetManifest> {
98
107
  }
99
108
 
100
109
  export async function readHtmlTemplate(): Promise<string> {
101
- const mtime = await manifestMtimeMs();
102
- if (cached && cached.manifestMtime === mtime) return cached.html;
110
+ const manifestMtime = await manifestMtimeMs();
111
+ const cached = htmlCache.get("default");
112
+ if (cached && cached.manifestMtime === manifestMtime) return cached.html;
103
113
  const manifest = await loadManifest();
104
114
  let html = PAGE_DOCUMENT
105
115
  .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
@@ -116,6 +126,6 @@ export async function readHtmlTemplate(): Promise<string> {
116
126
  ? html.replace("</head>", `${meta}</head>`)
117
127
  : `${meta}${html}`;
118
128
  }
119
- cached = { html, manifestMtime: mtime };
129
+ htmlCache.set("default", { html, manifestMtime });
120
130
  return html;
121
131
  }
@@ -44,6 +44,8 @@ export async function registerPageRoutes(
44
44
  const authMode = (config as { auth?: string }).auth ?? "none";
45
45
  const providers = ((config as { providers?: string[] }).providers ??
46
46
  []) as string[];
47
+ const portalScoped =
48
+ (config as { portal_scoped?: boolean }).portal_scoped === true;
47
49
 
48
50
  const registryPatterns = await registerRegistryPageRoute({
49
51
  app,
@@ -53,6 +55,7 @@ export async function registerPageRoutes(
53
55
  authMode,
54
56
  renderMode,
55
57
  providers,
58
+ portalScoped,
56
59
  });
57
60
 
58
61
  return registryPatterns;
@@ -79,6 +79,12 @@ interface RegistryRouteArgs {
79
79
  authMode: string;
80
80
  renderMode: RenderModeLiteral;
81
81
  providers: string[];
82
+ /** Declared service_install fact: this service's page URL space is
83
+ * org-scoped portal-shaped (/<websiteId>/<orgId>/<path>). Gates the SSR
84
+ * portal home redirect — NOT derivable from catchall presence: a flat-path
85
+ * catchall service (scala-agents-ui) serves its real page at "/" and must
86
+ * not be redirected to an org-scoped path that has no page there. */
87
+ portalScoped: boolean;
82
88
  }
83
89
 
84
90
  function extractUrlParams(
@@ -111,6 +117,7 @@ export async function registerRegistryPageRoute(
111
117
  authMode,
112
118
  renderMode,
113
119
  providers: baseProviders,
120
+ portalScoped,
114
121
  } = args;
115
122
  const log = getAppLogger();
116
123
 
@@ -123,16 +130,6 @@ export async function registerRegistryPageRoute(
123
130
  );
124
131
  if (serviceRoutes.length === 0) return [];
125
132
 
126
- // Only a service serving arbitrary portal paths (a `/*` catchall route —
127
- // the portal) redirects signed-in users from `/` into
128
- // /<website>/<org>/dashboard. Single-site portal-mode services (the
129
- // fleet dashboard) serve their real page AT `/` — the unconditional
130
- // redirect 404-looped them (2026-06-06; service-runtime@1.15.82 made
131
- // the redirect opt-in via this flag).
132
- const servesPortalCatchall = serviceRoutes.some(
133
- (r) => r.config.pattern === "/*",
134
- );
135
-
136
133
  // Sort routes so more specific patterns register before wildcard/param routes.
137
134
  // Hono matches by registration order, so `/*` (the portal catchall) registered
138
135
  // before `/sign-in` would swallow the sign-in page and render it inside the
@@ -834,7 +831,11 @@ export async function registerRegistryPageRoute(
834
831
  providers: routeProviders,
835
832
  renderMode: routeRenderMode,
836
833
  loadData: loadData as never,
837
- portalHomeRedirect: servesPortalCatchall,
834
+ // The portal home redirect follows the DECLARED portal_scoped service
835
+ // fact (see RegistryRouteArgs.portalScoped) — a signed-in user at "/" of
836
+ // an org-scoped portal belongs at /<websiteId>/<orgId>/dashboard; a
837
+ // flat-path service (the fleet dashboard) serves its real page at "/".
838
+ portalHomeRedirect: portalScoped,
838
839
  serviceSlug,
839
840
  });
840
841
 
@@ -5,6 +5,8 @@
5
5
  *
6
6
  * Polls the installed scala-tools package against GitHub Packages and repairs
7
7
  * stale daemon tooling by reinstalling and letting PM2 restart the process.
8
+ * The fetch routes through `getGithubPackagesLatestVersion` so the URL
9
+ * encoding and GitHub-Packages auth header live in the typed primitive.
8
10
  */
9
11
 
10
12
  import { recordEvent } from "@teamscala/event-log/record-event";
@@ -14,6 +16,7 @@ import { createWatchdog } from "@teamscala/watchdog/create-watchdog";
14
16
  import type { WatchdogHandle } from "@teamscala/watchdog/types";
15
17
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
18
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
19
+ import { getGithubPackagesLatestVersion } from "@teamscala/publish-registry";
17
20
  import * as v from "valibot";
18
21
 
19
22
  const PACKAGE_NAME = "@teamscala/scala-tools";
@@ -55,10 +58,6 @@ interface PackageManifest {
55
58
  version?: unknown;
56
59
  }
57
60
 
58
- interface PackagePackument {
59
- "dist-tags"?: { latest?: unknown };
60
- }
61
-
62
61
  let watchdog: WatchdogHandle | null = null;
63
62
 
64
63
  async function readInstalledVersion(cwd: string): Promise<string | null> {
@@ -70,24 +69,25 @@ async function readInstalledVersion(cwd: string): Promise<string | null> {
70
69
  }
71
70
  }
72
71
 
73
- async function readLatestVersion(fetchTimeoutMs: number): Promise<string | null> {
72
+ /**
73
+ * PACKAGE_URL is `<registry>/<encoded-name>`. Split it so the typed primitive
74
+ * owns the URL encoding (encodeURIComponent) and the GitHub-Packages auth
75
+ * header shape — neither is this file's concern.
76
+ */
77
+ function registryFromPackageUrl(pkgUrl: string): { registry: string; name: string } {
78
+ const lastSlash = pkgUrl.lastIndexOf("/");
79
+ if (lastSlash < 0) return { registry: pkgUrl, name: "" };
80
+ return {
81
+ registry: pkgUrl.slice(0, lastSlash),
82
+ name: decodeURIComponent(pkgUrl.slice(lastSlash + 1)),
83
+ };
84
+ }
85
+
86
+ async function readLatestVersion(_fetchTimeoutMs: number): Promise<string | null> {
74
87
  const token = await getGithubPackagesToken();
75
88
  if (!token) return null;
76
- try {
77
- const response = await fetch(PACKAGE_URL, {
78
- headers: {
79
- accept: "application/vnd.github+json",
80
- authorization: `Bearer ${token}`,
81
- },
82
- signal: AbortSignal.timeout(fetchTimeoutMs),
83
- });
84
- if (!response.ok) return null;
85
- const packument = (await response.json()) as PackagePackument;
86
- const latest = packument["dist-tags"]?.latest;
87
- return typeof latest === "string" ? latest : null;
88
- } catch {
89
- return null;
90
- }
89
+ const { registry, name } = registryFromPackageUrl(PACKAGE_URL);
90
+ return getGithubPackagesLatestVersion(name, token, registry);
91
91
  }
92
92
 
93
93
  async function checkCurrency(): Promise<{ healthy: boolean; details?: Record<string, unknown> }> {