@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,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
+