@teamscala/service-boot 0.23.434 → 0.23.436

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.434",
9
+ "version": "0.23.436",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -21,8 +21,6 @@
21
21
  * changes (`migrations-copy-not-rewrite`).
22
22
  */
23
23
 
24
- import { createHash } from "node:crypto";
25
- import { existsSync } from "node:fs";
26
24
  import { dirname, join, relative, resolve } from "node:path";
27
25
  import { firstPartyScopeAlternation } from "@teamscala/os/workspace-topology/first-party-scopes";
28
26
 
@@ -32,7 +30,7 @@ import { firstPartyScopeAlternation } from "@teamscala/os/workspace-topology/fir
32
30
  // blk-utilities changes undetected → stale bundle). Cycle-safe via `seen`.
33
31
  export async function hashResolvedPackageStylesheets(
34
32
  cwd: string,
35
- h: ReturnType<typeof createHash>,
33
+ h: Bun.CryptoHasher,
36
34
  ): Promise<void> {
37
35
  let appCss: string;
38
36
  try {
@@ -120,12 +118,15 @@ async function readInstalledPkgVersion(
120
118
 
121
119
  export async function hashTeamgbgJsGraph(
122
120
  cwd: string,
123
- h: ReturnType<typeof createHash>,
121
+ h: Bun.CryptoHasher,
124
122
  ): Promise<void> {
125
- const roots = [
123
+ const roots = [];
124
+ for (const p of [
126
125
  join(cwd, "src", "entry-client.ts"),
127
126
  join(cwd, "src", "page-manifest.ts"),
128
- ].filter((p) => existsSync(p));
127
+ ]) {
128
+ if (await Bun.file(p).exists()) roots.push(p);
129
+ }
129
130
  if (roots.length === 0) return; // backend service — no client bundle to guard
130
131
 
131
132
  const seen = new Set<string>();
@@ -147,7 +148,7 @@ export async function hashTeamgbgJsGraph(
147
148
  const nmIdx = file.indexOf("/node_modules/@teamscala/");
148
149
  if (nmIdx >= 0) {
149
150
  const rel = file.slice(nmIdx + "/node_modules/".length); // @teamscala/pkg/...
150
- fileHashes.set(rel, createHash("sha256").update(text).digest("hex"));
151
+ fileHashes.set(rel, new Bun.CryptoHasher("sha256").update(text).digest("hex"));
151
152
  }
152
153
  const next: string[] = [];
153
154
  for (const m of text.matchAll(JS_IMPORT_SPEC_RE)) {
@@ -32,14 +32,19 @@
32
32
 
33
33
  import { getManagedBaseDomain } from "@teamscala/auth/auth-env-resolution";
34
34
  import { isTrustedAuthOrigin } from "@teamscala/auth/is-trusted-auth-origin";
35
+ import { createCache } from "@teamscala/cache/create-cache";
35
36
  import { csrf } from "hono/csrf";
36
37
  import type { Context, MiddlewareHandler } from "hono";
37
38
 
38
39
  const POSITIVE_TTL_MS = 5 * 60_000;
39
40
  const NEGATIVE_TTL_MS = 10_000;
40
41
 
41
- type CacheEntry = { trusted: boolean; expiresAt: number };
42
- const customDomainCache = new Map<string, CacheEntry>();
42
+ // Positive and negative lookups expire on different clocks; the per-entry
43
+ // ttlMs override on set() carries that, the primitive owns eviction.
44
+ const customDomainCache = createCache<boolean>("service-boot:csrf-custom-domains", {
45
+ ttlMs: POSITIVE_TTL_MS,
46
+ maxSize: 512,
47
+ });
43
48
 
44
49
  function isPlatformOrigin(origin: string, baseDomain: string): boolean {
45
50
  let host: string;
@@ -61,9 +66,8 @@ async function isTrustedCustomOrigin(origin: string): Promise<boolean> {
61
66
  }
62
67
  if (!host) return false;
63
68
 
64
- const now = Date.now();
65
69
  const cached = customDomainCache.get(host);
66
- if (cached && cached.expiresAt > now) return cached.trusted;
70
+ if (cached !== undefined) return cached;
67
71
 
68
72
  const trusted = await (async () => {
69
73
  try {
@@ -81,11 +85,7 @@ async function isTrustedCustomOrigin(origin: string): Promise<boolean> {
81
85
  }
82
86
  })();
83
87
 
84
- customDomainCache.set(host, {
85
- trusted,
86
- expiresAt: now + (trusted ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS),
87
- });
88
- if (customDomainCache.size > 512) customDomainCache.clear();
88
+ customDomainCache.set(host, trusted, trusted ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS);
89
89
  return trusted;
90
90
  }
91
91
 
@@ -21,7 +21,6 @@
21
21
  */
22
22
 
23
23
  import { getAppLogger } from "@teamscala/logger/app-loggers";
24
- import { existsSync } from "node:fs";
25
24
  import { join } from "node:path";
26
25
  import { getPrisma } from "@teamscala/db/prisma-registry";
27
26
  import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
@@ -56,17 +55,30 @@ export async function discoverBlocks(
56
55
  // so the 2026-05-29 empty-list-scans-everything incident cannot recur either.
57
56
  const serviceModules = join(process.cwd(), "node_modules");
58
57
  const installedCache = new Map<string, boolean>();
59
- const isInstalled = (pkg: string): boolean => {
58
+ const isInstalled = async (pkg: string): Promise<boolean> => {
60
59
  const cached = installedCache.get(pkg);
61
60
  if (cached !== undefined) return cached;
62
- const ok = existsSync(join(serviceModules, ...pkg.split("/")));
61
+ // Probe the package's package.json, NOT its directory. Bun.file() on a
62
+ // DIRECTORY reports exists() === false, so the previous directory probe
63
+ // answered "not installed" for every package that was in fact installed
64
+ // — and because this predicate gates every block below, that skipped
65
+ // every block and booted the service with zero Puck components. Measured
66
+ // 2026-09-01: scala-portal, scala-public, scala-forms and scala-projects
67
+ // all FATAL at ui-service.page-manifest with their packages present on
68
+ // disk at the expected versions. A file probe is the correct shape.
69
+ const ok = await Bun.file(
70
+ join(serviceModules, ...pkg.split("/"), "package.json"),
71
+ ).exists();
63
72
  installedCache.set(pkg, ok);
64
73
  return ok;
65
74
  };
66
75
  // `blockPackages` is retained only to surface authored-vs-installed drift to
67
76
  // the operator; it no longer gates discovery, so it can never blank a block.
68
77
  if (blockPackages.length > 0) {
69
- const stale = blockPackages.filter((p) => !isInstalled(p));
78
+ const installed = await Promise.all(
79
+ blockPackages.map(async (p) => ({ p, ok: await isInstalled(p) })),
80
+ );
81
+ const stale = installed.filter((x) => !x.ok).map((x) => x.p);
70
82
  if (stale.length > 0) {
71
83
  getAppLogger().warn(
72
84
  `[discover-blocks] block_packages lists ${stale.length} package(s) that are not installed (ignored): ${stale.join(", ")}`,
@@ -105,7 +117,7 @@ export async function discoverBlocks(
105
117
  const rawPackage = rawComponentPath
106
118
  ? rawComponentPath.split("/").slice(0, 2).join("/")
107
119
  : null;
108
- if (!rawPackage || !isInstalled(rawPackage)) {
120
+ if (!rawPackage || !(await isInstalled(rawPackage))) {
109
121
  skipped.push(
110
122
  `${row.slug} (${rawPackage ?? "missing componentPath"} not installed)`,
111
123
  );
@@ -131,7 +143,7 @@ export async function discoverBlocks(
131
143
  // Scope to installed packages: each block_schema's componentPath must
132
144
  // start with a package present in the service's node_modules.
133
145
  const pkgPrefix = componentPath.split("/").slice(0, 2).join("/");
134
- if (!isInstalled(pkgPrefix)) {
146
+ if (!(await isInstalled(pkgPrefix))) {
135
147
  skipped.push(`${row.slug} (${pkgPrefix} not installed)`);
136
148
  return;
137
149
  }
@@ -176,12 +188,16 @@ export async function discoverBlocks(
176
188
  // missing componentPath are kept so `loadBlock` still flags them (real data
177
189
  // signal); `skipped` thus catches only genuine failures (installed package
178
190
  // but import/config/export failed) and the call-site WARN becomes signal.
179
- const candidateRows = rows.filter((row) => {
191
+ const candidateRows = [];
192
+ for (const row of rows) {
180
193
  const cp = getStringProperty(parseConfig(row.config), "componentPath");
181
- if (!cp) return true;
194
+ if (!cp) {
195
+ candidateRows.push(row);
196
+ continue;
197
+ }
182
198
  const pkg = cp.split("/").slice(0, 2).join("/");
183
- return isInstalled(pkg);
184
- });
199
+ if (await isInstalled(pkg)) candidateRows.push(row);
200
+ }
185
201
  // All blocks concurrent (module imports are in-process I/O + V8 compile — no
186
202
  // fork tax, so bounded concurrency isn't needed here; the chunk-of-8 was
187
203
  // needlessly serializing 44 imports into 6 waves when 1 wave suffices).
@@ -14,13 +14,18 @@ import { join } from "node:path";
14
14
  import { getAppLogger } from "@teamscala/logger/app-loggers";
15
15
  import type { Hono } from "hono";
16
16
  import { etag } from "hono/etag";
17
+ import { FALLBACK_PRIMARY_COLOR } from "@teamscala/os/brand/fallback-primary-color";
17
18
  import { buildErrorReport } from "@teamscala/os/errors/error-report";
18
19
  import { collectHeapReport } from "@teamscala/os/observability/heap-report";
19
20
  import { isLoopbackHost } from "@teamscala/os/observability/loopback-host";
20
21
  import { handleAssetProxy } from "./asset-proxy";
21
22
  import { resolveBrandFaviconPath, warmBrandFaviconCache } from "./resolve-brand-favicon";
22
23
 
23
- const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#6366f1"/><text x="16" y="23" text-anchor="middle" font-size="20" font-family="system-ui" fill="white">S</text></svg>`;
24
+ // Served ONLY when the org has no brand favicon, so the correct colour is the
25
+ // platform brand default rather than a literal. This changes the generic
26
+ // fallback mark from indigo to the canonical slate; any org with its own
27
+ // favicon is unaffected (resolveBrandFaviconPath wins before this is reached).
28
+ const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="${FALLBACK_PRIMARY_COLOR}"/><text x="16" y="23" text-anchor="middle" font-size="20" font-family="system-ui" fill="white">S</text></svg>`;
24
29
 
25
30
  export async function registerStaticAssets(
26
31
  app: Hono,