@teamscala/service-boot 0.23.284

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.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Walks `service_install.api_routes[]` and binds each entry to its
7
+ * registered handler (via mountRegistry.handlers, populated by server_packages
8
+ * during their mount phase). Unresolved handler_slug values are fatal — the
9
+ * service cannot boot with declared routes pointing at nothing.
10
+ *
11
+ * Injects x-remote-port header from Bun's getConnInfo so downstream
12
+ * handlers (MCP route) can resolve caller identity via process-tree
13
+ * walking when explicit caller headers are absent.
14
+ */
15
+
16
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
17
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
18
+ import { BootFatalError } from "@teamscala/os/errors/boot-error";
19
+ import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
20
+ import type { Context, Hono } from "hono";
21
+ import { getConnInfo } from "hono/bun";
22
+
23
+ const REMOTE_PORT_HEADER = "x-caller-remote-port";
24
+ // Verified socket-peer address (unforgeable transport origin). Sanitized
25
+ // (client-supplied value deleted) then set from getConnInfo so handlers that
26
+ // receive only a Request (e.g. mcp-tool-runtime/mount.ts) can gate on the
27
+ // genuine transport origin per no-client-controlled-trust-signals — an external
28
+ // attacker cannot make their TCP connection appear on the server's loopback.
29
+ const REMOTE_ADDR_HEADER = "x-caller-remote-address";
30
+
31
+ export function registerApiRoutes(
32
+ app: Hono,
33
+ config: ServiceInstallConfig,
34
+ mountRegistry: MountRegistry,
35
+ serviceSlug: string,
36
+ ): number {
37
+ const log = getAppLogger();
38
+ let count = 0;
39
+ for (const route of config.api_routes ?? []) {
40
+ const handler = mountRegistry.handlers.get(route.handler_slug);
41
+ if (!handler) {
42
+ throw new BootFatalError(
43
+ "register-api-routes",
44
+ serviceSlug,
45
+ `api_route "${route.method} ${route.path}" references handler_slug "${route.handler_slug}" but no server_package registered it`,
46
+ );
47
+ }
48
+ const method = route.method.toLowerCase() as
49
+ | "get"
50
+ | "post"
51
+ | "put"
52
+ | "delete"
53
+ | "patch";
54
+ (app as never as Record<string, (path: string, h: never) => void>)[method](
55
+ route.path,
56
+ (async (c: Context) => {
57
+ let req: Request = c.req.raw;
58
+ try {
59
+ if (typeof c.env !== "object" || c.env === null) {
60
+ log.debug(
61
+ `[register-api-routes] getConnInfo skipped: c.env is ${typeof c.env}`,
62
+ );
63
+ } else {
64
+ const info = getConnInfo(c);
65
+ const port = info?.remote?.port;
66
+ const address = info?.remote?.address ?? null;
67
+ // Port: set when not already present (caller-identity pane
68
+ // resolution preserves a chained value). Address: sanitize +
69
+ // re-set ONLY when getConnInfo returned one — the canonical
70
+ // address injection lives in service-runtime's serve.ts
71
+ // (server.requestIP, which reliably returns address; Hono's
72
+ // getConnInfo does not on Bun). Touching the address here
73
+ // only when we can re-verify avoids clobbering serve.ts's
74
+ // injection.
75
+ const needsPort =
76
+ typeof port === "number" &&
77
+ port > 0 &&
78
+ !req.headers.has(REMOTE_PORT_HEADER);
79
+ if (address !== null || needsPort) {
80
+ const headers = new Headers(req.headers);
81
+ if (address) {
82
+ headers.delete(REMOTE_ADDR_HEADER);
83
+ headers.set(REMOTE_ADDR_HEADER, String(address));
84
+ }
85
+ if (needsPort) headers.set(REMOTE_PORT_HEADER, String(port));
86
+ req = new Request(req.url, {
87
+ method: req.method,
88
+ headers,
89
+ body: req.body,
90
+ });
91
+ }
92
+ log.debug(
93
+ `[register-api-routes] getConnInfo port=${port ?? "undefined"} address=${address ?? "undefined"}`,
94
+ );
95
+ }
96
+ } catch (e) {
97
+ getAppLogger().error(
98
+ `[register-api-routes] getConnInfo failed: ${e}`,
99
+ );
100
+ }
101
+ const params = c.req.param();
102
+ return handler(req, params);
103
+ }) as never,
104
+ );
105
+ count++;
106
+ }
107
+
108
+ // CORS preflight: any service serving cross-origin browser clients (the central
109
+ // chat engine on mcp-ai-chat-system) needs an OPTIONS response. The shared
110
+ // session cookie (Domain=.scala.business) authenticates — only credentialed
111
+ // CORS is needed (no token-passing). Harmless for same-origin services.
112
+ app.options("*", (c) => {
113
+ const origin = c.req.header("origin");
114
+ if (!origin) return new Response(null, { status: 204 });
115
+ return new Response(null, {
116
+ status: 204,
117
+ headers: {
118
+ "Access-Control-Allow-Origin": origin,
119
+ "Access-Control-Allow-Credentials": "true",
120
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
121
+ "Access-Control-Allow-Headers": "Content-Type",
122
+ },
123
+ });
124
+ });
125
+
126
+ return count;
127
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Registers process-level crash handlers (unhandledRejection, uncaughtException)
7
+ * for a given service. Each handler logs the error with the service slug for
8
+ * identification. Per constitution crash-isolation.
9
+ */
10
+
11
+ export function registerCrashHandlers(opts: { serviceSlug: string }): {
12
+ unhandledRejection: boolean;
13
+ uncaughtException: boolean;
14
+ } {
15
+ let unhandledRejection = false;
16
+ let uncaughtException = false;
17
+
18
+ if (process.listenerCount("unhandledRejection") === 0) {
19
+ process.on("unhandledRejection", (reason) => {
20
+ console.error(
21
+ `[scala-install:${opts.serviceSlug}] unhandledRejection:`,
22
+ reason,
23
+ );
24
+ });
25
+ unhandledRejection = true;
26
+ }
27
+
28
+ if (process.listenerCount("uncaughtException") === 0) {
29
+ process.on("uncaughtException", (err) => {
30
+ console.error(
31
+ `[scala-install:${opts.serviceSlug}] uncaughtException:`,
32
+ err,
33
+ );
34
+ });
35
+ uncaughtException = true;
36
+ }
37
+
38
+ return { unhandledRejection, uncaughtException };
39
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit directly
5
+ *
6
+ * Page route registration — reads `page_route` registry rows for the
7
+ * current service and delegates to `registerRegistryPageRoute`.
8
+ *
9
+ * All routes are registry-driven (Phase 1): each `page_route` row pairs
10
+ * a URL pattern with a `resource_loader` graph that resolves data in
11
+ * wave-parallel via `resolveFromLoaderConfig`. No deploy-time shell
12
+ * variant routes — the old `service_install.page_routes[]` Phase 2 path
13
+ * was removed when every active service migrated to registry-driven
14
+ * `page_route` rows (all 9 services carry empty arrays).
15
+ */
16
+
17
+ import type { Hono } from "hono";
18
+ import type { ServiceInstallConfig } from "./load-service-install-row.ts";
19
+ import type { RenderModeLiteral } from "./page-route-shell-types.ts";
20
+ import { registerPageServerClient } from "./register-page-server-client.ts";
21
+ import { registerRegistryPageRoute } from "./register-registry-page-route.ts";
22
+ import type { RendererComponentEntry } from "./types.ts";
23
+ import type { CreatePageHandler } from "./types.ts";
24
+
25
+ export interface RegisterPageRoutesArgs {
26
+ app: Hono;
27
+ config: ServiceInstallConfig;
28
+ components: Record<string, RendererComponentEntry>;
29
+ serviceSlug: string;
30
+ createPageHandler: CreatePageHandler;
31
+ }
32
+
33
+ export async function registerPageRoutes(
34
+ args: RegisterPageRoutesArgs,
35
+ ): Promise<string[]> {
36
+ const { app, config, components, serviceSlug, createPageHandler } = args;
37
+
38
+ await registerPageServerClient(serviceSlug);
39
+
40
+ const declaredMode = (config as { render_mode?: string }).render_mode;
41
+ const renderMode: RenderModeLiteral =
42
+ declaredMode === "portal"
43
+ ? "portals"
44
+ : declaredMode === "utility"
45
+ ? "utility"
46
+ : "public";
47
+ const authMode = (config as { auth?: string }).auth ?? "none";
48
+ const providers = ((config as { providers?: string[] }).providers ??
49
+ []) as string[];
50
+
51
+ const registryPatterns = await registerRegistryPageRoute({
52
+ app,
53
+ serviceSlug,
54
+ components,
55
+ createPageHandler,
56
+ authMode,
57
+ renderMode,
58
+ providers,
59
+ });
60
+
61
+ return registryPatterns;
62
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Registers the system server-client for page-server SSR rendering. Composes
7
+ * the platform-owned routers into the service's generated `appRouter` and
8
+ * stashes the resulting client via setServerClient. Called once at boot before
9
+ * any page route handler fires.
10
+ */
11
+
12
+ import { join } from "node:path";
13
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
14
+
15
+ export async function registerPageServerClient(
16
+ serviceSlug: string,
17
+ ): Promise<void> {
18
+ const log = getAppLogger();
19
+ try {
20
+ const mod = (await import(
21
+ "@teamscala/service-boot/page-server-orpc/server-client" as string
22
+ )) as {
23
+ setServerClient: (client: unknown) => void;
24
+ };
25
+ const { createRouterClient } = await import("@orpc/server");
26
+ const { getPrisma } = await import("@teamscala/db/prisma-registry");
27
+ // Load the composed CRUD router via getAppRouter() — the SAME registered
28
+ // instance the /api/rpc mount uses (mount-orpc-router.ts). The previous code
29
+ // did a raw second `import(cwd + "prisma/generated/orpc/routers/index.ts")`,
30
+ // re-loading the generated routers under a different module specifier than
31
+ // the mount already used; in the full boot process that re-execution throws,
32
+ // and the throw was swallowed by a SILENT catch — leaving the page-server
33
+ // client without the CRUD models, so every data-backed page 500s
34
+ // ("client.<model> is undefined", the scala-sign prod outage). existsSync
35
+ // gates the legitimate no-router case (daemon/portal services that own no DB
36
+ // schema) so it stays a clean skip, never a misleading warn.
37
+ const { existsSync } = await import("node:fs");
38
+ const routerPath = join(
39
+ process.cwd(),
40
+ "prisma/generated/orpc/routers/index.ts",
41
+ );
42
+ let appRouter: Record<string, unknown> | null = null;
43
+ if (existsSync(routerPath)) {
44
+ try {
45
+ const { getAppRouter } = await import("@teamscala/orpc/app-router");
46
+ appRouter = ((await getAppRouter()) as Record<string, unknown>) ?? null;
47
+ } catch (err) {
48
+ // A genuine load failure here means every data-backed page 500s, so it
49
+ // must surface, never be swallowed (protective-layers-fail-explicitly).
50
+ log.warn(
51
+ `[scala-install:${serviceSlug}] appRouter load failed — data-backed pages will 500: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
52
+ );
53
+ }
54
+ }
55
+ const { puckPagesRouter } = await import(
56
+ "@teamscala/service-boot/page-server-orpc/puck-pages-router"
57
+ );
58
+ const { portalDiagRouter } = await import(
59
+ "@teamscala/service-boot/page-server-orpc/portal-diagnostics-router"
60
+ );
61
+ const { userPreferencesRouter } = await import(
62
+ "@teamscala/service-boot/page-server-orpc/user-preferences-router"
63
+ );
64
+ const { auditRevertRouter } = await import(
65
+ "@teamscala/service-boot/page-server-orpc/audit-revert-router"
66
+ );
67
+ const composed = {
68
+ ...((appRouter as Record<string, unknown> | null) ?? {}),
69
+ puckPages: puckPagesRouter,
70
+ portalDiag: portalDiagRouter,
71
+ userPreferences: userPreferencesRouter,
72
+ auditRevert: auditRevertRouter,
73
+ };
74
+ const { loadRegistryConfig } = await import(
75
+ "@teamscala/db/registry/load-config"
76
+ );
77
+ const { PlatformIdentitySchema } = await import(
78
+ "@teamscala/db-validation/registry-schemas/platform-identity"
79
+ );
80
+ // Platform org id is a registry value (registries-are-source-of-truth),
81
+ // not a hardcoded literal — read from config/platform-identity.
82
+ const platformIdentity = await loadRegistryConfig(
83
+ "config",
84
+ "platform-identity",
85
+ PlatformIdentitySchema,
86
+ );
87
+ const client = createRouterClient(
88
+ composed as never,
89
+ {
90
+ context: {
91
+ prisma: getPrisma(),
92
+ user: {
93
+ id: "system",
94
+ isSuperAdmin: true,
95
+ organisationId: platformIdentity.platformOrganisationId,
96
+ },
97
+ },
98
+ } as never,
99
+ );
100
+ mod.setServerClient(client);
101
+ log.info(
102
+ `[scala-install:${serviceSlug}] ORPC serverClient registered (system:super-admin)`,
103
+ );
104
+ } catch (err) {
105
+ log.warn(
106
+ `[scala-install:${serviceSlug}] Failed to load appRouter for serverClient: ${err instanceof Error ? err.message : err}`,
107
+ );
108
+ }
109
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * SSR-prefetch the signed-in user's AI-chat conversation so a click-opened AI
7
+ * panel renders the real conversation on first paint. Extracted from
8
+ * register-registry-page-route.ts (single-purpose-file split).
9
+ */
10
+ import { getServerClient } from "../page-server-orpc/server-client.ts";
11
+
12
+ /**
13
+ * Deep-find the serving `website_id` in the loader graph (resolved). The
14
+ * Utility/Portal shell carries the website id (not a slug) under a loader
15
+ * source key; the client RegionPageSwap reads it from loaderData.*.website_id
16
+ * the same way. Bounded walk (cycle-safe) over the resolved loader results.
17
+ */
18
+ function findWebsiteId(root: unknown): string | undefined {
19
+ if (!root || typeof root !== "object") return undefined;
20
+ const seen = new Set<unknown>();
21
+ const stack: unknown[] = [root];
22
+ while (stack.length > 0) {
23
+ const node = stack.pop() as Record<string, unknown> | null;
24
+ if (!node || typeof node !== "object" || seen.has(node)) continue;
25
+ seen.add(node);
26
+ for (const key in node) {
27
+ if (key === "website_id" && typeof node[key] === "string")
28
+ return node[key] as string;
29
+ const val = node[key];
30
+ if (val && typeof val === "object") stack.push(val);
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ /**
37
+ * SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
38
+ * panel renders the real conversation on first paint — never a loading gate
39
+ * (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
40
+ * isn't rendered when the panel is closed, so its block serverLoader can't run
41
+ * at page-load; this prefetches server-side (regardless of panel open-state) +
42
+ * stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
43
+ * resolved from the /ai panel page's own AiChat block (single source of truth —
44
+ * no duplicate config to drift). Fired in parallel with the post-processors +
45
+ * bounded by their window; a failure degrades silently to the client-side
46
+ * createOrRestore fallback, never an error page.
47
+ */
48
+ export async function prefetchAiChatSession(
49
+ resolved: Record<string, unknown>,
50
+ sessionUserId: string | undefined,
51
+ orgId: string | undefined,
52
+ ): Promise<Record<string, unknown> | null> {
53
+ if (!sessionUserId || !orgId) return null;
54
+ // The serving website id is nested in the loader graph (the client
55
+ // RegionPageSwap reads it from loaderData.*.website_id the same way) — the
56
+ // Utility/Portal shell carries the website id, not a slug. Deep-find it.
57
+ const websiteSlug = findWebsiteId(resolved);
58
+ if (!websiteSlug) return null;
59
+ try {
60
+ const client = getServerClient() as {
61
+ regions?: {
62
+ loadPage?: (i: {
63
+ websiteSlug: string;
64
+ path: string;
65
+ }) => Promise<{
66
+ puckData?: {
67
+ content?: Array<{
68
+ type?: string;
69
+ props?: { agent?: string; agentId?: string };
70
+ }>;
71
+ };
72
+ } | null>;
73
+ };
74
+ fn?: {
75
+ chatSession?: {
76
+ createOrRestore?: (i: {
77
+ agentId: string;
78
+ organisationId: string;
79
+ userId?: string;
80
+ }) => Promise<{
81
+ conversationId: string;
82
+ messages?: unknown[];
83
+ tokenTotals?: unknown;
84
+ hasMore?: boolean;
85
+ }>;
86
+ };
87
+ };
88
+ };
89
+ // Load the /ai panel page config (cached 60s SWR — static, no per-user
90
+ // data) + find the AiChat block's agent (the single source of truth).
91
+ const page = await client.regions?.loadPage?.({ websiteSlug, path: "/ai" });
92
+ const aiBlock = page?.puckData?.content?.find((b) => b?.type === "AiChat");
93
+ const agentId = aiBlock?.props?.agent ?? aiBlock?.props?.agentId;
94
+ if (!agentId) return null;
95
+ const session = await client.fn?.chatSession?.createOrRestore?.({
96
+ agentId,
97
+ organisationId: orgId,
98
+ userId: sessionUserId,
99
+ });
100
+ if (!session?.conversationId) return null;
101
+ return session as Record<string, unknown>;
102
+ } catch {
103
+ // Prefetch failed — client-side createOrRestore is the fallback.
104
+ return null;
105
+ }
106
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Regression guard for readCachedGraph's stale-while-revalidate decision
7
+ * (capability-harvest-on-split / what-must-have-a-test). The fold onto
8
+ * @teamscala/cache's getWithMeta + refreshInBackground must keep firing the
9
+ * background refresh past the fresh window — if it ever stops, the page-route
10
+ * graph cache silently degrades to fresh-or-miss (the measured 3.8s
11
+ * cache-expired in-request regression the SWR exists to prevent).
12
+ */
13
+ import { describe, expect, test } from "bun:test";
14
+ import { createCache } from "@teamscala/cache/create-cache";
15
+ import { readCachedGraph } from "./graph-cache.ts";
16
+
17
+ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
18
+
19
+ describe("readCachedGraph", () => {
20
+ test("fresh hit returns the cached value and fires NO refresh", async () => {
21
+ const cache = createCache<unknown>("graph-cache-test:fresh", {
22
+ maxSize: 4,
23
+ ttlMs: 1000,
24
+ staleTtlMs: 1000,
25
+ });
26
+ let refreshCalls = 0;
27
+ cache.set("k", "v");
28
+ expect(readCachedGraph(cache, "k", async () => ++refreshCalls)).toBe("v");
29
+ await delay(5);
30
+ expect(refreshCalls).toBe(0);
31
+ });
32
+
33
+ test("stale hit returns the cached value AND fires the background refresh", async () => {
34
+ const cache = createCache<unknown>("graph-cache-test:stale", {
35
+ maxSize: 4,
36
+ ttlMs: 20,
37
+ staleTtlMs: 5000,
38
+ });
39
+ let refreshCalls = 0;
40
+ cache.set("k", "v");
41
+ await delay(40); // past ttlMs (fresh) → within staleTtlMs → stale
42
+ // Serves the stale value instantly...
43
+ expect(readCachedGraph(cache, "k", async () => ++refreshCalls)).toBe("v");
44
+ // ...and fires exactly one background refresh.
45
+ await delay(5);
46
+ expect(refreshCalls).toBe(1); // FAILS if the background refresh stops firing
47
+ });
48
+
49
+ test("miss returns undefined so the caller resolves synchronously", () => {
50
+ const cache = createCache<unknown>("graph-cache-test:miss", {
51
+ maxSize: 4,
52
+ ttlMs: 1000,
53
+ staleTtlMs: 1000,
54
+ });
55
+ let refreshCalls = 0;
56
+ expect(
57
+ readCachedGraph(cache, "absent", async () => ++refreshCalls),
58
+ ).toBeUndefined();
59
+ expect(refreshCalls).toBe(0);
60
+ });
61
+
62
+ test("stale refresh is single-flight (a second stale read while in-flight does not re-fire)", async () => {
63
+ const cache = createCache<unknown>("graph-cache-test:dedup", {
64
+ maxSize: 4,
65
+ ttlMs: 20,
66
+ staleTtlMs: 5000,
67
+ });
68
+ let refreshCalls = 0;
69
+ let resolveRefresh: (value: number) => void = () => {};
70
+ const slowRefresh = (): Promise<number> =>
71
+ new Promise<number>((resolve) => {
72
+ refreshCalls++;
73
+ resolveRefresh = resolve;
74
+ });
75
+ cache.set("k", "v");
76
+ await delay(40); // stale
77
+ readCachedGraph(cache, "k", slowRefresh); // fires refresh → in-flight
78
+ readCachedGraph(cache, "k", slowRefresh); // deduped against the same in-flight map
79
+ await delay(5);
80
+ expect(refreshCalls).toBe(1); // only ONE refresh in flight, not two
81
+ resolveRefresh(refreshCalls);
82
+ await delay(5);
83
+ });
84
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten @indivisible-unit
4
+ * @edit edit directly
5
+ *
6
+ * The two SWR loader-graph caches (static-shell + parameterised routes) for the
7
+ * registry page registrar, plus readCachedGraph — the testable stale-while-
8
+ * revalidate read. Extracted from register-registry-page-route.ts so the SWR
9
+ * decision is unit-testable independent of the Hono/DB/graph machinery.
10
+ *
11
+ * The SWR contract rides @teamscala/cache directly (the hand-rolled `at` timestamp,
12
+ * FRESH_MS window, and graphRefreshInflight single-flight Set are gone): each
13
+ * cache is configured ttlMs = the fresh window (serve, no refresh) + staleTtlMs =
14
+ * the rest (serve stale + fire ONE background refresh). readCachedGraph uses
15
+ * getWithMeta for the fresh/stale/miss decision and cache.refreshInBackground
16
+ * for the fire-and-forget refresh — deduped against the cache's own in-flight map
17
+ * (so delete/clear/disable drop its flights, and a refresh in flight across an
18
+ * invalidation can no longer resurrect the dropped entry).
19
+ *
20
+ * Graphs deep-clone on BOTH set and get at the caller (registerRegistryPageRoute):
21
+ * gateAgreementContent + downstream block resolution MUTATE the object in place,
22
+ * so handing a cached tree out twice would ship request A's resolved data to
23
+ * request B (ssr-first-tick-parity). Clone-on-get stays local to the caller.
24
+ */
25
+ import { createCache } from "@teamscala/cache/create-cache";
26
+
27
+ // The structural surface readCachedGraph touches (getWithMeta + refreshInBackground).
28
+ // ManagedCache<unknown> satisfies it; declared locally so the SWR decision does
29
+ // not depend on the cache package's internal type export path.
30
+ type GraphCache = {
31
+ getWithMeta(key: string): { value: unknown; stale: boolean } | undefined;
32
+ refreshInBackground(
33
+ key: string,
34
+ compute: () => Promise<unknown>,
35
+ opts?: { ttlMs?: number },
36
+ ): void;
37
+ };
38
+
39
+ // Static-shell routes (no resource_type, no :params, no wildcard — the fleet
40
+ // dashboard's "/", any fixed page) resolve the SAME loader graph every request:
41
+ // pure page/website structure that changes only on editor writes, yet every SSR
42
+ // re-paid the full graph over the tunnel DB (load=1.8-2.2s measured 2026-06-06 —
43
+ // the whole remaining load tier). 60s fresh (ttlMs) + 540s stale (staleTtlMs) =
44
+ // 600s total; past the fresh window a hit still serves instantly and fires ONE
45
+ // background re-resolve. Without SWR every cache-expiry request paid the full
46
+ // resolve in-request — measured 3.8s on a cache-expired hit.
47
+ export const staticShellGraphCache = createCache<unknown>(
48
+ "registry-route:static-shell-graph",
49
+ { maxSize: 64, ttlMs: 60_000, staleTtlMs: 540_000 },
50
+ );
51
+
52
+ // Parameterised routes (:param / wildcard — record pages, the portal catchall)
53
+ // were permanently uncacheable: every hit re-paid the full graph resolve (~2.3s
54
+ // from the dev host). The graphKey encodes routeRow.slug + host + path and the
55
+ // graph is purely path-determined (searchParams, theme, agreement gating all
56
+ // apply AFTER the clone), so a short fresh window is safe: a record edit is
57
+ // visible within 15s on a trafficked page (background refresh). 15s fresh
58
+ // (ttlMs) + 285s stale (staleTtlMs) = 300s total.
59
+ export const paramRouteGraphCache = createCache<unknown>(
60
+ "registry-route:param-route-graph",
61
+ { maxSize: 256, ttlMs: 15_000, staleTtlMs: 285_000 },
62
+ );
63
+
64
+ /**
65
+ * Stale-while-revalidate read of a cached loader graph.
66
+ *
67
+ * - Fresh hit (within ttlMs): return the cached graph, no refresh.
68
+ * - Stale hit (within ttlMs + staleTtlMs): return the cached graph AND fire ONE
69
+ * background refresh via cache.refreshInBackground — single-flight (deduped
70
+ * against the cache's in-flight map), never rejects, leaves the stale entry in
71
+ * place on failure. The caller deep-clones the returned value before use.
72
+ * - Miss (past staleTtlMs, or never cached): return undefined — the caller
73
+ * resolves synchronously and cache.set()s the clone.
74
+ *
75
+ * The refresh callback must return a graph safe to store. To SKIP caching a
76
+ * refresh result (e.g. a primary-missing / dead-link graph that must stay
77
+ * per-request so a just-created record appears immediately), the callback THROWS
78
+ * — refreshInBackground catches it, leaves the stale entry, and logs one line
79
+ * (an observable dead-link signal, not a hard error).
80
+ */
81
+ export function readCachedGraph(
82
+ cache: GraphCache,
83
+ key: string,
84
+ refresh: () => Promise<unknown>,
85
+ ): unknown | undefined {
86
+ const meta = cache.getWithMeta(key);
87
+ if (meta === undefined) return undefined;
88
+ if (meta.stale) cache.refreshInBackground(key, refresh);
89
+ return meta.value;
90
+ }