@teamscala/service-boot 0.23.433 → 0.23.435

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.433",
9
+ "version": "0.23.435",
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,20 @@ 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
+ const ok = await Bun.file(join(serviceModules, ...pkg.split("/"))).exists();
63
62
  installedCache.set(pkg, ok);
64
63
  return ok;
65
64
  };
66
65
  // `blockPackages` is retained only to surface authored-vs-installed drift to
67
66
  // the operator; it no longer gates discovery, so it can never blank a block.
68
67
  if (blockPackages.length > 0) {
69
- const stale = blockPackages.filter((p) => !isInstalled(p));
68
+ const installed = await Promise.all(
69
+ blockPackages.map(async (p) => ({ p, ok: await isInstalled(p) })),
70
+ );
71
+ const stale = installed.filter((x) => !x.ok).map((x) => x.p);
70
72
  if (stale.length > 0) {
71
73
  getAppLogger().warn(
72
74
  `[discover-blocks] block_packages lists ${stale.length} package(s) that are not installed (ignored): ${stale.join(", ")}`,
@@ -105,7 +107,7 @@ export async function discoverBlocks(
105
107
  const rawPackage = rawComponentPath
106
108
  ? rawComponentPath.split("/").slice(0, 2).join("/")
107
109
  : null;
108
- if (!rawPackage || !isInstalled(rawPackage)) {
110
+ if (!rawPackage || !(await isInstalled(rawPackage))) {
109
111
  skipped.push(
110
112
  `${row.slug} (${rawPackage ?? "missing componentPath"} not installed)`,
111
113
  );
@@ -131,7 +133,7 @@ export async function discoverBlocks(
131
133
  // Scope to installed packages: each block_schema's componentPath must
132
134
  // start with a package present in the service's node_modules.
133
135
  const pkgPrefix = componentPath.split("/").slice(0, 2).join("/");
134
- if (!isInstalled(pkgPrefix)) {
136
+ if (!(await isInstalled(pkgPrefix))) {
135
137
  skipped.push(`${row.slug} (${pkgPrefix} not installed)`);
136
138
  return;
137
139
  }
@@ -176,12 +178,16 @@ export async function discoverBlocks(
176
178
  // missing componentPath are kept so `loadBlock` still flags them (real data
177
179
  // signal); `skipped` thus catches only genuine failures (installed package
178
180
  // but import/config/export failed) and the call-site WARN becomes signal.
179
- const candidateRows = rows.filter((row) => {
181
+ const candidateRows = [];
182
+ for (const row of rows) {
180
183
  const cp = getStringProperty(parseConfig(row.config), "componentPath");
181
- if (!cp) return true;
184
+ if (!cp) {
185
+ candidateRows.push(row);
186
+ continue;
187
+ }
182
188
  const pkg = cp.split("/").slice(0, 2).join("/");
183
- return isInstalled(pkg);
184
- });
189
+ if (await isInstalled(pkg)) candidateRows.push(row);
190
+ }
185
191
  // All blocks concurrent (module imports are in-process I/O + V8 compile — no
186
192
  // fork tax, so bounded concurrency isn't needed here; the chunk-of-8 was
187
193
  // needlessly serializing 44 imports into 6 waves when 1 wave suffices).
@@ -13,10 +13,24 @@
13
13
  * RegionPageSwap can fetch a region's Puck page on demand and render it into a
14
14
  * slot — the no-reload half of `region-views-are-pages` (every region is a Puck
15
15
  * page; switching a view loads a different page's content).
16
+ *
17
+ * `resolvePanelData` WAS RESTORED 2026-08-31. `72ac1a7` ("Retire unoffered page
18
+ * ORPC procedures") deleted it three weeks earlier as unoffered, but its
19
+ * consumer is live and in ANOTHER REPO: `use-region-page.ts` in
20
+ * @teamscala/ui-foundation calls `regions.resolvePanelData` on every panel
21
+ * open. Only the comment describing it was left behind — the shape
22
+ * `a-deleted-producer-leaves-its-consumers-executing` names, where deleting a
23
+ * producer leaves its consumers demanding something that can no longer exist.
24
+ *
25
+ * It failed SILENTLY for three weeks because the client treats a failure as
26
+ * best-effort and maps it to null, so the 404 rendered an empty panel with no
27
+ * error anywhere. Observed live as `POST /api/rpc/regions/resolvePanelData 404`
28
+ * on every panel open on projects-dev.
16
29
  */
17
30
 
18
31
  import { registerCustomRouter } from "@teamscala/orpc/app-router";
19
32
  import { puckPagesRouter } from "../page-server-orpc/puck-pages-router";
33
+ import { resolvePanelDataHandler } from "./panel-data-router.ts";
20
34
  import { watchDataChange } from "@teamscala/realtime-utils/data-change-procedure";
21
35
  import { watchPresence } from "@teamscala/realtime-utils/presence-procedure";
22
36
  import { agreementSendRouter } from "./agreement-send-router.ts";
@@ -51,6 +65,8 @@ export function registerFnRouters(): void {
51
65
  loadPage: puckPagesRouter.loadFromWebsite,
52
66
  // Resolve a page-kind panel's OWN loaderData (panel_view.loader_slug) so a
53
67
  // panel region renders against the panel page's data, not the host's.
68
+ //
69
+ resolvePanelData: resolvePanelDataHandler,
54
70
  });
55
71
  registerCustomRouter("realtime", {
56
72
  watchDataChange,
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * `regions.resolvePanelData` — resolves a page-kind panel's OWN loaderData so a
7
+ * panel region renders blocks against the panel page's data, not the host
8
+ * page's. RegionPageSwap calls this on panel-open + injects the result via
9
+ * LiveDataProvider("page-data"), making panel-embedded data blocks (e.g. the
10
+ * workload Chart) self-sufficient: each panel carries its own data, no host
11
+ * coupling + no preloading the host route.
12
+ *
13
+ * Flow (registry reads via the loadRegistryConfig helper — registry-via-helper):
14
+ * panel_view.content.loader_slug → resource_loader config → orgId derived from
15
+ * the serving website (builda_websites.organisation_id) → resolveFromLoaderConfig
16
+ * → the loader's resolved sources. Panels without a loader_slug (e.g. the AI
17
+ * panel, whose block self-loads) return null — no extra round-trip, no behavior
18
+ * change.
19
+ */
20
+ import { os } from "@orpc/server";
21
+ import * as v from "valibot";
22
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
23
+ import type { PanelViewKey } from "@teamscala/db/registry/generated/panel-view";
24
+ import type { ResourceLoaderKey } from "@teamscala/db/registry/generated/resource-loader";
25
+ import { resolveFromLoaderConfig } from "../resolve-from-loader-config.ts";
26
+
27
+ interface Context {
28
+ prisma: {
29
+ builda_websites: {
30
+ findFirst: (args: unknown) => Promise<{
31
+ organisation_id?: string | null;
32
+ } | null>;
33
+ };
34
+ };
35
+ }
36
+
37
+ const panelViewSchema = v.looseObject({
38
+ content: v.looseObject({ loader_slug: v.optional(v.string()) }),
39
+ });
40
+
41
+ export const resolvePanelDataHandler = os
42
+ .$context<Context>()
43
+ .input(
44
+ v.object({
45
+ panelSlug: v.string(),
46
+ websiteSlug: v.string(),
47
+ /** Tenant orgId (preferred over the website's organisation_id, which is
48
+ * the platform org on a multi-tenant portal — not the tenant). The
49
+ * caller (RegionPageSwap) threads this from __PAGE_DATA__.orgId. */
50
+ orgId: v.optional(v.string()),
51
+ }),
52
+ )
53
+ .handler(async ({ input, context }) => {
54
+ // panel_view → loader_slug (null when missing or the panel has no loader).
55
+ let loaderSlug: string | undefined;
56
+ try {
57
+ // THE SLUG IS RUNTIME-SUPPLIED, so it cannot be statically a
58
+ // PanelViewKey: it arrives from the browser as the opened panel's
59
+ // name. The lookup itself is the validation — loadRegistryConfig
60
+ // throws when no such row exists, and the catch below turns that into
61
+ // the documented null. Narrowing here states that contract rather
62
+ // than asserting the value is known good.
63
+ const pv = await loadRegistryConfig(
64
+ "panel_view",
65
+ input.panelSlug as PanelViewKey,
66
+ panelViewSchema,
67
+ );
68
+ loaderSlug = pv.content?.loader_slug;
69
+ } catch {
70
+ return null;
71
+ }
72
+ if (!loaderSlug) return null;
73
+ // resource_loader → its config (loose schema; resolveFromLoaderConfig owns
74
+ // the typed LoaderConfig contract).
75
+ let loader: unknown;
76
+ try {
77
+ loader = await loadRegistryConfig(
78
+ "resource_loader",
79
+ // Same contract: loader_slug is read from the panel row's loose
80
+ // content schema as a plain string, and a missing row throws into
81
+ // the catch below.
82
+ loaderSlug as ResourceLoaderKey,
83
+ v.unknown(),
84
+ );
85
+ } catch {
86
+ return null;
87
+ }
88
+ // Tenant orgId: prefer the caller-provided value (the route's tenant org,
89
+ // threaded from __PAGE_DATA__.orgId); fall back to the website's
90
+ // organisation_id (the platform org — wrong for multi-tenant portals, but
91
+ // the only derivable value when the caller omits it).
92
+ let orgId = input.orgId;
93
+ if (!orgId) {
94
+ const site = await context.prisma.builda_websites.findFirst({
95
+ where: { OR: [{ slug: input.websiteSlug }, { id: input.websiteSlug }] },
96
+ select: { organisation_id: true },
97
+ });
98
+ orgId = site?.organisation_id ?? undefined;
99
+ }
100
+ return await resolveFromLoaderConfig(
101
+ loader as Parameters<typeof resolveFromLoaderConfig>[0],
102
+ { orgId },
103
+ );
104
+ });
@@ -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,