@teamscala/service-boot 0.23.349 → 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.
- package/package.json +479 -567
- package/src/agreement-gate.ts +1 -1
- package/src/auth-gate.ts +0 -11
- package/src/fn-routers/index.ts +1 -12
- package/src/fn-routers/public-agreement-router.ts +37 -49
- package/src/mount-orpc-router.test.ts +46 -0
- package/src/mount-orpc-router.ts +16 -3
- package/src/mutable-app-ref.ts +0 -20
- package/src/page-server-orpc/puck-pages-router.ts +0 -102
- package/src/register-page-server-client.ts +0 -4
- package/src/register-ws-routes.ts +10 -3
- package/src/two-phase-boot.ts +2 -1
- package/src/ui-service.ts +11 -18
- package/src/fn-routers/ai-health-router.ts +0 -64
- package/src/fn-routers/panel-data-router.ts +0 -93
- package/src/fn-routers/plans-router.ts +0 -141
- package/src/fn-routers/task-sync-assign.test.ts +0 -483
- package/src/fn-routers/task-sync-assign.ts +0 -485
- package/src/fn-routers/task-sync-bind.test.ts +0 -110
- package/src/fn-routers/task-sync-brief.test.ts +0 -120
- package/src/fn-routers/task-sync-brief.ts +0 -151
- package/src/fn-routers/task-sync-classify.test.ts +0 -109
- package/src/fn-routers/task-sync-close.test.ts +0 -44
- package/src/fn-routers/task-sync-close.ts +0 -82
- package/src/fn-routers/task-sync-created-status.test.ts +0 -58
- package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
- package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
- package/src/fn-routers/task-sync-lane.test.ts +0 -36
- package/src/fn-routers/task-sync-projection.test.ts +0 -172
- package/src/fn-routers/task-sync-projection.ts +0 -273
- package/src/fn-routers/task-sync-rename.test.ts +0 -53
- package/src/fn-routers/task-sync-rename.ts +0 -111
- package/src/fn-routers/tasks-router.ts +0 -77
- package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
- package/src/page-server-orpc/templates-router.ts +0 -116
- package/src/serve/serve-ws.test.ts +0 -135
- package/src/serve/serve.ts +0 -148
- package/src/serve/stamp-transport-identity.test.ts +0 -24
- package/src/serve/stamp-transport-identity.ts +0 -109
package/src/agreement-gate.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/fn-routers/index.ts
CHANGED
|
@@ -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).
|
|
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
|
|
260
|
-
*
|
|
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
|
|
296
|
-
*
|
|
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
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
841
|
-
|
|
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
|
+
});
|
package/src/mount-orpc-router.ts
CHANGED
|
@@ -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
|
-
|
|
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: () =>
|
|
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 =
|
|
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 },
|
package/src/mutable-app-ref.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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;
|
package/src/two-phase-boot.ts
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
113
|
-
|
|
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
|
|
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
|
-
});
|