@teamscala/service-boot 0.23.541 → 0.23.543

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
@@ -4,6 +4,13 @@
4
4
  "status": "generated",
5
5
  "edit": "edit src/ — dependencies and exports derive from imports; everything else preserved"
6
6
  },
7
+ "publishConfig": {
8
+ "registry": "https://registry.npmjs.org"
9
+ },
10
+ "repository": "https://github.com/teamgbg/service-boot",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "version": "0.23.543",
7
14
  "dependencies": {
8
15
  "@orpc/openapi": "*",
9
16
  "@orpc/server": "*",
@@ -227,6 +234,8 @@
227
234
  "./register-registry-page-route/resolve-panel-page.ts": "./src/register-registry-page-route/resolve-panel-page.ts",
228
235
  "./register-registry-page-route/resolve-request-graph": "./src/register-registry-page-route/resolve-request-graph.ts",
229
236
  "./register-registry-page-route/resolve-request-graph.ts": "./src/register-registry-page-route/resolve-request-graph.ts",
237
+ "./register-registry-page-route/resolve-signed-in-org": "./src/register-registry-page-route/resolve-signed-in-org.ts",
238
+ "./register-registry-page-route/resolve-signed-in-org.ts": "./src/register-registry-page-route/resolve-signed-in-org.ts",
230
239
  "./register-registry-page-route/run-content-gates": "./src/register-registry-page-route/run-content-gates.ts",
231
240
  "./register-registry-page-route/run-content-gates.ts": "./src/register-registry-page-route/run-content-gates.ts",
232
241
  "./register-registry-page-route/single-page-data": "./src/register-registry-page-route/single-page-data.ts",
@@ -300,12 +309,5 @@
300
309
  "@teamscala/chat-completion": {
301
310
  "optional": true
302
311
  }
303
- },
304
- "publishConfig": {
305
- "registry": "https://registry.npmjs.org"
306
- },
307
- "repository": "https://github.com/teamgbg/service-boot",
308
- "sideEffects": false,
309
- "type": "module",
310
- "version": "0.23.541"
312
+ }
311
313
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { join } from "node:path";
7
+ import { createSafeFallback } from "@teamscala/safe-fallback/create-safe-fallback";
7
8
  import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
8
9
 
9
10
  export const MANIFEST_FILE = "asset-manifest.json";
