@teamscala/service-boot 0.23.350 → 0.23.352

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 (39) hide show
  1. package/package.json +479 -567
  2. package/src/agreement-gate.ts +1 -1
  3. package/src/auth-gate.ts +0 -11
  4. package/src/fn-routers/index.ts +1 -12
  5. package/src/fn-routers/public-agreement-router.ts +37 -49
  6. package/src/mount-orpc-router.test.ts +46 -0
  7. package/src/mount-orpc-router.ts +16 -3
  8. package/src/mutable-app-ref.ts +0 -20
  9. package/src/page-server-orpc/puck-pages-router.ts +0 -102
  10. package/src/register-page-server-client.ts +0 -4
  11. package/src/register-ws-routes.ts +10 -3
  12. package/src/two-phase-boot.ts +2 -1
  13. package/src/ui-service.ts +11 -18
  14. package/src/fn-routers/ai-health-router.ts +0 -64
  15. package/src/fn-routers/panel-data-router.ts +0 -93
  16. package/src/fn-routers/plans-router.ts +0 -141
  17. package/src/fn-routers/task-sync-assign.test.ts +0 -483
  18. package/src/fn-routers/task-sync-assign.ts +0 -485
  19. package/src/fn-routers/task-sync-bind.test.ts +0 -110
  20. package/src/fn-routers/task-sync-brief.test.ts +0 -120
  21. package/src/fn-routers/task-sync-brief.ts +0 -151
  22. package/src/fn-routers/task-sync-classify.test.ts +0 -109
  23. package/src/fn-routers/task-sync-close.test.ts +0 -44
  24. package/src/fn-routers/task-sync-close.ts +0 -82
  25. package/src/fn-routers/task-sync-created-status.test.ts +0 -58
  26. package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
  27. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
  28. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  29. package/src/fn-routers/task-sync-projection.test.ts +0 -172
  30. package/src/fn-routers/task-sync-projection.ts +0 -273
  31. package/src/fn-routers/task-sync-rename.test.ts +0 -53
  32. package/src/fn-routers/task-sync-rename.ts +0 -111
  33. package/src/fn-routers/tasks-router.ts +0 -77
  34. package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
  35. package/src/page-server-orpc/templates-router.ts +0 -116
  36. package/src/serve/serve-ws.test.ts +0 -135
  37. package/src/serve/serve.ts +0 -148
  38. package/src/serve/stamp-transport-identity.test.ts +0 -24
  39. package/src/serve/stamp-transport-identity.ts +0 -109
