@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.
- package/package.json +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- 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
|
+
}
|