@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,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
* @indivisible-unit
|
|
6
|
+
*
|
|
7
|
+
* One-call install of the entire logging pipeline. After installLogging()
|
|
8
|
+
* runs, every error in the service — process-level, HTTP middleware, Hono
|
|
9
|
+
* handlers, Puck blocks, even third-party packages calling console.* — is
|
|
10
|
+
* captured by @teamscala/logger and flows to Sentry/GlitchTip (prod) or
|
|
11
|
+
* loga (dev) with full context. Application code never imports the logger
|
|
12
|
+
* or wraps anything in try/catch for telemetry purposes.
|
|
13
|
+
*
|
|
14
|
+
* Marked @indivisible-unit: installLogging (early bootstrap, hardcoded
|
|
15
|
+
* defaults) and applyLoggerConfig (late-stage, DB-driven) are the two
|
|
16
|
+
* phases of the same one-call install — the service boot pipeline calls
|
|
17
|
+
* them in sequence with the same serviceSlug.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ManagedCache } from "@teamscala/cache/cache";
|
|
21
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
22
|
+
import { createDiskCache } from "@teamscala/cache/disk-cache";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { userHome } from "@teamscala/os/host-paths";
|
|
25
|
+
import { slugify } from "@teamscala/formatters/slugify";
|
|
26
|
+
import { createTimer } from "@teamscala/timing/create-timer";
|
|
27
|
+
import { configure as dbConfigure } from "@teamscala/db/configure";
|
|
28
|
+
import { getPrisma as dbGetPrisma } from "@teamscala/db/prisma-registry";
|
|
29
|
+
import { getEntriesByType as dbGetEntriesByType } from "@teamscala/db/registry/getEntriesByType";
|
|
30
|
+
import { loadRegistryConfig as dbLoadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
31
|
+
import { configure as httpConfigure } from "@teamscala/http/configure";
|
|
32
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
33
|
+
import { setMinLevel } from "@teamscala/logger/creator";
|
|
34
|
+
import { configureFileSink } from "@teamscala/logger/file-sink";
|
|
35
|
+
import { initSentry } from "@teamscala/logger/init-sentry";
|
|
36
|
+
import { attachRequestLogger } from "@teamscala/logger/install-logging";
|
|
37
|
+
import { getRequestContext } from "@teamscala/logger/request-context-async";
|
|
38
|
+
import { configure as orpcConfigure } from "@teamscala/orpc/configure";
|
|
39
|
+
|
|
40
|
+
// Re-exported from @teamscala/logger (the canonical home, per
|
|
41
|
+
// vertical-dependency-only). service-boot consumers that previously
|
|
42
|
+
// imported from service-boot/install-logging continue to work; the
|
|
43
|
+
// implementation lives once in @teamscala/logger.
|
|
44
|
+
export { attachRequestLogger };
|
|
45
|
+
|
|
46
|
+
const CONSOLE_METHODS = [
|
|
47
|
+
"log",
|
|
48
|
+
"info",
|
|
49
|
+
"warn",
|
|
50
|
+
"error",
|
|
51
|
+
"debug",
|
|
52
|
+
"trace",
|
|
53
|
+
] as const;
|
|
54
|
+
type ConsoleMethod = (typeof CONSOLE_METHODS)[number];
|
|
55
|
+
|
|
56
|
+
export interface InstallLoggingOptions {
|
|
57
|
+
serviceSlug: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Initial bootstrap install — runs BEFORE DB/Registry are available.
|
|
61
|
+
* Uses hardcoded defaults (INFO level, /tmp/scala-logs). */
|
|
62
|
+
export function installLogging(opts: InstallLoggingOptions): void {
|
|
63
|
+
const { serviceSlug } = opts;
|
|
64
|
+
|
|
65
|
+
initSentry({ service: serviceSlug, dsn: process.env.SENTRY_DSN });
|
|
66
|
+
|
|
67
|
+
installConsoleShim(serviceSlug);
|
|
68
|
+
installProcessHandlers(serviceSlug);
|
|
69
|
+
|
|
70
|
+
// Bootloader injection per `bootloader-injection-contract`. After
|
|
71
|
+
// initSentry + the console redirect, the logger surface is live; inject it
|
|
72
|
+
// into every configurable primitive that defaults to noop. The list is
|
|
73
|
+
// enumerated here; it becomes registry-driven once #57 lands.
|
|
74
|
+
const log = getAppLogger();
|
|
75
|
+
// Persisted last-known-good tier for registry config reads — created here
|
|
76
|
+
// (runtime tier) so host-path resolution stays out of the foundation-tier
|
|
77
|
+
// db package, which cannot import @teamscala/cache or resolve userHome itself.
|
|
78
|
+
// load-config serves this on a transient DB failure instead of crash-looping.
|
|
79
|
+
const registryConfigDiskCache = createDiskCache("db:registry-config", {
|
|
80
|
+
dir: join(userHome(), ".scala-db-cache", "registry-config"),
|
|
81
|
+
ttlMs: 24 * 60 * 60 * 1000,
|
|
82
|
+
});
|
|
83
|
+
dbConfigure({
|
|
84
|
+
logger: log,
|
|
85
|
+
// `as never` bridges the configured-primitives type gap: db holds
|
|
86
|
+
// structural approximations of these foundation factories (it cannot
|
|
87
|
+
// import @teamscala/cache/timing for the real CacheOptions/TimerOptions
|
|
88
|
+
// without re-creating the same-tier edge). The runtime values are the
|
|
89
|
+
// real factories; the cast only reconciles the approximate signatures.
|
|
90
|
+
ManagedCache: ManagedCache as never,
|
|
91
|
+
createCache: createCache as never,
|
|
92
|
+
createTimer: createTimer as never,
|
|
93
|
+
diskCache: registryConfigDiskCache as never,
|
|
94
|
+
slugify,
|
|
95
|
+
});
|
|
96
|
+
orpcConfigure({
|
|
97
|
+
logger: log,
|
|
98
|
+
getPrisma: () => dbGetPrisma() as never,
|
|
99
|
+
loadRegistryConfig: dbLoadRegistryConfig as never,
|
|
100
|
+
getEntriesByType: async (type) =>
|
|
101
|
+
dbGetEntriesByType(type as never) as never,
|
|
102
|
+
scalaDevKey: process.env.SCALA_DEV_KEY,
|
|
103
|
+
});
|
|
104
|
+
httpConfigure({
|
|
105
|
+
logger: log,
|
|
106
|
+
requestContextProvider: () => getRequestContext(),
|
|
107
|
+
});
|
|
108
|
+
// @teamscala/mcp-multi-session and @teamscala/tool-executor are MCP-only;
|
|
109
|
+
// their configure() calls moved to @teamscala/mcp-tool-runtime/mount.ts
|
|
110
|
+
// per `runtime-deps-universal-only`. MCP services declare mcp-tool-runtime
|
|
111
|
+
// in service_install.server_packages and pick up the configures there.
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Late-stage configuration — runs AFTER Registry is loaded.
|
|
115
|
+
* Injects DB-driven settings into the logger primitive. */
|
|
116
|
+
export function applyLoggerConfig(config: {
|
|
117
|
+
minLevel?: "debug" | "info" | "warn" | "error";
|
|
118
|
+
logDir?: string;
|
|
119
|
+
maxFileSize?: number;
|
|
120
|
+
maxFiles?: number;
|
|
121
|
+
}): void {
|
|
122
|
+
if (config.minLevel) {
|
|
123
|
+
setMinLevel(config.minLevel);
|
|
124
|
+
}
|
|
125
|
+
configureFileSink({
|
|
126
|
+
logDir: config.logDir,
|
|
127
|
+
maxFileSize: config.maxFileSize,
|
|
128
|
+
maxFiles: config.maxFiles,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function installConsoleShim(serviceSlug: string): void {
|
|
133
|
+
const log = getAppLogger();
|
|
134
|
+
const methodMap: Record<
|
|
135
|
+
ConsoleMethod,
|
|
136
|
+
(msg: string, ctx: Record<string, unknown>) => void
|
|
137
|
+
> = {
|
|
138
|
+
log: (m, c) => log.info(m, c),
|
|
139
|
+
info: (m, c) => log.info(m, c),
|
|
140
|
+
warn: (m, c) => log.warn(m, c),
|
|
141
|
+
error: (m, c) => log.error(m, c),
|
|
142
|
+
debug: (m, c) => log.debug?.(m, c) ?? log.info(m, c),
|
|
143
|
+
trace: (m, c) => log.debug?.(m, c) ?? log.info(m, c),
|
|
144
|
+
};
|
|
145
|
+
for (const method of CONSOLE_METHODS) {
|
|
146
|
+
const original = console[method].bind(console);
|
|
147
|
+
(console as unknown as Record<string, unknown>)[method] = (
|
|
148
|
+
...args: unknown[]
|
|
149
|
+
) => {
|
|
150
|
+
try {
|
|
151
|
+
const msg = args
|
|
152
|
+
.map((a) => (typeof a === "string" ? a : safeStringify(a)))
|
|
153
|
+
.join(" ");
|
|
154
|
+
methodMap[method](msg, { service: serviceSlug, source: "console" });
|
|
155
|
+
} catch {
|
|
156
|
+
original(...args);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function installProcessHandlers(serviceSlug: string): void {
|
|
163
|
+
const log = getAppLogger();
|
|
164
|
+
process.on("unhandledRejection", (reason) => {
|
|
165
|
+
log.error(`[${serviceSlug}] unhandledRejection`, {
|
|
166
|
+
err: reason,
|
|
167
|
+
stack: (reason as Error)?.stack,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
process.on("uncaughtException", (err) => {
|
|
171
|
+
log.error(`[${serviceSlug}] uncaughtException`, { err, stack: err.stack });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function safeStringify(v: unknown): string {
|
|
176
|
+
if (v instanceof Error) return v.stack ?? v.message;
|
|
177
|
+
try {
|
|
178
|
+
return JSON.stringify(v);
|
|
179
|
+
} catch {
|
|
180
|
+
return String(v);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Loads the service_install registry row for a given service slug.
|
|
7
|
+
* Uses registry.require() per Constitution Rule 11.
|
|
8
|
+
* Schema is owned by @teamscala/db-validation (Rule 7).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getCache } from "@teamscala/db/registry/loader/cache/get-cache";
|
|
12
|
+
import {
|
|
13
|
+
RegistryMissingError,
|
|
14
|
+
RegistryValidationError,
|
|
15
|
+
} from "@teamscala/db/registry/require";
|
|
16
|
+
import { normalizeRegistryConfigObject } from "@teamscala/db/registry/validation";
|
|
17
|
+
import type { ServiceInstallConfig } from "@teamscala/db-validation/registry-schemas/service-install";
|
|
18
|
+
import {
|
|
19
|
+
enforceExplicitRenderMode,
|
|
20
|
+
ServiceInstallConfigSchema,
|
|
21
|
+
ServiceInstallValidationError,
|
|
22
|
+
} from "@teamscala/db-validation/registry-schemas/service-install";
|
|
23
|
+
import * as v from "valibot";
|
|
24
|
+
|
|
25
|
+
export { ServiceInstallConfigSchema } from "@teamscala/db-validation/registry-schemas/service-install";
|
|
26
|
+
export type { ServiceInstallConfig };
|
|
27
|
+
|
|
28
|
+
export interface LoadServiceInstallRowResult {
|
|
29
|
+
success: true;
|
|
30
|
+
config: ServiceInstallConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LoadServiceInstallRowError {
|
|
34
|
+
success: false;
|
|
35
|
+
error: "row_not_found" | "validation_failed";
|
|
36
|
+
slug: string;
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type LoadServiceInstallRowOutcome =
|
|
41
|
+
| LoadServiceInstallRowResult
|
|
42
|
+
| LoadServiceInstallRowError;
|
|
43
|
+
|
|
44
|
+
export async function loadServiceInstallRow(
|
|
45
|
+
serviceSlug: string,
|
|
46
|
+
): Promise<LoadServiceInstallRowOutcome> {
|
|
47
|
+
try {
|
|
48
|
+
const cacheEntry = getCache().get(`service_install:${serviceSlug}`);
|
|
49
|
+
if (!cacheEntry) {
|
|
50
|
+
throw new RegistryMissingError(serviceSlug);
|
|
51
|
+
}
|
|
52
|
+
const rawConfig = normalizeRegistryConfigObject(
|
|
53
|
+
cacheEntry.config,
|
|
54
|
+
`service_install:${serviceSlug}`,
|
|
55
|
+
) as Record<string, unknown>;
|
|
56
|
+
enforceExplicitRenderMode(serviceSlug, rawConfig);
|
|
57
|
+
const config = v.parse(ServiceInstallConfigSchema, rawConfig);
|
|
58
|
+
return { success: true, config };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof RegistryMissingError) {
|
|
61
|
+
const sql = `INSERT INTO registry_entries (type, slug, config, is_active) VALUES ('service_install', '${serviceSlug}', '{"kind":"ui","render_mode":"public","port":null,"page_routes":[],"providers":[],"api_routes":[],"block_packages":[],"teamgbg_packages":[],"env_vars":[]}'::jsonb, true);`;
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
error: "row_not_found",
|
|
65
|
+
slug: serviceSlug,
|
|
66
|
+
message: [
|
|
67
|
+
`service_install row not found for "${serviceSlug}".`,
|
|
68
|
+
``,
|
|
69
|
+
`Create it with:`,
|
|
70
|
+
sql,
|
|
71
|
+
].join("\n"),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (err instanceof RegistryValidationError) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: "validation_failed",
|
|
78
|
+
slug: serviceSlug,
|
|
79
|
+
message: `service_install row for "${serviceSlug}" failed schema validation`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (err instanceof ServiceInstallValidationError) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: "validation_failed",
|
|
86
|
+
slug: serviceSlug,
|
|
87
|
+
message: err.message,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
error: "row_not_found",
|
|
94
|
+
slug: serviceSlug,
|
|
95
|
+
message: `Unexpected error loading service_install for "${serviceSlug}": ${err}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Loads the service's own generated PrismaClient from
|
|
7
|
+
* <cwd>/prisma/generated/prisma/client.ts. Each service repo generates its own
|
|
8
|
+
* client from its own schema.prisma; resolving a static import would pick up
|
|
9
|
+
* whichever @prisma/generated bun resolves first in a multi-service workspace,
|
|
10
|
+
* which is wrong.
|
|
11
|
+
*
|
|
12
|
+
* No fallback: if the generated client is missing the deploy is broken — fail
|
|
13
|
+
* loud with the remediation step.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ServicePrismaClientConstructor = new (args: {
|
|
17
|
+
adapter: unknown;
|
|
18
|
+
}) => unknown;
|
|
19
|
+
|
|
20
|
+
export async function loadServicePrismaClient(
|
|
21
|
+
serviceSlug: string,
|
|
22
|
+
): Promise<ServicePrismaClientConstructor> {
|
|
23
|
+
// Per `near-empty-services`, the platform owns one Prisma client (in
|
|
24
|
+
// @teamscala/db) shared by every service. Service-local Prisma clients
|
|
25
|
+
// remain supported when a service ships its own `prisma/schema.prisma`.
|
|
26
|
+
const fs = await import("node:fs");
|
|
27
|
+
const servicePath = `${process.cwd()}/prisma/generated/prisma/client.ts`;
|
|
28
|
+
// `@teamscala/db/client` is the sanctioned subpath that re-exports the
|
|
29
|
+
// generated PrismaClient (per `@teamscala/db/package.json#exports`).
|
|
30
|
+
// The previous import of `@teamscala/db/generated/client/client.ts`
|
|
31
|
+
// reached into an internal path that isn't exported; under bun
|
|
32
|
+
// isolated-linker + node's exports-field enforcement, that path
|
|
33
|
+
// fails to resolve at runtime even though the file is on disk.
|
|
34
|
+
const platformPath = "@teamscala/db/client";
|
|
35
|
+
const path = fs.existsSync(servicePath) ? servicePath : platformPath;
|
|
36
|
+
let mod: { PrismaClient?: ServicePrismaClientConstructor };
|
|
37
|
+
try {
|
|
38
|
+
mod = (await import(path)) as {
|
|
39
|
+
PrismaClient?: ServicePrismaClientConstructor;
|
|
40
|
+
};
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[scala-install:${serviceSlug}] Failed to load Prisma client at ${path}. ` +
|
|
44
|
+
`Cause: ${err instanceof Error ? err.message : err}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (!mod.PrismaClient) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[scala-install:${serviceSlug}] Prisma client at ${path} has no PrismaClient export.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return mod.PrismaClient;
|
|
53
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Mounts the composed ORPC app router (generated Prisma CRUD + custom fn routers)
|
|
7
|
+
* at /api/rpc/* over HTTP via @orpc/server/fetch's RPCHandler.
|
|
8
|
+
* Uses getAppRouter() from @teamscala/orpc/app-router which merges generated
|
|
9
|
+
* routers with custom routers registered via registerCustomRouter().
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
|
+
import { registerFnRouters } from "./fn-routers/index.ts";
|
|
16
|
+
import type { Hono } from "hono";
|
|
17
|
+
import { bodyLimit } from "hono/body-limit";
|
|
18
|
+
|
|
19
|
+
export interface OrpcMountResult {
|
|
20
|
+
routeCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isLocalhostRequest(headers: Headers): boolean {
|
|
24
|
+
const host = (headers.get("host") || "").split(":")[0];
|
|
25
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function mountOrpcRouter(
|
|
29
|
+
app: Hono,
|
|
30
|
+
serviceSlug: string,
|
|
31
|
+
): Promise<OrpcMountResult | null> {
|
|
32
|
+
const log = getAppLogger();
|
|
33
|
+
registerFnRouters();
|
|
34
|
+
const routerPath = join(
|
|
35
|
+
process.cwd(),
|
|
36
|
+
"prisma/generated/orpc/routers/index.ts",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// No generated ORPC router is the EXPECTED state for daemon-only / boot-hook
|
|
40
|
+
// services (session-picker, package-poller, etc.) that own no DB schema or
|
|
41
|
+
// api_routes — they serve no /api/rpc surface. Skip silently: importing a
|
|
42
|
+
// nonexistent module would throw into the catch below and surface a
|
|
43
|
+
// misleading "ORPC router not mounted" WARN on every boot. The catch is
|
|
44
|
+
// reserved for GENUINE mount failures (router present but fails to load).
|
|
45
|
+
// A service that SHOULD have a router but doesn't is a codegen gap caught by
|
|
46
|
+
// API verification, not a boot warning.
|
|
47
|
+
if (!existsSync(routerPath)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const { getAppRouter } = await import("@teamscala/orpc/app-router");
|
|
53
|
+
const router = await getAppRouter();
|
|
54
|
+
if (!router || Object.keys(router).length === 0) return null;
|
|
55
|
+
|
|
56
|
+
const { RPCHandler } = await import("@orpc/server/fetch");
|
|
57
|
+
const handler = new RPCHandler(router as never, {});
|
|
58
|
+
|
|
59
|
+
const { getPrisma } = await import("@teamscala/db/prisma-registry");
|
|
60
|
+
|
|
61
|
+
// Bridge the composed router into tool-executor so in-process tool
|
|
62
|
+
// dispatch (chat agents' work_items/meetings/agreements/proposals
|
|
63
|
+
// tools, etc.) resolves against this service's ORPC router + Prisma
|
|
64
|
+
// client. Without this every ORPC tool call throws
|
|
65
|
+
// "app router provider not configured". Parallel to dev-mcp-boot's
|
|
66
|
+
// configureToolExecutor. Calls getAppRouter() fresh each time (not a
|
|
67
|
+
// boot-time capture) so the tool-executor always resolves the CURRENT
|
|
68
|
+
// memoized router — the tool-generator listener may regenerate the
|
|
69
|
+
// ORPC router and call clearAppRouterCache() after boot, producing a
|
|
70
|
+
// new router object that a captured closure would miss.
|
|
71
|
+
const { configure: configureToolExecutor } = await import(
|
|
72
|
+
"@teamscala/tool-executor/configure"
|
|
73
|
+
);
|
|
74
|
+
configureToolExecutor({
|
|
75
|
+
getAppRouter: () => getAppRouter(),
|
|
76
|
+
getPrisma: () => getPrisma(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const resolveUser = async (headers: Headers) => {
|
|
80
|
+
if (isLocalhostRequest(headers)) {
|
|
81
|
+
return {
|
|
82
|
+
id: "localhost",
|
|
83
|
+
role: "super_admin" as const,
|
|
84
|
+
isSuperAdmin: true,
|
|
85
|
+
organisationId: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const { getSession } = await import("@teamscala/auth/get-session.ts");
|
|
90
|
+
const session = await getSession(headers);
|
|
91
|
+
if (!session?.user) return null;
|
|
92
|
+
const role = session.user.role;
|
|
93
|
+
return {
|
|
94
|
+
id: session.user.id,
|
|
95
|
+
name: session.user.name ?? undefined,
|
|
96
|
+
email: session.user.email,
|
|
97
|
+
// The active organisation lives on the SESSION
|
|
98
|
+
// (activeOrganizationId, set at sign-in by scala-otp's
|
|
99
|
+
// session.create hook), NOT on the user object — getSession's
|
|
100
|
+
// ResolvedSession.user carries no organisationId. Reading the
|
|
101
|
+
// user field left organisationId always null, so the generated
|
|
102
|
+
// router's org-scope gate 403'd every non-super-admin write
|
|
103
|
+
// ("Organization membership required") on the public-HTTP path.
|
|
104
|
+
organisationId: session.session.activeOrganizationId ?? null,
|
|
105
|
+
// better-auth's admin plugin issues role "admin"; the platform
|
|
106
|
+
// also uses "super-admin"/"super_admin". All three are platform
|
|
107
|
+
// super-admins for ORPC org-scope bypass.
|
|
108
|
+
isSuperAdmin:
|
|
109
|
+
role === "super_admin" ||
|
|
110
|
+
role === "super-admin" ||
|
|
111
|
+
role === "admin",
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
app.post(
|
|
119
|
+
"/api/rpc/*",
|
|
120
|
+
bodyLimit({
|
|
121
|
+
maxSize: 10 * 1024 * 1024,
|
|
122
|
+
onError: (c) => c.json({ error: "Request body too large" }, 413),
|
|
123
|
+
}),
|
|
124
|
+
async (c) => {
|
|
125
|
+
const prisma = getPrisma();
|
|
126
|
+
const result = await handler.handle(c.req.raw, {
|
|
127
|
+
prefix: "/api/rpc",
|
|
128
|
+
context: { prisma, headers: c.req.raw.headers, resolveUser },
|
|
129
|
+
});
|
|
130
|
+
if (!result?.matched) return c.notFound();
|
|
131
|
+
return result.response;
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const routeKeys = Object.keys(router as Record<string, unknown>).filter(
|
|
136
|
+
(k) => typeof (router as Record<string, unknown>)[k] !== "undefined",
|
|
137
|
+
);
|
|
138
|
+
log.info(
|
|
139
|
+
`[scala-install:${serviceSlug}] ORPC router mounted at /api/rpc/* (${routeKeys.length} procedure(s))`,
|
|
140
|
+
);
|
|
141
|
+
return { routeCount: routeKeys.length };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log.warn(
|
|
144
|
+
`[scala-install:${serviceSlug}] ORPC router not mounted: ${err instanceof Error ? err.message : err}`,
|
|
145
|
+
);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Walks `service_install.server_packages[]`, dynamic-imports each, and calls
|
|
7
|
+
* its `mount(ctx)` function. Server packages register handlers / boot hooks /
|
|
8
|
+
* scheduled jobs / WS routes via the MountContext exposed in ctx; the
|
|
9
|
+
* resulting MountRegistry is consumed by register-api-routes /
|
|
10
|
+
* register-ws-routes / run-boot-hook-list / start-scheduled-jobs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
|
+
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
16
|
+
import {
|
|
17
|
+
createMountContext,
|
|
18
|
+
createMountRegistry,
|
|
19
|
+
type MountContext,
|
|
20
|
+
type MountRegistry,
|
|
21
|
+
} from "@teamscala/os/runtime-contracts/mount-context";
|
|
22
|
+
import type { Hono } from "hono";
|
|
23
|
+
|
|
24
|
+
type ServerPackageMount = (ctx: MountContext) => Promise<void> | void;
|
|
25
|
+
|
|
26
|
+
/** Candidate import paths tried for each server_package, in priority order.
|
|
27
|
+
* Shared by preloadServerPackages (cache-warming) and mountServerPackages
|
|
28
|
+
* (resolution) so the candidate set has ONE source of truth. */
|
|
29
|
+
function mountCandidates(pkg: string): string[] {
|
|
30
|
+
return [`${pkg}/mount.ts`, `${pkg}/boot/mount.ts`, pkg];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Speculatively import every server_package's mount module so Bun compiles
|
|
35
|
+
* the module graphs into its cache NOW, overlapping the (CPU-bound)
|
|
36
|
+
* compilation with whatever I/O the caller runs next. When mountServerPackages
|
|
37
|
+
* later imports the same paths they hit the cache (instant).
|
|
38
|
+
*
|
|
39
|
+
* Same pattern as create-boot-pipeline's speculative preloads — the
|
|
40
|
+
* cross-internet DB I/O in the gate chain (~3.4s) is dead time the compiler
|
|
41
|
+
* can use. Best-effort + non-fatal: a failed import here (wrong candidate
|
|
42
|
+
* path, transient resolution race) is silently swallowed because
|
|
43
|
+
* mountServerPackages will re-attempt with full error surfacing; the preload
|
|
44
|
+
* only warms the cache, never gates boot.
|
|
45
|
+
*/
|
|
46
|
+
export async function preloadServerPackages(packages: string[]): Promise<void> {
|
|
47
|
+
await Promise.allSettled(
|
|
48
|
+
packages.flatMap((pkg) =>
|
|
49
|
+
mountCandidates(pkg).map((candidate) =>
|
|
50
|
+
import(candidate).catch(() => {}),
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function mountServerPackages(
|
|
57
|
+
app: Hono,
|
|
58
|
+
config: ServiceInstallConfig,
|
|
59
|
+
serviceSlug: string,
|
|
60
|
+
): Promise<MountRegistry> {
|
|
61
|
+
const log = getAppLogger();
|
|
62
|
+
const mountRegistry = createMountRegistry();
|
|
63
|
+
const mountCtx = createMountContext(serviceSlug, config, app, mountRegistry);
|
|
64
|
+
const serverPackages = (config.server_packages ?? []) as string[];
|
|
65
|
+
|
|
66
|
+
if (serverPackages.length === 0) {
|
|
67
|
+
log.info(`[scala-install:${serviceSlug}] No server_packages declared`);
|
|
68
|
+
return mountRegistry;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isModuleNotFound = (err: unknown, requestedPath: string): boolean => {
|
|
72
|
+
// Bun's ResolveMessage is not an Error subclass — read message defensively.
|
|
73
|
+
// Match ONLY when the missing module is the candidate path itself; transitive
|
|
74
|
+
// not-found errors (e.g. a file inside the candidate fails to resolve a
|
|
75
|
+
// different module) must surface, not be swallowed as "not found here".
|
|
76
|
+
const msg =
|
|
77
|
+
(err as { message?: string })?.message ??
|
|
78
|
+
(typeof err === "string" ? err : String(err));
|
|
79
|
+
return (
|
|
80
|
+
msg.includes(`Cannot find module '${requestedPath}'`) ||
|
|
81
|
+
msg.includes(`Cannot find package '${requestedPath}'`)
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (const pkg of serverPackages) {
|
|
86
|
+
try {
|
|
87
|
+
let mod: {
|
|
88
|
+
mount?: ServerPackageMount;
|
|
89
|
+
default?: { mount?: ServerPackageMount };
|
|
90
|
+
} | undefined;
|
|
91
|
+
const candidates = mountCandidates(pkg);
|
|
92
|
+
let lastNotFound: unknown;
|
|
93
|
+
let resolved: (typeof candidates)[number] | undefined;
|
|
94
|
+
let loaded = false;
|
|
95
|
+
for (const candidate of candidates) {
|
|
96
|
+
try {
|
|
97
|
+
mod = (await import(candidate)) as typeof mod;
|
|
98
|
+
resolved = candidate;
|
|
99
|
+
loaded = true;
|
|
100
|
+
break;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (isModuleNotFound(err, candidate)) {
|
|
103
|
+
lastNotFound = err;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Real error (syntax, runtime, transitive resolution failure inside the
|
|
107
|
+
// loaded module) — surface it directly. Swallowing here is what hides
|
|
108
|
+
// the true cause behind a misleading "cannot find <pkg>" further down.
|
|
109
|
+
throw new BootFatalError(
|
|
110
|
+
"mount-server-packages",
|
|
111
|
+
serviceSlug,
|
|
112
|
+
`server_package "${pkg}" load failed at "${candidate}": ${err instanceof Error ? err.message : err}`,
|
|
113
|
+
err,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!loaded) {
|
|
118
|
+
throw new BootFatalError(
|
|
119
|
+
"mount-server-packages",
|
|
120
|
+
serviceSlug,
|
|
121
|
+
`server_package "${pkg}" not found at any of: ${candidates.join(", ")}`,
|
|
122
|
+
lastNotFound,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const mountFn: ServerPackageMount | undefined =
|
|
126
|
+
typeof mod?.mount === "function"
|
|
127
|
+
? mod?.mount
|
|
128
|
+
: typeof mod?.default?.mount === "function"
|
|
129
|
+
? mod?.default.mount
|
|
130
|
+
: undefined;
|
|
131
|
+
if (mountFn) {
|
|
132
|
+
await mountFn(mountCtx);
|
|
133
|
+
log.info(
|
|
134
|
+
`[scala-install:${serviceSlug}] Mounted server_package: ${pkg} (via ${resolved})`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err instanceof BootFatalError) throw err;
|
|
139
|
+
throw new BootFatalError(
|
|
140
|
+
"mount-server-packages",
|
|
141
|
+
serviceSlug,
|
|
142
|
+
`server_package "${pkg}" mount failed: ${err instanceof Error ? err.message : err}`,
|
|
143
|
+
err,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
log.info(
|
|
149
|
+
`[scala-install:${serviceSlug}] Registered ${mountRegistry.handlers.size} handler(s), ` +
|
|
150
|
+
`${mountRegistry.wsRoutes.size} ws route(s), ${mountRegistry.scheduledJobs.size} scheduled job(s), ` +
|
|
151
|
+
`${mountRegistry.bootHooks.size} boot hook(s)`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return mountRegistry;
|
|
155
|
+
}
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Server-package mount for opt-in service-boot watchdogs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MountContext } from "@teamscala/os/runtime-contracts/mount-context";
|
|
10
|
+
import { startScalaToolsCurrencyWatchdog } from "./scala-tools-currency-watchdog";
|
|
11
|
+
|
|
12
|
+
export function mount(ctx: MountContext): void {
|
|
13
|
+
if (ctx.serviceSlug !== "scala-sentinel") return;
|
|
14
|
+
ctx.registerBootHook("scala-tools-currency-watchdog", async () => {
|
|
15
|
+
startScalaToolsCurrencyWatchdog();
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|