@@ -280,7 +280,7 @@ async function buildSigningSession(
280
280
  // A named "viewer"/"cc" recipient — like an org member below — is
281
281
  // view-only: authedRole "viewer" matches no party key, so no slot.
282
282
  // Certificate data is built only for a party signer (same email-must-
283
- // be-a-party gate as publicAgreement.certificate) and seeded for inline
283
+ // be-a-party gate) and seeded for inline
284
284
  // render; a view-only recipient gets null (and the cell never shows it).
285
285
  const isSigner = me.recipientRole === "signer";
286
286
  const cert = isSigner
package/src/auth-gate.ts CHANGED
@@ -107,17 +107,6 @@ const PUBLIC_INFRA_PREFIXES = [
107
107
  // for no session). They replaced the former /api/data/sign-in-flow-config,
108
108
  // /check-session, /portals HANDLER_MAP entries (handler-map-retirement).
109
109
  "/api/rpc/fn/signInFlow/",
110
- // fn.taskSync procedures (lane task-list <-> work_items). Exempt so a
111
- // localhost hook/plugin (Claude PostToolUse curl, OpenCode scala-hooks
112
- // fetch) reaches the ORPC router, where mount-orpc-router's resolveUser
113
- // grants localhost -> super_admin (the documented internal caller model) and
114
- // a non-localhost caller still needs a session (resolveUser returns null ->
115
- // authed throws 401). Safety rests on Host:127.0.0.1 being unforgeable
116
- // through the Cloudflare tunnel (the edge sets Host to the public hostname)
117
- // — verified by the negative case (a non-localhost caller is rejected).
118
- // Tighter than signInFlow: NOT anonymous-allowed (signInFlow returns {} for
119
- // no session; taskSync writes require super-admin or a real session).
120
- "/api/rpc/fn/taskSync/",
121
110
  // scala-enroll device-onboarding: the machine-facing installer + register
122
111
  // routes are TOKEN-authenticated (a single-use enrollment token issued by a
123
112
  // logged-in, dev_access-approved operator), fetched by the onboardee via
@@ -12,34 +12,27 @@
12
12
  * `loadPage` procedure (reusing puckPagesRouter.loadFromWebsite) so a client
13
13
  * RegionPageSwap can fetch a region's Puck page on demand and render it into a
14
14
  * slot — the no-reload half of `region-views-are-pages` (every region is a Puck
15
- * page; switching a view loads a different page's content). loadById is NOT
16
- * exposed (website+path is the client-facing contract, not raw page ids).
15
+ * page; switching a view loads a different page's content).
17
16
  */
18
17
 
19
18
  import { registerCustomRouter } from "@teamscala/orpc/app-router";
20
19
  import { puckPagesRouter } from "@teamscala/service-boot/page-server-orpc/puck-pages-router";
21
20
  import { watchDataChange } from "@teamscala/realtime-utils/data-change-procedure";
22
21
  import { watchPresence } from "@teamscala/realtime-utils/presence-procedure";
23
- import { aiHealthHandler } from "./ai-health-router.ts";
24
- import { resolvePanelDataHandler } from "./panel-data-router.ts";
25
22
  import { agreementSendRouter } from "./agreement-send-router.ts";
26
23
  import { cacheAdminRouter } from "./cache-admin-router.ts";
27
24
  import { chatSessionRouter } from "./chat-session-router.ts";
28
25
  import { proposalSendRouter } from "./proposal-send-router.ts";
29
26
  import { authRouter } from "./auth-router.ts";
30
- import { plansRouter } from "./plans-router.ts";
31
27
  import { publicAgreementRouter } from "./public-agreement-router.ts";
32
28
  import { publicAssessmentRouter } from "./public-assessment-router.ts";
33
29
  import { publicGptSessionRouter } from "./public-gpt-session-router.ts";
34
30
  import { signInFlowRouter } from "./sign-in-flow-router.ts";
35
- import { tasksRouter } from "./tasks-router.ts";
36
31
  import { userPreferencesRouter } from "./user-preferences-router.ts";
37
- import { userPreferencesRouter as portalPreferencesRouter } from "../page-server-orpc/user-preferences-router.ts";
38
32
  import { workItemsRouter } from "./work-items-router.ts";
39
33
 
40
34
  export function registerFnRouters(): void {
41
35
  registerCustomRouter("fn", {
42
- aiHealth: aiHealthHandler,
43
36
  agreementSend: agreementSendRouter,
44
37
  proposalSend: proposalSendRouter,
45
38
  auth: authRouter,
@@ -49,17 +42,13 @@ export function registerFnRouters(): void {
49
42
  publicAssessment: publicAssessmentRouter,
50
43
  publicGptSession: publicGptSessionRouter,
51
44
  signInFlow: signInFlowRouter,
52
- tasks: tasksRouter,
53
45
  workItems: workItemsRouter,
54
46
  userPreferences: userPreferencesRouter,
55
- portalPreferences: portalPreferencesRouter,
56
- plans: plansRouter,
57
47
  });
58
48
  registerCustomRouter("regions", {
59
49
  loadPage: puckPagesRouter.loadFromWebsite,
60
50
  // Resolve a page-kind panel's OWN loaderData (panel_view.loader_slug) so a
61
51
  // panel region renders against the panel page's data, not the host's.
62
- resolvePanelData: resolvePanelDataHandler,
63
52
  });
64
53
  registerCustomRouter("realtime", {
65
54
  watchDataChange,
@@ -256,10 +256,8 @@ async function loadCertificateCopy(): Promise<CertificateCopy | null> {
256
256
  }
257
257
 
258
258
  /** The Certificate of Completion data shape — the court-style audit certificate.
259
- * One home: {@link buildCertificateData} is consumed by both the
260
- * `publicAgreement.certificate` ORPC and the SSR agreement gate, so the signing
261
- * block receives identical data whether it reads the ORPC response or the
262
- * server-seeded prop. */
259
+ * One home: {@link buildCertificateData} is consumed by the SSR agreement
260
+ * gate, so the signing block receives the server-seeded prop. */
263
261
  export interface CertificateData {
264
262
  ok: boolean;
265
263
  envelopeId?: string;
@@ -292,8 +290,8 @@ export interface CertificateData {
292
290
  /**
293
291
  * Build the Certificate of Completion data for an agreement, gated to a party
294
292
  * email — signer evidence (IP/device) is never exposed to a non-party. One data
295
- * home: the `publicAgreement.certificate` ORPC and the SSR agreement gate
296
- * (`buildSigningSession` in agreement-gate.ts) both call this. The read is
293
+ * home: the SSR agreement gate (`buildSigningSession` in agreement-gate.ts)
294
+ * calls this. The read is
297
295
  * intentionally RLS-unscoped via the raw registry prisma — the certificate is
298
296
  * reached without a session (AG cookie only), which context.prisma's RLS would
299
297
  * otherwise treat as no-org and hide. Accepts either the agreement record id or
@@ -306,22 +304,24 @@ export async function buildCertificateData(
306
304
  const id = agreementId.trim();
307
305
  const mail = email.trim().toLowerCase();
308
306
  if (!id || !mail) return { ok: false };
309
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
310
- const db = getPrisma();
311
- const agreement = await db.agreements.findFirst({
312
- where: { OR: [{ id }, { page_id: id }] },
313
- select: {
314
- id: true,
315
- name: true,
316
- created_at: true,
317
- sent_at: true,
318
- viewed_at: true,
319
- signed_at: true,
320
- signature_data: true,
321
- signer_email: true,
322
- signer_name: true,
323
- },
324
- });
307
+ const [agreement] = await queryRows<
308
+ {
309
+ id: string;
310
+ name: string;
311
+ created_at: Date | null;
312
+ sent_at: Date | null;
313
+ viewed_at: Date | null;
314
+ signed_at: Date | null;
315
+ signature_data: unknown;
316
+ signer_email: string | null;
317
+ signer_name: string | null;
318
+ }[]
319
+ >`
320
+ SELECT id, name, created_at, sent_at, viewed_at, signed_at,
321
+ signature_data, signer_email, signer_name
322
+ FROM agreements
323
+ WHERE (id = ${id} OR page_id = ${id})
324
+ LIMIT 1`;
325
325
  if (!agreement) return { ok: false };
326
326
  const parties = parseParties(agreement.signature_data);
327
327
  const evidence = parseEvidence(agreement.signature_data);
@@ -793,17 +793,6 @@ export const publicAgreementRouter = {
793
793
  }
794
794
  }),
795
795
 
796
- // Certificate of Completion — the court-style audit certificate for an
797
- // agreement. Delegates to buildCertificateData (the single data home shared
798
- // with the SSR agreement gate); the email-must-be-a-party gate lives there,
799
- // so signer evidence (IP/device) is never exposed to a non-party. Read-only;
800
- // the signing block renders it inline.
801
- certificate: pub
802
- .input(v.object({ agreementId: v.string(), email: v.string() }))
803
- .handler(async ({ input }) =>
804
- buildCertificateData(input.agreementId, input.email),
805
- ),
806
-
807
796
  // Public tamper-verification: re-hash the currently-stored document + roster
808
797
  // and compare to the sealed hash captured at completion. Returns only
809
798
  // public-safe data (names, roles, signed timestamps, hash match) — never
@@ -818,18 +807,19 @@ export const publicAgreementRouter = {
818
807
  // is unauthenticated (no access cookie), so context.prisma's RLS would
819
808
  // hide the row — use the raw registry prisma, the same bypass the gate's
820
809
  // signer-email resolver uses.
821
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
822
- const db = getPrisma();
823
- const agreement = await db.agreements.findFirst({
824
- where: { OR: [{ id: agreementId }, { page_id: agreementId }] },
825
- select: {
826
- id: true,
827
- name: true,
828
- signed_at: true,
829
- signature_data: true,
830
- page_id: true,
831
- },
832
- });
810
+ const [agreement] = await queryRows<
811
+ {
812
+ id: string;
813
+ name: string;
814
+ signed_at: Date | null;
815
+ signature_data: unknown;
816
+ page_id: string | null;
817
+ }[]
818
+ >`
819
+ SELECT id, name, signed_at, signature_data, page_id
820
+ FROM agreements
821
+ WHERE (id = ${agreementId} OR page_id = ${agreementId})
822
+ LIMIT 1`;
833
823
  if (!agreement) return { ok: false as const };
834
824
  const parties = parseParties(agreement.signature_data);
835
825
  const evidence = parseEvidence(agreement.signature_data);
@@ -837,10 +827,8 @@ export const publicAgreementRouter = {
837
827
  !!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
838
828
  let recomputedHash: string | null = null;
839
829
  if (evidence?.documentHash && parties && agreement.page_id) {
840
- const doc = await db.builda_pages.findFirst({
841
- where: { id: agreement.page_id },
842
- select: { puck_data: true },
843
- });
830
+ const [doc] = await queryRows<{ puck_data: unknown }[]>`
831
+ SELECT puck_data FROM builda_pages WHERE id = ${agreement.page_id} LIMIT 1`;
844
832
  recomputedHash = computeDocumentHash(doc?.puck_data ?? null, parties);
845
833
  }
846
834
  return {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Regression coverage for the generated-router boot seam. A service schema
7
+ * without its generated router is a codegen failure, not a daemon-only
8
+ * service, so production boot must fail before it can report healthy.
9
+ */
10
+
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { mountOrpcRouter } from "./mount-orpc-router.ts";
16
+
17
+ const originalCwd = process.cwd();
18
+ const tempDirs: string[] = [];
19
+
20
+ afterEach(() => {
21
+ process.chdir(originalCwd);
22
+ for (const directory of tempDirs.splice(0)) {
23
+ rmSync(directory, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ describe("mountOrpcRouter generated-artifact seam", () => {
28
+ test("fails when schema exists but generated router is absent", async () => {
29
+ const service = mkdtempSync(join(tmpdir(), "mount-orpc-router-test-"));
30
+ tempDirs.push(service);
31
+ mkdirSync(join(service, "prisma"), { recursive: true });
32
+ writeFileSync(
33
+ join(service, "prisma/schema.prisma"),
34
+ 'datasource db { provider = "postgresql" }',
35
+ );
36
+ process.chdir(service);
37
+
38
+ await expect(
39
+ mountOrpcRouter({} as never, "scala-mcp"),
40
+ ).rejects.toThrow(
41
+ "Prisma schema exists at " +
42
+ join(service, "prisma/schema.prisma") +
43
+ " but the generated ORPC router is missing",
44
+ );
45
+ });
46
+ });
@@ -35,6 +35,7 @@ export async function mountOrpcRouter(
35
35
  process.cwd(),
36
36
  "prisma/generated/orpc/routers/index.ts",
37
37
  );
38
+ const schemaPath = join(process.cwd(), "prisma/schema.prisma");
38
39
 
39
40
  // No generated ORPC router is the EXPECTED state for daemon-only / boot-hook
40
41
  // services (session-picker, dep-sync, etc.) that own no DB schema or
@@ -45,6 +46,13 @@ export async function mountOrpcRouter(
45
46
  // A service that SHOULD have a router but doesn't is a codegen gap caught by
46
47
  // API verification, not a boot warning.
47
48
  if (!existsSync(routerPath)) {
49
+ if (existsSync(schemaPath)) {
50
+ throw new Error(
51
+ `[scala-install:${serviceSlug}] Prisma schema exists at ${schemaPath} ` +
52
+ `but the generated ORPC router is missing at ${routerPath}. ` +
53
+ `Refusing to boot without the generated router; regenerate codegen before starting.`,
54
+ );
55
+ }
48
56
  return null;
49
57
  }
50
58
 
@@ -56,7 +64,12 @@ export async function mountOrpcRouter(
56
64
  const { RPCHandler } = await import("@orpc/server/fetch");
57
65
  const handler = new RPCHandler(router as never, {});
58
66
 
59
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
67
+ // The bootloader injects the service Prisma client into ORPC's configured
68
+ // primitive. Reading it through that surface keeps this mount on the same
69
+ // dependency boundary as the generated procedures.
70
+ const { getPrisma: getConfiguredPrisma } = await import(
71
+ "@teamscala/orpc/configure"
72
+ );
60
73
 
61
74
  // Bridge the composed router into tool-executor so in-process tool
62
75
  // dispatch (chat agents' work_items/meetings/agreements/proposals
@@ -73,7 +86,7 @@ export async function mountOrpcRouter(
73
86
  );
74
87
  configureToolExecutor({
75
88
  getAppRouter: () => getAppRouter(),
76
- getPrisma: () => getPrisma(),
89
+ getPrisma: () => getConfiguredPrisma(),
77
90
  });
78
91
 
79
92
  const resolveUser = async (headers: Headers) => {
@@ -122,7 +135,7 @@ export async function mountOrpcRouter(
122
135
  onError: (c) => c.json({ error: "Request body too large" }, 413),
123
136
  }),
124
137
  async (c) => {
125
- const prisma = getPrisma();
138
+ const prisma = getConfiguredPrisma();
126
139
  const result = await handler.handle(c.req.raw, {
127
140
  prefix: "/api/rpc",
128
141
  context: { prisma, headers: c.req.raw.headers, resolveUser },
@@ -14,8 +14,6 @@
14
14
  */
15
15
 
16
16
  import type { Hono } from "hono";
17
- import { stampTransportIdentity } from "./serve/stamp-transport-identity.ts";
18
-
19
17
  let currentApp: Hono | null = null;
20
18
  let appLabel = "initial";
21
19
 
@@ -55,21 +53,3 @@ export function swapApp(newApp: Hono, newLabel: string): string {
55
53
  appLabel = newLabel;
56
54
  return oldLabel;
57
55
  }
58
-
59
- /**
60
- * Fetch handler that routes through the mutable app reference.
61
- * This replaces the direct app.fetch call in the serve layer.
62
- *
63
- * Stamps transport-derived identity (x-caller-remote-address/-port + the
64
- * loopback local-caller comm) via the SAME stampTransportIdentity helper as
65
- * direct mode, so two-phase-boot (mutable-ref) UI services carry identical
66
- * identity on every request — closing the gap where the mutable-ref path
67
- * previously served requests with no transport headers stamped at all.
68
- */
69
- export function fetchWithMutableApp(
70
- req: Request,
71
- server: ReturnType<typeof Bun.serve>,
72
- ): Promise<Response> {
73
- const app = getApp();
74
- return Promise.resolve(app.fetch(stampTransportIdentity(req, server), { server }));
75
- }
@@ -165,108 +165,6 @@ export const puckPagesRouter = {
165
165
  });
166
166
  }),
167
167
 
168
- loadById: procedure
169
- .input(v.object({ pageId: v.string() }))
170
- .output(v.nullable(PuckPageRowSchema))
171
- .handler(async ({ input, context }) => {
172
- const db = (context as { prisma?: unknown }).prisma;
173
- if (!db) throw new Error("puckPages.loadById requires context.prisma");
174
-
175
- console.log("[DEBUG-loadById] pageId:", input.pageId);
176
-
177
- const row = (await (
178
- db as {
179
- builda_pages: { findFirst: (args: unknown) => Promise<unknown> };
180
- }
181
- ).builda_pages.findFirst({
182
- where: { id: input.pageId, is_active: true },
183
- select: {
184
- id: true,
185
- path: true,
186
- puck_data: true,
187
- website_id: true,
188
- meta_title: true,
189
- meta_description: true,
190
- },
191
- })) as {
192
- id: string;
193
- path: string | null;
194
- puck_data: unknown;
195
- website_id: string;
196
- meta_title: string | null;
197
- meta_description: string | null;
198
- } | null;
199
-
200
- if (!row) {
201
- console.log("[DEBUG-loadById] NO ROW for pageId:", input.pageId);
202
- return null;
203
- }
204
-
205
- const pd = row.puck_data as { content?: Array<{ type?: string }> } | null;
206
- const types = pd?.content?.map((c) => c.type) ?? [];
207
- console.log(
208
- "[DEBUG-loadById] row found, content types:",
209
- JSON.stringify(types),
210
- );
211
-
212
- const site = (await (
213
- db as {
214
- builda_websites: { findFirst: (args: unknown) => Promise<unknown> };
215
- }
216
- ).builda_websites.findFirst({
217
- where: { id: row.website_id, status: { not: "archived" } },
218
- select: {
219
- id: true,
220
- name: true,
221
- slug: true,
222
- organisation_id: true,
223
- website_type: true,
224
- layout_settings: true,
225
- header_settings: true,
226
- footer_settings: true,
227
- },
228
- })) as {
229
- id: string;
230
- name: string;
231
- slug: string;
232
- organisation_id: string | null;
233
- website_type: string | null;
234
- layout_settings: Record<string, unknown> | null;
235
- header_settings: Record<string, unknown> | null;
236
- footer_settings: Record<string, unknown> | null;
237
- } | null;
238
-
239
- return {
240
- id: row.id,
241
- path: row.path,
242
- puckData: row.puck_data,
243
- mode: null,
244
- render_mode: null,
245
- website_slug: site?.slug ?? "",
246
- organisation_id: site?.organisation_id ?? null,
247
- meta_title: row.meta_title,
248
- meta_description: row.meta_description,
249
- website: site
250
- ? {
251
- slug: site.slug,
252
- name: site.name,
253
- organisation_id: site.organisation_id,
254
- website_type: site.website_type,
255
- layout_settings: site.layout_settings,
256
- header_settings: site.header_settings,
257
- footer_settings: site.footer_settings,
258
- }
259
- : {
260
- slug: "",
261
- name: "",
262
- organisation_id: null,
263
- website_type: null,
264
- layout_settings: null,
265
- header_settings: null,
266
- footer_settings: null,
267
- },
268
- };
269
- }),
270
168
  };
271
169
 
272
170
  export type PuckPagesRouter = typeof puckPagesRouter;
@@ -55,9 +55,6 @@ export async function registerPageServerClient(
55
55
  const { puckPagesRouter } = await import(
56
56
  "@teamscala/service-boot/page-server-orpc/puck-pages-router"
57
57
  );
58
- const { portalDiagRouter } = await import(
59
- "@teamscala/service-boot/page-server-orpc/portal-diagnostics-router"
60
- );
61
58
  const { userPreferencesRouter } = await import(
62
59
  "@teamscala/service-boot/page-server-orpc/user-preferences-router"
63
60
  );
@@ -67,7 +64,6 @@ export async function registerPageServerClient(
67
64
  const composed = {
68
65
  ...((appRouter as Record<string, unknown> | null) ?? {}),
69
66
  puckPages: puckPagesRouter,
70
- portalDiag: portalDiagRouter,
71
67
  userPreferences: userPreferencesRouter,
72
68
  auditRevert: auditRevertRouter,
73
69
  };
@@ -34,8 +34,10 @@ import type {
34
34
  WsRouteHandler,
35
35
  } from "@teamscala/os/runtime-contracts/mount-context";
36
36
  import type { Context, Hono } from "hono";
37
+ import type { createBunWebSocket } from "hono/bun";
37
38
  import * as v from "valibot";
38
- import { upgradeWebSocket } from "./serve/serve.ts";
39
+
40
+ export type UpgradeWebSocket = ReturnType<typeof createBunWebSocket>["upgradeWebSocket"];
39
41
 
40
42
  const ORPC_WS_PATH = "/api/rpc/ws";
41
43
 
@@ -44,6 +46,7 @@ export async function registerWsRoutes(
44
46
  config: ServiceInstallConfig,
45
47
  mountRegistry: MountRegistry,
46
48
  serviceSlug: string,
49
+ upgradeWebSocket: UpgradeWebSocket,
47
50
  ): Promise<number> {
48
51
  const log = getAppLogger();
49
52
  const allDeclared = new Set(
@@ -87,7 +90,7 @@ export async function registerWsRoutes(
87
90
  `Either remove the route declaration or add a server_package that calls ctx.registerWsRoute("${path}", handler).`,
88
91
  );
89
92
  }
90
- app.get(path, adaptLegacyWsHandler(handler, path));
93
+ app.get(path, adaptLegacyWsHandler(handler, path, upgradeWebSocket));
91
94
  }
92
95
 
93
96
  // Unwired: handlers registered by a server_package for a path NOT declared
@@ -112,7 +115,11 @@ export async function registerWsRoutes(
112
115
  return count;
113
116
  }
114
117
 
115
- function adaptLegacyWsHandler(legacy: WsRouteHandler, pattern: string) {
118
+ function adaptLegacyWsHandler(
119
+ legacy: WsRouteHandler,
120
+ pattern: string,
121
+ upgradeWebSocket: UpgradeWebSocket,
122
+ ) {
116
123
  return upgradeWebSocket((c: Context) => {
117
124
  const params = c.req.param() as Record<string, string>;
118
125
  const upgradeReq = c.req.raw;
@@ -218,6 +218,7 @@ export async function buildMinimalApp(
218
218
  config: ServiceInstallConfig,
219
219
  serviceSlug: string,
220
220
  databaseUrl: string,
221
+ upgradeWebSocket: import("./register-ws-routes.ts").UpgradeWebSocket,
221
222
  ): Promise<MinimalAppResult> {
222
223
  const log = getAppLogger();
223
224
  const blockPackages = ((config as { block_packages?: string[] })
@@ -359,7 +360,7 @@ export async function buildMinimalApp(
359
360
  );
360
361
 
361
362
  const wsRouteCount = await runStep("register-ws-routes", () =>
362
- registerWsRoutes(app, config, mountRegistry, serviceSlug),
363
+ registerWsRoutes(app, config, mountRegistry, serviceSlug, upgradeWebSocket),
363
364
  );
364
365
 
365
366
  // Resolve the deferred TTL load (started at the top — should be long done,
package/src/ui-service.ts CHANGED
@@ -15,8 +15,8 @@ import { Hono } from "hono";
15
15
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
16
16
  import { setReady } from "./readiness-gate.ts";
17
17
  import { resolveServicePort } from "./resolve-service-port.ts";
18
- import { serveStart } from "./serve/serve";
19
18
  import { buildMinimalApp, buildFullApp, swapToFullApp } from "./two-phase-boot.ts";
19
+ import type { UpgradeWebSocket } from "./register-ws-routes.ts";
20
20
 
21
21
  export interface ResolvedBlock {
22
22
  slug: string;
@@ -40,6 +40,7 @@ export interface UiServiceBootResult {
40
40
  */
41
41
  export interface PageServerDeps {
42
42
  createPageHandler: import("./types.ts").CreatePageHandler;
43
+ upgradeWebSocket?: UpgradeWebSocket;
43
44
  }
44
45
 
45
46
  /**
@@ -65,7 +66,12 @@ export async function bootUiService(
65
66
  const startTime = Date.now();
66
67
 
67
68
  // Phase 1: Build minimal tool-only app
68
- const minimalAppResult = await buildMinimalApp(config, serviceSlug, databaseUrl);
69
+ const minimalAppResult = await buildMinimalApp(
70
+ config,
71
+ serviceSlug,
72
+ databaseUrl,
73
+ deps.upgradeWebSocket!,
74
+ );
69
75
 
70
76
  // Resolve the listen port through the SHARED resolver (`resolveServicePort`),
71
77
  // the same one the backend branch uses. The registry wins wherever it has an
@@ -109,25 +115,12 @@ export async function bootUiService(
109
115
  }
110
116
  const port = requireValidPort(resolution.port, serviceSlug);
111
117
 
112
- // Start serving with the minimal app (useMutableRef=true)
113
- try {
114
- serveStart(minimalAppResult.app, {
115
- port,
116
- serviceSlug,
117
- }, true); // useMutableRef=true
118
- } catch (err) {
119
- throw new BootFatalError(
120
- "ui-service.server-bind",
121
- serviceSlug,
122
- `Bun.serve failed to bind on port ${port}. Error: ${err instanceof Error ? err.message : err}`,
123
- err,
124
- );
125
- }
126
-
118
+ // service-runtime owns the TCP listener. service-boot only composes the app
119
+ // and starts the two-phase build; the runtime binds the returned app.
127
120
  const minimalReadyTime = Date.now();
128
121
  const minimalDuration = minimalReadyTime - startTime;
129
122
  log.info(
130
- `[ui-service-boot:${serviceSlug}] Minimal app serving on port ${port} in ${minimalDuration}ms`,
123
+ `[ui-service-boot:${serviceSlug}] Minimal app composed on port ${port} in ${minimalDuration}ms`,
131
124
  );
132
125
 
133
126
  // FLIP READY SWITCH: minimal app is ready, MCP tools can connect
@@ -1,64 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * `fn.aiHealth` — the AI-availability probe the shared chat hook
7
- * (`useAIHealthCheck`) polls before showing the chat's health dot. It resolves
8
- * the same DB-driven default model the chat engine uses (`ai-default-model`
9
- * config → an active `llm_model` row) and reports `{status, mcp, gateway}`;
10
- * `status:"ok"` means the chat has a usable model configured.
11
- *
12
- * Unauthed (a public GPT surface polls it too) and a LIGHTWEIGHT registry read
13
- * via the request `prisma` — deliberately NO `@teamscala/chat-completion` import,
14
- * so wiring this universal procedure never pulls the AI stack into every
15
- * service. Registered into the `fn` namespace by registerFnRouters, so any
16
- * chat-capable service answers it instead of 404-ing.
17
- */
18
-
19
- import { os } from "@orpc/server";
20
-
21
- interface Context {
22
- prisma: {
23
- registry_entries: {
24
- findFirst: (args: unknown) => Promise<{ config?: unknown; id?: string } | null>;
25
- };
26
- };
27
- }
28
-
29
- export interface AiHealthResponse {
30
- status: "ok" | "error";
31
- mcp: boolean;
32
- gateway: boolean;
33
- error?: string;
34
- }
35
-
36
- export const aiHealthHandler = os
37
- .$context<Context>()
38
- .handler(async ({ context }): Promise<AiHealthResponse> => {
39
- try {
40
- const cfg = await context.prisma.registry_entries.findFirst({
41
- where: { type: "config", slug: "ai-default-model", is_active: true },
42
- select: { config: true },
43
- });
44
- const modelKey = (cfg?.config as { model?: string } | null)?.model;
45
- if (!modelKey) {
46
- return { status: "error", mcp: false, gateway: false, error: "No default AI model configured" };
47
- }
48
- const model = await context.prisma.registry_entries.findFirst({
49
- where: { type: "llm_model", slug: modelKey, is_active: true },
50
- select: { id: true },
51
- });
52
- if (!model) {
53
- return {
54
- status: "error",
55
- mcp: false,
56
- gateway: false,
57
- error: `Default AI model '${modelKey}' has no active llm_model row`,
58
- };
59
- }
60
- return { status: "ok", mcp: true, gateway: true };
61
- } catch (err) {
62
- return { status: "error", mcp: false, gateway: false, error: (err as Error).message };
63
- }
64
- });