@@ -22,23 +23,60 @@ export interface FailedBuildManifest {
22
23
 
23
24
  export type ManifestFile = AssetManifest | FailedBuildManifest;
24
25
 
25
- export async function readManifest(outDir: string): Promise<AssetManifest | null> {
26
+ export type ManifestState =
27
+ | { state: "assets"; manifest: AssetManifest }
28
+ | { state: "build-failed"; manifest: FailedBuildManifest }
29
+ | { state: "absent" };
30
+
31
+ const manifestRead = createSafeFallback<ManifestState>({
32
+ name: "service-boot:asset-manifest-read",
33
+ onFailure: "fail-closed-warn",
34
+ degradedResult: { state: "absent" },
35
+ });
36
+
37
+ export async function readManifestState(outDir: string): Promise<ManifestState> {
26
38
  const path = join(outDir, MANIFEST_FILE);
27
- if (!(await Bun.file(path).exists())) return null;
28
- try {
29
- const parsed = (await Bun.file(path).json()) as ManifestFile;
30
- if ("buildFailed" in parsed) return null;
31
- if (parsed.stylesPath && parsed.hydratePath) {
32
- return {
39
+ if (!(await Bun.file(path).exists())) return { state: "absent" };
40
+ const parsed = await manifestRead.call(
41
+ () => Bun.file(path).json() as Promise<ManifestFile>,
42
+ );
43
+ if ("buildFailed" in parsed) return { state: "build-failed", manifest: parsed };
44
+ if (parsed.stylesPath && parsed.hydratePath) {
45
+ return {
46
+ state: "assets",
47
+ manifest: {
33
48
  stylesPath: parsed.stylesPath,
34
49
  hydratePath: parsed.hydratePath,
35
50
  sourceMaps: parsed.sourceMaps,
36
- };
37
- }
38
- return null;
39
- } catch {
40
- return null;
51
+ },
52
+ };
41
53
  }
54
+ return { state: "absent" };
55
+ }
56
+
57
+ export function verifyManifestState(
58
+ state: ManifestState,
59
+ outDir: string,
60
+ ): { ok: true; manifest: AssetManifest } | { ok: false; reason: string } {
61
+ if (state.state === "build-failed") {
62
+ return {
63
+ ok: false,
64
+ reason:
65
+ `the client build recorded its own failure at ${state.manifest.buildFailedAt}: ${state.manifest.buildFailedReason} ` +
66
+ `(asset manifest ${join(outDir, MANIFEST_FILE)}) — the artifact records a failed build, so nothing it names is servable; ` +
67
+ `fix the build the reason names and restart`,
68
+ };
69
+ }
70
+ if (state.state === "assets") return { ok: true, manifest: state.manifest };
71
+ return {
72
+ ok: false,
73
+ reason: `no readable asset manifest at ${join(outDir, MANIFEST_FILE)} — the client build never ran or its output is gone`,
74
+ };
75
+ }
76
+
77
+ export async function readManifest(outDir: string): Promise<AssetManifest | null> {
78
+ const verdict = verifyManifestState(await readManifestState(outDir), outDir);
79
+ return verdict.ok ? verdict.manifest : null;
42
80
  }
43
81
 
44
82
  export async function writeManifest(
@@ -55,13 +93,9 @@ export async function verifyClientBuildAssets(
55
93
  cwd = process.cwd(),
56
94
  ): Promise<{ ok: true } | { ok: false; reason: string }> {
57
95
  const outDir = join(cwd, "dist", "client");
58
- const manifest = await readManifest(outDir);
59
- if (!manifest || !manifest.stylesPath || !manifest.hydratePath) {
60
- return {
61
- ok: false,
62
- reason: `no readable asset manifest at ${join(outDir, MANIFEST_FILE)} — the client build never ran or its output is gone`,
63
- };
64
- }
96
+ const verdict = verifyManifestState(await readManifestState(outDir), outDir);
97
+ if (!verdict.ok) return verdict;
98
+ const manifest = verdict.manifest;
65
99
  for (const [label, url] of [
66
100
  ["stylesheet", manifest.stylesPath],
67
101
  ["hydrate bundle", manifest.hydratePath],
@@ -20,3 +20,13 @@ export function isModuleNotFound(err: unknown, requestedPath: string): boolean {
20
20
  msg.includes(`Cannot find package '${bare}'`)
21
21
  );
22
22
  }
23
+
24
+ export function isExportsSubpathMiss(err: unknown): boolean {
25
+ if ((err as { code?: string })?.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
26
+ return true;
27
+ }
28
+ const msg =
29
+ (err as { message?: string })?.message ??
30
+ (typeof err === "string" ? err : String(err));
31
+ return msg.includes('is not defined by "exports"');
32
+ }
@@ -3,20 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Speculatively import every server_package's mount module so Bun compiles
8
- * the module graphs into its cache NOW, overlapping the (CPU-bound)
9
- * compilation with whatever I/O the caller runs next. When mountServerPackages
10
- * later imports the same paths they hit the cache (instant).
11
- *
12
- * Same pattern as create-boot-pipeline's speculative preloads — the
13
- * cross-internet DB I/O in the gate chain (~3.4s) is dead time the compiler
14
- * can use. Best-effort + non-fatal: a failed import here (wrong candidate
15
- * path, transient resolution race) is silently swallowed because
16
- * mountServerPackages will re-attempt with full error surfacing; the preload
17
- * only warms the cache, never gates boot.
18
- */
19
-
20
6
  import type { ServiceInstallConfig } from "./load-service-install-row";
21
7
  import { getAppLogger } from "@teamscala/logger/app-loggers";
22
8
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
@@ -27,15 +13,12 @@ import {
27
13
  type MountRegistry,
28
14
  } from "@teamscala/os/runtime-contracts/mount-context";
29
15
  import type { Hono } from "hono";
30
- import { isModuleNotFound } from "./module-not-found";
16
+ import { isExportsSubpathMiss, isModuleNotFound } from "./module-not-found";
31
17
 
32
18
  type ServerPackageMount = (ctx: MountContext) => Promise<void> | void;
33
19
 
34
- /** Candidate import paths tried for each server_package, in priority order.
35
- * Shared by preloadServerPackages (cache-warming) and mountServerPackages
36
- * (resolution) so the candidate set has ONE source of truth. */
37
20
  function mountCandidates(pkg: string): string[] {
38
- return [`${pkg}/mount.ts`, `${pkg}/boot/mount.ts`, pkg];
21
+ return [`${pkg}/mount`, `${pkg}/boot/mount`, pkg];
39
22
  }
40
23
 
41
24
  export async function preloadServerPackages(packages: string[]): Promise<void> {
@@ -81,7 +64,7 @@ export async function mountServerPackages(
81
64
  loaded = true;
82
65
  break;
83
66
  } catch (err) {
84
- if (isModuleNotFound(err, candidate)) {
67
+ if (isModuleNotFound(err, candidate) || isExportsSubpathMiss(err)) {
85
68
  lastNotFound = err;
86
69
  continue;
87
70
  }
@@ -112,6 +95,10 @@ export async function mountServerPackages(
112
95
  log.info(
113
96
  `[scala-install:${serviceSlug}] Mounted server_package: ${pkg} (via ${resolved})`,
114
97
  );
98
+ } else {
99
+ log.warn(
100
+ `[scala-install:${serviceSlug}] server_package ${pkg} resolved at ${resolved} but exports no mount function — nothing registered`,
101
+ );
115
102
  }
116
103
  } catch (err) {
117
104
  if (err instanceof BootFatalError) throw err;
@@ -295,6 +295,25 @@
295
295
  "suite_type": "function_call",
296
296
  "slug": "quiz-gate:gate-declares-the-one-view-entry-marker"
297
297
  },
298
+ {
299
+ "args": [
300
+ {
301
+ "state": "absent"
302
+ },
303
+ "/srv/app/dist/client"
304
+ ],
305
+ "note": "Bug 01a14918-f66b companion: the misdiagnosis reason is RESERVED for a manifest that is genuinely absent (or a carrying shape with no paths), never for a tombstone the build wrote.",
306
+ "label": "an actually-absent manifest is the only case 'never ran or its output is gone' describes",
307
+ "expect": {
308
+ "ok": false,
309
+ "reason": "no readable asset manifest at /srv/app/dist/client/asset-manifest.json — the client build never ran or its output is gone"
310
+ },
311
+ "export": "verifyManifestState",
312
+ "module": "src/build-client-assets/manifest.ts",
313
+ "package": "@teamscala/service-boot",
314
+ "suite_type": "function_call",
315
+ "slug": "service-boot:absent-manifest-verdict-reserves-never-ran"
316
+ },
298
317
  {
299
318
  "args": [
300
319
  "@teamscala/cli-session"
@@ -4178,6 +4197,30 @@
4178
4197
  "package": "@teamscala/service-boot",
4179
4198
  "suite_type": "function_call",
4180
4199
  "slug": "service-boot:to-capability-slug-strips-the-language-and-tier-suffix-from-a-prod-deployable-scala-projects-ts-prod"
4200
+ },
4201
+ {
4202
+ "args": [
4203
+ {
4204
+ "state": "build-failed",
4205
+ "manifest": {
4206
+ "buildFailed": true,
4207
+ "buildFailedAt": "2026-10-17T08:18:00.000Z",
4208
+ "buildFailedReason": "hydrate build failed: Could not resolve \"@teamscala/ui-foundation/list/block\""
4209
+ }
4210
+ },
4211
+ "/srv/app/dist/client"
4212
+ ],
4213
+ "note": "Bug 01a14918-f66b (Build and release desk, delivery 01a1492e): the boot surfaces carried 'the client build never ran or its output is gone' where the artifact records buildFailedReason+buildFailedAt. The verdict is a pure function of the parsed manifest state, so the misdiagnosis for a tombstone is unmakeable.",
4214
+ "label": "a failed-build tombstone verdict quotes the recorded reason, never the misdiagnosis",
4215
+ "expect": {
4216
+ "ok": false,
4217
+ "reason": "the client build recorded its own failure at 2026-10-17T08:18:00.000Z: hydrate build failed: Could not resolve \"@teamscala/ui-foundation/list/block\" (asset manifest /srv/app/dist/client/asset-manifest.json) — the artifact records a failed build, so nothing it names is servable; fix the build the reason names and restart"
4218
+ },
4219
+ "export": "verifyManifestState",
4220
+ "module": "src/build-client-assets/manifest.ts",
4221
+ "package": "@teamscala/service-boot",
4222
+ "suite_type": "function_call",
4223
+ "slug": "service-boot:tombstone-manifest-verdict-quotes-the-recorded-failure"
4181
4224
  }
4182
4225
  ]
4183
4226
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @status generated — ts_function_contract_runner template (scala-test-suites
3
- * templates/ts_function_contract_runner.pkl @1.16.1) over @teamscala/service-boot's
3
+ * templates/ts_function_contract_runner.pkl @1.16.3) over @teamscala/service-boot's
4
4
  * test_vector rows: one `it` per function_call row (args -> value or
5
5
  * expect_error) and one per fs_flow row (ordered steps in a temp
6
6
  * workspace). This runner carries NO facts: every case is the registry
@@ -111,7 +111,7 @@ const ROOT = (() => {
111
111
  throw new Error("no package.json above " + import.meta.dir + " — a contract suite renders inside a package");
112
112
  })();
113
113
 
114
- type Vector = Record<string, unknown> & { suite_type: string; slug: string };
114
+ type Vector = Record<string, unknown> & { suite_type: string; slug: string; module: string; export: string; env?: Record<string, string>; args?: unknown[]; fixtures?: { stubs?: Stub[] } };
115
115
 
116
116
  let ACTIVE_RECORDERS: Record<string, { args: unknown[] }[]> = {};
117
117
  function declareRecorder(name: string): void {
@@ -133,7 +133,7 @@ async function decodeValue(value: unknown): Promise<unknown> {
133
133
  ? ((await import(spec.module)) as Record<string, unknown>)[spec.class]
134
134
  : (globalThis as Record<string, unknown>)[spec.class];
135
135
  if (typeof ctor !== "function") throw new Error("unconstructible error class: " + spec.class);
136
- return new ctor(...(spec.args ?? []));
136
+ return Reflect.construct(ctor, spec.args ?? []);
137
137
  }
138
138
  if (value !== null && typeof value === "object" && "$abort" in (value as object)) {
139
139
  const spec = (value as { $abort: { afterMs?: unknown } }).$abort;
@@ -157,7 +157,7 @@ async function decodeValue(value: unknown): Promise<unknown> {
157
157
  if (typeof value === "object" && value !== null && "$map" in (value as object)) {
158
158
  const spec = (value as { $map: { entries?: unknown[] } }).$map;
159
159
  if (spec === null || typeof spec !== "object" || (spec.entries !== undefined && !Array.isArray(spec.entries))) throw new Error("$map needs {entries?} — an array of [key, value] pairs, or nothing for the empty map a subject is handed when it has nothing to say");
160
- const pairs: unknown[][] = [];
160
+ const pairs: [unknown, unknown][] = [];
161
161
  for (const [i, entry] of ((spec.entries ?? []) as unknown[]).entries()) {
162
162
  if (!Array.isArray(entry) || entry.length !== 2) throw new Error("$map entry " + i + " is " + JSON.stringify(entry) + " — a map entry is a [key, value] pair, and a pair of another length is a typo, not a value");
163
163
  pairs.push([await decodeValue(entry[0]), await decodeValue(entry[1])]);
@@ -851,6 +851,34 @@ function isHook(spec: unknown): boolean {
851
851
  return spec !== null && typeof spec === "object" && !Array.isArray(spec) && (spec as Record<string, unknown>).hook !== undefined;
852
852
  }
853
853
 
854
+ function hookResultText(value: unknown, seen: Set<object>): string {
855
+ if (value === null) return "null";
856
+ const kind = typeof value;
857
+ if (kind === "string") return JSON.stringify(value);
858
+ if (kind === "number") return Number.isFinite(value) ? String(value) : "null";
859
+ if (kind === "bigint") return String(value);
860
+ if (kind === "symbol") return String(value);
861
+ if (kind === "function") return "[function " + (value as { name?: string }).name + "]";
862
+ const asObject = value as object;
863
+ if (seen.has(asObject)) return "[cyclic]";
864
+ seen.add(asObject);
865
+ let text: string;
866
+ if (Array.isArray(value)) {
867
+ text = "[" + Array.from(value as unknown[], (entry) => (entry === undefined || typeof entry === "function" || typeof entry === "symbol" ? "null" : hookResultText(entry, seen))).join(",") + "]";
868
+ } else if (typeof (value as { toJSON?: (this: unknown) => unknown }).toJSON === "function") {
869
+ text = hookResultText((value as { toJSON: (this: unknown) => unknown }).toJSON.call(value), seen);
870
+ } else {
871
+ const members: string[] = [];
872
+ for (const [key, inner] of Object.entries(value as Record<string, unknown>)) {
873
+ if (inner === undefined || typeof inner === "function" || typeof inner === "symbol") continue;
874
+ members.push(JSON.stringify(key) + ":" + hookResultText(inner, seen));
875
+ }
876
+ text = "{" + members.join(",") + "}";
877
+ }
878
+ seen.delete(asObject);
879
+ return text;
880
+ }
881
+
854
882
  function assertHookResult(slug: string, latest: unknown, spec: Record<string, unknown>): void {
855
883
  if (latest === null || typeof latest !== "object") throw new Error("row " + slug + " outcome hook did not hold: the hook returned " + JSON.stringify(latest) + ", which carries no field to read");
856
884
  const value = latest as Record<string, unknown>;
@@ -858,11 +886,14 @@ function assertHookResult(slug: string, latest: unknown, spec: Record<string, un
858
886
  if (!(field in value)) throw new Error("row " + slug + " outcome hook did not hold: the hook returned no field " + field + ", so a claim over it asserts nothing");
859
887
  expect(value[field]).toEqual(want);
860
888
  }
861
- const text = JSON.stringify(value) ?? "";
862
- for (const needle of (spec.contains ?? []) as string[]) {
889
+ const contains = (spec.contains ?? []) as string[];
890
+ const absent = (spec.absent ?? []) as string[];
891
+ if (contains.length === 0 && absent.length === 0) return;
892
+ const text = hookResultText(value, new Set());
893
+ for (const needle of contains) {
863
894
  if (!text.includes(needle)) throw new Error("row " + slug + " outcome hook did not hold: the hook's result does not contain " + JSON.stringify(needle));
864
895
  }
865
- for (const needle of (spec.absent ?? []) as string[]) {
896
+ for (const needle of absent) {
866
897
  if (text.includes(needle)) throw new Error("row " + slug + " outcome hook did not hold: the hook's result still carries " + JSON.stringify(needle));
867
898
  }
868
899
  }
@@ -893,189 +924,6 @@ function isRenderRow(spec: unknown, args: unknown[] | undefined): boolean {
893
924
  return isRender(spec) || (args !== undefined && args.some(hasMarker));
894
925
  }
895
926
 
896
- function buildElements(React: typeof import("react"), value: unknown, slug: string): unknown {
897
- if (Array.isArray(value)) return value.map((item, index) => {
898
- const builtItem = buildElements(React, item, slug);
899
- if (builtItem !== null && typeof builtItem === "object" && (builtItem as Record<string, unknown>).$$typeof !== undefined) return React.cloneElement(builtItem, { key: String(index) });
900
- return builtItem;
901
- });
902
- if (value !== null && typeof value === "object") {
903
- const record = value as Record<string, unknown>;
904
- if (Object.keys(record).includes(ELEMENT_MARKER)) {
905
- if (typeof record[ELEMENT_MARKER] !== "string") throw new Error("row " + slug + " is malformed: " + ELEMENT_MARKER + " takes the error message string, got " + JSON.stringify(record[ELEMENT_MARKER]));
906
- const message = record[ELEMENT_MARKER] as string;
907
- return React.createElement(function Thrower(): never {
908
- throw new Error(message);
909
- });
910
- }
911
- const built: Record<string, unknown> = {};
912
- for (const key of Object.keys(record)) {
913
- if (key.startsWith("$")) throw new Error("row " + slug + " is malformed: element marker " + key + " is not declared — a marker this runner does not know is a child that would render as inert data");
914
- built[key] = buildElements(React, record[key], slug);
915
- }
916
- return built;
917
- }
918
- return value;
919
- }
920
-
921
- function assertRenderSpec(slug: string, container: HTMLElement, spec: Record<string, unknown>): void {
922
- const text = container.textContent ?? "";
923
- const bad: (verb: string, detail: string) => never = (verb, detail) => {
924
- throw new Error("row " + slug + " outcome " + verb + " did not hold: " + detail);
925
- };
926
- for (const needle of (spec.contains ?? []) as string[]) {
927
- if (!text.includes(needle)) bad("contains", "the rendered text has no " + JSON.stringify(needle));
928
- }
929
- for (const needle of (spec.absent ?? []) as string[]) {
930
- if (text.includes(needle)) bad("absent", "the rendered text still carries " + JSON.stringify(needle));
931
- }
932
- for (const selector of (spec.selector_present ?? []) as string[]) {
933
- if (container.querySelector(selector) === null) bad("selector_present", "no element matches " + selector);
934
- }
935
- for (const selector of (spec.selector_absent ?? []) as string[]) {
936
- if (container.querySelector(selector) !== null) bad("selector_absent", selector + " is present");
937
- }
938
- for (const [selector, claims] of Object.entries((spec.attr_equals ?? {}) as Record<string, Record<string, string>>)) {
939
- const el = container.querySelector(selector);
940
- if (el === null) bad("attr_equals", selector + " matches no element, so an attribute claim over it asserts nothing");
941
- for (const [attr, want] of Object.entries(claims)) {
942
- const got = el.getAttribute(attr);
943
- if (got !== String(want)) bad("attr_equals", selector + " carries " + attr + "=" + JSON.stringify(got) + ", the row declares " + JSON.stringify(String(want)));
944
- }
945
- }
946
- for (const [selector, claims] of Object.entries((spec.attr_contains ?? {}) as Record<string, Record<string, string[]>>)) {
947
- const el = container.querySelector(selector);
948
- if (el === null) bad("attr_contains", selector + " matches no element, so an attribute claim over it asserts nothing");
949
- for (const [attr, needles] of Object.entries(claims)) {
950
- const got = el.getAttribute(attr) ?? "";
951
- for (const needle of needles) {
952
- if (!got.includes(needle)) bad("attr_contains", selector + " carries " + attr + "=" + JSON.stringify(got) + ", which does not carry " + JSON.stringify(needle));
953
- }
954
- }
955
- }
956
- }
957
-
958
- async function renderRow(row: Vector): Promise<void> {
959
- if (!globalThis.document) {
960
- const { GlobalRegistrator } = await import("@happy-dom/global-registrator");
961
- await GlobalRegistrator.register({ url: "http://localhost" });
962
- }
963
- const restore = applyEnv(row.env);
964
- try {
965
- await withStubs(row, async () => {
966
- (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
967
- const React = await import("react");
968
- const { createRoot } = await import("react-dom/client");
969
- const act = (React as unknown as { act: (run: () => Promise<void>) => Promise<void> }).act;
970
- const Component = await loadExport(row.module, row.export);
971
- if (typeof Component !== "function") throw new Error("export " + row.export + " in " + row.module + " is not a component — a render row needs a callable component export");
972
- const decoded = await Promise.all(((row.args ?? []) as unknown[]).map(decodeValue));
973
- const props = decoded.length === 0 ? {} : buildElements(React, decoded[0], row.slug);
974
- const host = document.createElement("div");
975
- document.body.appendChild(host);
976
- const root = createRoot(host);
977
- let caught: unknown;
978
- try {
979
- await act(async () => {
980
- root.render(React.createElement(Component as never, props as Record<string, unknown>));
981
- });
982
- } catch (err) {
983
- caught = err;
984
- }
985
- if (row.expect_error !== undefined) {
986
- if (caught === undefined) throw new Error("row " + row.slug + " expected the render to throw " + JSON.stringify(row.expect_error) + ", and it returned a tree");
987
- const message = caught instanceof Error ? caught.message : String(caught);
988
- if (!message.includes(String(row.expect_error))) throw new Error("row " + row.slug + " outcome expect_error did not hold: the render threw " + JSON.stringify(message));
989
- await act(async () => { root.unmount(); });
990
- host.remove();
991
- return;
992
- }
993
- if (caught !== undefined) throw caught instanceof Error ? caught : new Error(String(caught));
994
- const clickSelectors = ((row.expect as Record<string, unknown>).render as Record<string, unknown>).click;
995
- if (clickSelectors !== undefined) {
996
- for (const selector of (Array.isArray(clickSelectors) ? clickSelectors : [clickSelectors]) as string[]) {
997
- const target = host.querySelector(selector);
998
- if (target === null) throw new Error("row " + row.slug + " outcome click did not hold: no element matches " + selector);
999
- await act(async () => {
1000
- target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
1001
- });
1002
- }
1003
- }
1004
- assertRenderSpec(row.slug, host, (row.expect as Record<string, unknown>).render as Record<string, unknown>);
1005
- await act(async () => { root.unmount(); });
1006
- host.remove();
1007
- });
1008
-
1009
- } finally {
1010
- restore();
1011
- }
1012
- }
1013
- async function hookRow(row: Vector): Promise<void> {
1014
- if (!globalThis.document) {
1015
- const { GlobalRegistrator } = await import("@happy-dom/global-registrator");
1016
- await GlobalRegistrator.register({ url: "http://localhost" });
1017
- }
1018
- const restore = applyEnv(row.env);
1019
- try {
1020
- await withStubs(row, async () => {
1021
- (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
1022
- const React = await import("react");
1023
- const { createRoot } = await import("react-dom/client");
1024
- const act = (React as unknown as { act: (run: () => Promise<void>) => Promise<void> }).act;
1025
- const hook = await loadExport(row.module, row.export);
1026
- if (typeof hook !== "function") throw new Error("export " + row.export + " in " + row.module + " is not a hook — a hook row mounts the export and drives what it returns");
1027
- const decoded = await Promise.all(((row.args ?? []) as unknown[]).map(decodeValue));
1028
- if (decoded.length > 1) throw new Error("a hook row takes at most one arg — the options object the hook is mounted with");
1029
- const options = decoded.length === 0 ? {} : decoded[0];
1030
- let latest: unknown = undefined;
1031
- const held: { value: unknown } = { value: undefined };
1032
- ACTIVE_HOOK = held;
1033
- function Probe(): null {
1034
- latest = (hook as (o: unknown) => unknown)(options);
1035
- held.value = latest;
1036
- return null;
1037
- }
1038
- const spec = ((row.expect as Record<string, unknown>).hook) as Record<string, unknown>;
1039
- const providerName = spec.provider as string | undefined;
1040
- let wrapped: unknown = React.createElement(Probe);
1041
- if (providerName !== undefined) {
1042
- const provider = await loadExport(row.module, providerName);
1043
- if (typeof provider !== "function") throw new Error("row " + row.slug + " names hook.provider " + providerName + ", which " + row.module + " does not export as a component — a wrapper the module does not export leaves the hook mounted in nothing");
1044
- const providerProps = await Promise.all(((spec.provider_args ?? []) as unknown[]).map(decodeValue));
1045
- wrapped = React.createElement(provider as (props: unknown) => unknown, (providerProps.length === 0 ? null : providerProps[0]) as never, React.createElement(Probe));
1046
- }
1047
- const root = createRoot(document.createElement("div"));
1048
- await act(async () => { root.render(wrapped); });
1049
- for (const [j, entry] of ((spec.act ?? []) as Record<string, unknown>[]).entries()) {
1050
- const returned = latest as Record<string, unknown> | null | undefined;
1051
- const fn = returned === null || returned === undefined ? undefined : returned[entry.call as string];
1052
- if (typeof fn !== "function") throw new Error("row " + row.slug + " outcome hook did not hold: the hook returned no callable " + entry.call + " at act[" + j + "] — a callback the row drives is one the hook itself returns");
1053
- const actArgs = await Promise.all(((entry.args ?? []) as unknown[]).map(decodeValue));
1054
- let raised: unknown;
1055
- let failed = false;
1056
- try {
1057
- await act(async () => { await (fn as (...a: unknown[]) => unknown)(...actArgs); });
1058
- } catch (err) {
1059
- raised = err;
1060
- failed = true;
1061
- }
1062
- if (entry.expect_error === undefined) {
1063
- if (failed) throw raised;
1064
- } else {
1065
- if (!failed) throw new Error("row " + row.slug + " declares expect_error " + JSON.stringify(String(entry.expect_error)) + " on act[" + j + "] and " + entry.call + " RESOLVED — an act whose failure is the claim must be answered by a rejection");
1066
- const message = String((raised as Error)?.message ?? raised);
1067
- if (!message.includes(String(entry.expect_error))) throw new Error("row " + row.slug + " act[" + j + "] " + entry.call + " rejected with " + JSON.stringify(message) + ", which does not carry " + JSON.stringify(String(entry.expect_error)));
1068
- }
1069
- }
1070
- assertHookResult(row.slug, latest, spec.result as Record<string, unknown>);
1071
- await act(async () => { root.unmount(); });
1072
- ACTIVE_HOOK = null;
1073
- });
1074
- } finally {
1075
- ACTIVE_HOOK = null;
1076
- restore();
1077
- }
1078
- }
1079
927
  function runFsFlow(row: Vector): Promise<void> {
1080
928
  return (async () => {
1081
929
  const workspace = await mkdtemp(join(tmpdir(), "contract-"));
@@ -1205,7 +1053,7 @@ function stubReturn(slug: string, group: Stub[], call: Call): unknown {
1205
1053
  }
1206
1054
 
1207
1055
  async function reactCreateElement(): Promise<(tag: string, attrs: Record<string, unknown>) => unknown> {
1208
- const react = (await import("react")) as { createElement?: unknown };
1056
+ const react = (await import(String("react"))) as { createElement?: unknown };
1209
1057
  if (typeof react.createElement !== "function") throw new Error("a fixture element stub needs react.createElement, and this package does not resolve react — the arm mocks a COMPONENT, so the runtime that draws one is part of the stub");
1210
1058
  return react.createElement as (tag: string, attrs: Record<string, unknown>) => unknown;
1211
1059
  }
@@ -1431,8 +1279,6 @@ function runRow(row: Vector): Promise<void> | void {
1431
1279
  assertWellFormed(row);
1432
1280
  ACTIVE_RECORDERS = {};
1433
1281
  if (SELECTED === undefined && (hasFixtures(row) || hasHoist(row))) return runIsolated(row);
1434
- if (isRenderRow(row.expect, row.args as unknown[] | undefined)) return renderRow(row);
1435
- if (isHook(row.expect)) return hookRow(row);
1436
1282
  if (row.suite_type === "fs_flow") return runFsFlow(row);
1437
1283
  if (row.template !== undefined && row.template !== null) {
1438
1284
  if (row.args !== undefined && row.args !== null) throw new Error("row " + row.slug + " declares both args and template — a tagged-template subject takes its statement as template, and two ways to say the same call is a row whose intent is unreadable");
@@ -8,6 +8,45 @@ import { join } from "node:path";
8
8
  import { getAppLogger } from "@teamscala/logger/app-loggers";
9
9
  import { toCapabilitySlug } from "./capability-slug.ts";
10
10
 
11
+ function orpcRouterFatalMessage(input: {
12
+ serviceSlug: string;
13
+ declaredModelCount: number;
14
+ declaredModelSample: string;
15
+ routerPath: string;
16
+ routerPresent: boolean;
17
+ loadError: unknown;
18
+ }): string {
19
+ if (input.routerPresent) {
20
+ const detail =
21
+ input.loadError !== undefined
22
+ ? input.loadError instanceof Error
23
+ ? `${input.loadError.message}\n${input.loadError.stack ?? ""}`
24
+ : String(input.loadError)
25
+ : "getAppRouter() returned no router and recorded no error";
26
+ return (
27
+ `[scala-install:${input.serviceSlug}] service_schema declares ${input.declaredModelCount} ` +
28
+ `model(s) (${input.declaredModelSample}) and the generated ORPC router EXISTS at ` +
29
+ `${input.routerPath} but failed to load. The import failure, logged as a warning ` +
30
+ `above, was:\n${detail}\n` +
31
+ `Every data-backed page would 500 with MissingLoaderModelCapabilityError while ` +
32
+ `/health reported 200. This is a package-resolution or broken-artifact fault, NOT a ` +
33
+ `missing-bundle fault: fix the failing import the error above names (the missing ` +
34
+ `package, its version window, or the generated artifact) and restart — never patch ` +
35
+ `the image by hand.`
36
+ );
37
+ }
38
+ return (
39
+ `[scala-install:${input.serviceSlug}] service_schema declares ${input.declaredModelCount} ` +
40
+ `model(s) (${input.declaredModelSample}) but no generated ORPC router is present at ` +
41
+ `${input.routerPath}. Every data-backed page would 500 with ` +
42
+ `MissingLoaderModelCapabilityError while /health reported 200. This is a BUILD fault: ` +
43
+ `the codegen_output emission for the ORPC router ` +
44
+ `(prisma/generated/orpc/routers/index.ts) is missing from the bundle — check that ` +
45
+ `the service's codegen_output rows are active and their outputs land in the image. ` +
46
+ `Rebuild via scala-pack; never patch the image by hand.`
47
+ );
48
+ }
49
+
11
50
  export async function registerPageServerClient(
12
51
  serviceSlug: string,
13
52
  ): Promise<void> {
@@ -26,11 +65,13 @@ export async function registerPageServerClient(
26
65
  "prisma/generated/orpc/routers/index.ts",
27
66
  );
28
67
  let appRouter: Record<string, unknown> | null = null;
68
+ let routerLoadError: unknown;
29
69
  if (existsSync(routerPath)) {
30
70
  try {
31
71
  const { getAppRouter } = await import("@teamscala/orpc/app-router");
32
72
  appRouter = ((await getAppRouter()) as Record<string, unknown>) ?? null;
33
73
  } catch (err) {
74
+ routerLoadError = err;
34
75
  log.warn(
35
76
  `[scala-install:${serviceSlug}] appRouter load failed — data-backed pages will 500: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
36
77
  );
@@ -70,14 +111,14 @@ export async function registerPageServerClient(
70
111
  })();
71
112
  if (declaredModels.length > 0 && !appRouter) {
72
113
  throw new Error(
73
- `[scala-install:${serviceSlug}] service_schema declares ${declaredModels.length} ` +
74
- `model(s) (${declaredModels.slice(0, 5).join(", ")}${declaredModels.length > 5 ? ", …" : ""}) ` +
75
- `but no generated ORPC router is present at ${routerPath}. Every data-backed page ` +
76
- `would 500 with MissingLoaderModelCapabilityError while /health reported 200. ` +
77
- `This is a BUILD fault: the codegen_output emission for the ORPC router ` +
78
- `(prisma/generated/orpc/routers/index.ts) is missing from the bundle — check that ` +
79
- `the service's codegen_output rows are active and their outputs land in the image. ` +
80
- `Rebuild via scala-pack; never patch the image by hand.`,
114
+ orpcRouterFatalMessage({
115
+ serviceSlug,
116
+ declaredModelCount: declaredModels.length,
117
+ declaredModelSample: `${declaredModels.slice(0, 5).join(", ")}${declaredModels.length > 5 ? ", …" : ""}`,
118
+ routerPath,
119
+ routerPresent: existsSync(routerPath),
120
+ loadError: routerLoadError,
121
+ }),
81
122
  );
82
123
  }
83
124
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ */
5
+
6
+ export async function resolveSignedInOrgId(
7
+ headers: Headers,
8
+ ): Promise<string | null> {
9
+ const { getSession } = await import("@teamscala/auth/get-session.ts");
10
+ const session = await getSession(headers);
11
+ if (!session?.user) return null;
12
+ const { queryRowsUnsafe } = await import("@teamscala/db/query-rows");
13
+ const rows = (await queryRowsUnsafe(
14
+ 'SELECT fn_resolve_user_org_id($1) AS "resolved"',
15
+ session.user.id,
16
+ )) as Array<{ resolved: { orgId: string | null } | null }>;
17
+ return rows[0]?.resolved?.orgId ?? null;
18
+ }
@@ -17,6 +17,7 @@ import type { Hono } from "hono";
17
17
  import { bindPageHandler } from "./bind-page-handler.ts";
18
18
  import { selectServiceRoutes } from "./select-service-routes.ts";
19
19
  import { resolveRequestGraph } from "./register-registry-page-route/resolve-request-graph.ts";
20
+ import { resolveSignedInOrgId } from "./register-registry-page-route/resolve-signed-in-org.ts";
20
21
  import { stampSessionContext } from "./register-registry-page-route/stamp-session-context.ts";
21
22
  import { runContentGatesForPage } from "./register-registry-page-route/run-content-gates.ts";
22
23
  import {
@@ -281,6 +282,10 @@ export async function registerRegistryPageRoute(
281
282
  });
282
283
 
283
284
  app.get(pattern, async (c) => {
285
+ if (routeConfig.org_home_redirect) {
286
+ const homeOrgId = await resolveSignedInOrgId(c.req.raw.headers);
287
+ if (homeOrgId) return c.redirect(`/${homeOrgId}`, 302);
288
+ }
284
289
  const routeOrgId = c.req.param("orgId");
285
290
  return bindPageHandler(
286
291
  c,
@@ -1,87 +0,0 @@
1
- // @system codegen
2
- // @status generated
3
- // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
- //
5
- // This suite's assertions are OWNED by the codegen band: the band module
6
- // carries them verbatim, this file is the emission, and hand edits here are
7
- // overwritten on the next run. The rationale each assertion carries moved
8
- // with it into the band.
9
-
10
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
11
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
- import { tmpdir } from "node:os";
13
- import { join } from "node:path";
14
- import { writeManifest, verifyClientBuildAssets } from "./manifest.ts";
15
-
16
- let dir: string;
17
-
18
- beforeEach(async () => {
19
- dir = await mkdtemp(join(tmpdir(), "verify-client-build-"));
20
- await mkdir(join(dir, "dist", "client"), { recursive: true });
21
- });
22
-
23
- afterEach(async () => {
24
- await rm(dir, { recursive: true, force: true });
25
- });
26
-
27
- async function writeAssetFiles(styles: string, hydrate: string): Promise<void> {
28
- await Bun.file(join(dir, "dist", "client", styles.replace(/^\/assets\//, ""))).write("body{}");
29
- await Bun.file(join(dir, "dist", "client", hydrate.replace(/^\/assets\//, ""))).write("export{}");
30
- }
31
-
32
- describe("verifyClientBuildAssets", () => {
33
- test("a manifest whose files both exist verifies", async () => {
34
- await writeManifest(join(dir, "dist", "client"), {
35
- stylesPath: "/assets/styles-abc.css",
36
- hydratePath: "/assets/hydrate-abc.js",
37
- });
38
- await writeAssetFiles("/assets/styles-abc.css", "/assets/hydrate-abc.js");
39
- expect(await verifyClientBuildAssets(dir)).toEqual({ ok: true });
40
- });
41
-
42
- test("an absent manifest refuses — the client build never ran", async () => {
43
- const result = await verifyClientBuildAssets(dir);
44
- expect(result.ok).toBe(false);
45
- if (!result.ok) expect(result.reason).toContain("never ran or its output is gone");
46
- });
47
-
48
- test("the first-boot tombstone shape (unhashed paths, no files) refuses", async () => {
49
- await writeManifest(join(dir, "dist", "client"), {
50
- stylesPath: "/assets/styles.css",
51
- hydratePath: "/assets/hydrate.js",
52
- });
53
- const result = await verifyClientBuildAssets(dir);
54
- expect(result.ok).toBe(false);
55
- if (!result.ok) expect(result.reason).toContain("unbuilt assets");
56
- });
57
-
58
- test("a path-less tombstone (the shape build-client-assets now writes) refuses as unbuilt", async () => {
59
- await writeManifest(join(dir, "dist", "client"), {
60
- inputFingerprint: "failed:2026-09-01T00:00:00.000Z",
61
- buildFailed: true,
62
- buildFailedReason: "hydrate build failed: something",
63
- buildFailedAt: "2026-09-01T00:00:00.000Z",
64
- } as never);
65
- const result = await verifyClientBuildAssets(dir);
66
- expect(result.ok).toBe(false);
67
- if (!result.ok) expect(result.reason).toContain("never ran or its output is gone");
68
- });
69
-
70
- test("a manifest naming a file that was deleted refuses (stale-bundle shape)", async () => {
71
- await writeManifest(join(dir, "dist", "client"), {
72
- stylesPath: "/assets/styles-abc.css",
73
- hydratePath: "/assets/hydrate-abc.js",
74
- });
75
- await writeAssetFiles("/assets/styles-abc.css", "/assets/hydrate-abc.js");
76
- await rm(join(dir, "dist", "client", "hydrate-abc.js"));
77
- const result = await verifyClientBuildAssets(dir);
78
- expect(result.ok).toBe(false);
79
- if (!result.ok) expect(result.reason).toContain("hydrate bundle");
80
- });
81
-
82
- test("an unreadable manifest refuses", async () => {
83
- await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write("{broken");
84
- const result = await verifyClientBuildAssets(dir);
85
- expect(result.ok).toBe(false);
86
- });
87
- });