@teamscala/service-boot 0.23.445 → 0.23.448
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 +18 -9
- package/src/fn-routers/auth-router.ts +8 -73
- package/src/fn-routers/index.ts +5 -4
- package/src/read-html-template.ts +7 -2
- package/src/register-registry-page-route/public-shell-page-data.test.ts +104 -0
- package/src/register-registry-page-route/public-shell-page-data.ts +18 -1
- package/src/register-ws-routes.ts +15 -21
- package/src/theme-fonts-link.test.ts +98 -0
- package/src/theme-fonts-link.ts +64 -0
- package/src/two-phase-minimal-app.ts +1 -1
- package/src/ui-service.ts +1 -1
- package/src/fn-routers/cache-admin-router.ts +0 -50
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.448",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"@teamscala/file-lock": "*",
|
|
33
33
|
"@teamscala/formatters": "*",
|
|
34
34
|
"@teamscala/generic-registry": "*",
|
|
35
|
+
"@teamscala/hono-boundary": "*",
|
|
35
36
|
"@teamscala/host-command-bus": "*",
|
|
36
37
|
"@teamscala/http": "*",
|
|
37
38
|
"@teamscala/logger": "*",
|
|
@@ -458,14 +459,6 @@
|
|
|
458
459
|
"types": "./src/fn-routers/auth-router.d.ts",
|
|
459
460
|
"default": "./src/fn-routers/auth-router.ts"
|
|
460
461
|
},
|
|
461
|
-
"./fn-routers/cache-admin-router": {
|
|
462
|
-
"types": "./src/fn-routers/cache-admin-router.d.ts",
|
|
463
|
-
"default": "./src/fn-routers/cache-admin-router.ts"
|
|
464
|
-
},
|
|
465
|
-
"./fn-routers/cache-admin-router.ts": {
|
|
466
|
-
"types": "./src/fn-routers/cache-admin-router.d.ts",
|
|
467
|
-
"default": "./src/fn-routers/cache-admin-router.ts"
|
|
468
|
-
},
|
|
469
462
|
"./fn-routers/chat-session-router": {
|
|
470
463
|
"types": "./src/fn-routers/chat-session-router.d.ts",
|
|
471
464
|
"default": "./src/fn-routers/chat-session-router.ts"
|
|
@@ -526,6 +519,14 @@
|
|
|
526
519
|
"types": "./src/fn-routers/messaging-whatsapp-router.d.ts",
|
|
527
520
|
"default": "./src/fn-routers/messaging-whatsapp-router.ts"
|
|
528
521
|
},
|
|
522
|
+
"./fn-routers/panel-data-router": {
|
|
523
|
+
"types": "./src/fn-routers/panel-data-router.d.ts",
|
|
524
|
+
"default": "./src/fn-routers/panel-data-router.ts"
|
|
525
|
+
},
|
|
526
|
+
"./fn-routers/panel-data-router.ts": {
|
|
527
|
+
"types": "./src/fn-routers/panel-data-router.d.ts",
|
|
528
|
+
"default": "./src/fn-routers/panel-data-router.ts"
|
|
529
|
+
},
|
|
529
530
|
"./fn-routers/persist-chat-messages": {
|
|
530
531
|
"types": "./src/fn-routers/persist-chat-messages.d.ts",
|
|
531
532
|
"default": "./src/fn-routers/persist-chat-messages.ts"
|
|
@@ -790,6 +791,14 @@
|
|
|
790
791
|
"types": "./src/install-logging.d.ts",
|
|
791
792
|
"default": "./src/install-logging.ts"
|
|
792
793
|
},
|
|
794
|
+
"./is-react-element-type": {
|
|
795
|
+
"types": "./src/is-react-element-type.d.ts",
|
|
796
|
+
"default": "./src/is-react-element-type.ts"
|
|
797
|
+
},
|
|
798
|
+
"./is-react-element-type.ts": {
|
|
799
|
+
"types": "./src/is-react-element-type.d.ts",
|
|
800
|
+
"default": "./src/is-react-element-type.ts"
|
|
801
|
+
},
|
|
793
802
|
"./load-service-install-row": {
|
|
794
803
|
"types": "./src/load-service-install-row.d.ts",
|
|
795
804
|
"default": "./src/load-service-install-row.ts"
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
* ORPC
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* ORPC `authed` middleware shared by the fn-routers NOT YET row-composed:
|
|
7
|
+
* session resolve → UNAUTHORIZED → setRlsContext → orgId injection. The same
|
|
8
|
+
* logic the generated fn.ts renders per service (orpc-bundle emits fn.ts
|
|
9
|
+
* from api_route rows; consult that file when adding a procedure). This
|
|
10
|
+
* handwritten copy will vanish with the last remaining family.
|
|
11
|
+
*
|
|
12
|
+
* isOrgAdmin + resolveAdminOverride moved to @teamscala/db/org-admin and are
|
|
13
|
+
* ROW-composed as fn.auth.* procedures now.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import { os } from "@orpc/server";
|
|
12
|
-
import { createCache } from "@teamscala/cache/create-cache";
|
|
13
17
|
import type { PrismaClient } from "@teamscala/db/client";
|
|
14
|
-
import { queryRows } from "@teamscala/db/query-rows";
|
|
15
18
|
import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
|
|
16
19
|
|
|
17
20
|
interface User {
|
|
@@ -58,73 +61,5 @@ const authed = os.$context<Context>().use(async (opt) => {
|
|
|
58
61
|
}
|
|
59
62
|
});
|
|
60
63
|
|
|
61
|
-
const adminCache = createCache<boolean>("service-boot:org-admin", {
|
|
62
|
-
ttlMs: 5 * 60 * 1000,
|
|
63
|
-
maxSize: 2000,
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
async function isOrgAdminCached(
|
|
67
|
-
userId: string,
|
|
68
|
-
organisationId: string,
|
|
69
|
-
): Promise<boolean> {
|
|
70
|
-
const cacheKey = `${userId}:${organisationId}`;
|
|
71
|
-
const cached = adminCache.get(cacheKey);
|
|
72
|
-
if (cached !== undefined) return cached;
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
// Raw SQL, not the model API: user_organisation_memberships lives in host
|
|
76
|
-
// schemas (not the shared client), so a typed model access would only
|
|
77
|
-
// compile on the hosts that declare it. queryRows is the sanctioned read
|
|
78
|
-
// surface and is host-portable by table name.
|
|
79
|
-
const membership = await queryRows<{ id: string }>`
|
|
80
|
-
SELECT id FROM user_organisation_memberships
|
|
81
|
-
WHERE user_id = ${userId}
|
|
82
|
-
AND organisation_id = ${organisationId}
|
|
83
|
-
AND role IN ('admin', 'owner')
|
|
84
|
-
LIMIT 1`;
|
|
85
|
-
const result = membership.length > 0;
|
|
86
|
-
adminCache.set(cacheKey, result);
|
|
87
|
-
return result;
|
|
88
|
-
} catch {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export const authRouter = {
|
|
94
|
-
isOrgAdmin: authed.handler(async ({ input, context }) => {
|
|
95
|
-
const data = input as { userId: string; organisationId: string };
|
|
96
|
-
if (!data.userId || !data.organisationId) return false;
|
|
97
|
-
return isOrgAdminCached(data.userId, data.organisationId);
|
|
98
|
-
}),
|
|
99
|
-
resolveAdminOverride: authed.handler(async ({ input, context }) => {
|
|
100
|
-
const data = input as {
|
|
101
|
-
targetUserId?: string;
|
|
102
|
-
adminOverride?: boolean;
|
|
103
|
-
};
|
|
104
|
-
const targetUserId = data.targetUserId as string | undefined;
|
|
105
|
-
if (!targetUserId) return null;
|
|
106
|
-
if (targetUserId === context.user?.id) return null;
|
|
107
|
-
if (!data.adminOverride) {
|
|
108
|
-
throw new Error(
|
|
109
|
-
`This tool does not support admin override. Remove targetUserId from the request.`,
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
const userId = context.user?.id;
|
|
113
|
-
const organisationId = context.orgId;
|
|
114
|
-
if (!userId || !organisationId) {
|
|
115
|
-
throw new Error(
|
|
116
|
-
`Admin override requires both user and organisation context.`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
const admin = await isOrgAdminCached(userId, organisationId);
|
|
120
|
-
if (!admin) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`Access denied: you must be an org admin or owner to view another user's data.`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
return targetUserId;
|
|
126
|
-
}),
|
|
127
|
-
};
|
|
128
|
-
|
|
129
64
|
export type { Context, User };
|
|
130
65
|
export { authed };
|
package/src/fn-routers/index.ts
CHANGED
|
@@ -34,10 +34,8 @@ import { resolvePanelDataHandler } from "./panel-data-router.ts";
|
|
|
34
34
|
import { watchDataChange } from "@teamscala/realtime-utils/data-change-procedure";
|
|
35
35
|
import { watchPresence } from "@teamscala/realtime-utils/presence-procedure";
|
|
36
36
|
import { agreementSendRouter } from "./agreement-send-router.ts";
|
|
37
|
-
import { cacheAdminRouter } from "./cache-admin-router.ts";
|
|
38
37
|
import { chatSessionRouter } from "./chat-session-router.ts";
|
|
39
38
|
import { proposalSendRouter } from "./proposal-send-router.ts";
|
|
40
|
-
import { authRouter } from "./auth-router.ts";
|
|
41
39
|
import { publicAgreementRouter } from "./public-agreement-router.ts";
|
|
42
40
|
import { publicAssessmentRouter } from "./public-assessment-router.ts";
|
|
43
41
|
import { publicGptSessionRouter } from "./public-gpt-session-router.ts";
|
|
@@ -46,12 +44,15 @@ import { userPreferencesRouter } from "./user-preferences-router.ts";
|
|
|
46
44
|
import { workItemsRouter } from "./work-items-router.ts";
|
|
47
45
|
import { messagingWhatsappRouter } from "./messaging-whatsapp-router.ts";
|
|
48
46
|
|
|
47
|
+
// fn.cacheAdmin.* and fn.auth.* are ROW-COMPOSED now: api_route rows
|
|
48
|
+
// (handler_type "orpc_fn") are rendered into the service's generated ORPC
|
|
49
|
+
// bundle (routers/fn.ts) by orpc-bundle@1.0.0 and merged into the app router
|
|
50
|
+
// by getAppRouter. Register them by editing ROWS, never by re-adding
|
|
51
|
+
// handwritten routers here. Convert the remaining families the same way.
|
|
49
52
|
export function registerFnRouters(): void {
|
|
50
53
|
registerCustomRouter("fn", {
|
|
51
54
|
agreementSend: agreementSendRouter,
|
|
52
55
|
proposalSend: proposalSendRouter,
|
|
53
|
-
auth: authRouter,
|
|
54
|
-
cacheAdmin: cacheAdminRouter,
|
|
55
56
|
chatSession: chatSessionRouter,
|
|
56
57
|
messagingWhatsappSend: messagingWhatsappRouter.messagingWhatsappSend,
|
|
57
58
|
publicAgreement: publicAgreementRouter,
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
|
|
54
54
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
55
55
|
import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
|
|
56
|
+
import { themeFontsLink } from "./theme-fonts-link.ts";
|
|
56
57
|
import { join } from "node:path";
|
|
57
58
|
|
|
58
59
|
interface AssetManifest {
|
|
@@ -67,7 +68,7 @@ const PAGE_DOCUMENT = `<!doctype html>
|
|
|
67
68
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
68
69
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
69
70
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
70
|
-
|
|
71
|
+
<!--theme-fonts-->
|
|
71
72
|
<link rel="stylesheet" href="{{stylesPath}}" />
|
|
72
73
|
<!--head-outlet-->
|
|
73
74
|
</head>
|
|
@@ -140,7 +141,11 @@ export async function readHtmlTemplate(): Promise<string> {
|
|
|
140
141
|
}
|
|
141
142
|
let html = PAGE_DOCUMENT
|
|
142
143
|
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
143
|
-
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
|
|
144
|
+
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
|
|
145
|
+
// The head's font families derive from the css_theme row (one source);
|
|
146
|
+
// captured at cache build — boot-time semantics, a row edit reaches the
|
|
147
|
+
// envelope on the next service restart.
|
|
148
|
+
.replace("<!--theme-fonts-->", () => themeFontsLink());
|
|
144
149
|
// Tombstone stamp — surfaces a failed client build at the served layer.
|
|
145
150
|
// `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
|
|
146
151
|
// JS dependency, visible via view-source / devtools / a future status
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Regression cover for two defects the operator reported on the launcha quiz,
|
|
7
|
+
* both of which lived in publicShellPageData and were invisible to every other
|
|
8
|
+
* check because the page rendered 200 with a clean log either way.
|
|
9
|
+
*
|
|
10
|
+
* 1. screenMode was never threaded, so a page declaring "full-screen" fell back
|
|
11
|
+
* to build-page-tree's card-wrapper default and the site header rendered
|
|
12
|
+
* INSIDE the card instead of across the top of the page.
|
|
13
|
+
* 2. the page title used the LEGAL entity name (companyName) rather than the
|
|
14
|
+
* trading name (companyFriendlyName), putting "GMG APAC" on every launcha
|
|
15
|
+
* page.
|
|
16
|
+
*
|
|
17
|
+
* Both assert the OUTCOME (the emitted flag, the composed title text), not the
|
|
18
|
+
* presence of a key — a shape assertion would have passed throughout the bug.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test } from "bun:test";
|
|
22
|
+
import { publicShellPageData } from "./public-shell-page-data.ts";
|
|
23
|
+
|
|
24
|
+
/** Minimal resolved graph: one body page, one brand, one website. */
|
|
25
|
+
function resolvedWith(options: {
|
|
26
|
+
screenMode?: string;
|
|
27
|
+
companyName?: string;
|
|
28
|
+
companyFriendlyName?: string;
|
|
29
|
+
}): Record<string, unknown> {
|
|
30
|
+
const rootProps: Record<string, unknown> = {};
|
|
31
|
+
if (options.screenMode) rootProps.screenMode = options.screenMode;
|
|
32
|
+
return {
|
|
33
|
+
page: {
|
|
34
|
+
id: "page-1",
|
|
35
|
+
name: "The Launcha Authority Index",
|
|
36
|
+
puck_data: { root: { props: rootProps }, content: [] },
|
|
37
|
+
},
|
|
38
|
+
website: { slug: "launcha-website", name: "launcha Website" },
|
|
39
|
+
brand: {
|
|
40
|
+
primaryColor: "#FF4500",
|
|
41
|
+
companyName: options.companyName ?? "GMG APAC",
|
|
42
|
+
companyFriendlyName: options.companyFriendlyName,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("publicShellPageData threads screenMode", () => {
|
|
48
|
+
test("a page declaring full-screen emits fullScreen true", () => {
|
|
49
|
+
const data = publicShellPageData(
|
|
50
|
+
resolvedWith({ screenMode: "full-screen" }),
|
|
51
|
+
{},
|
|
52
|
+
undefined,
|
|
53
|
+
undefined,
|
|
54
|
+
);
|
|
55
|
+
// The renderer branches on this exact flag. Absent, build-page-tree
|
|
56
|
+
// wraps the body in a centred card and the header goes inside it.
|
|
57
|
+
expect(data.fullScreen).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a page declaring a non-full-screen mode emits fullScreen false", () => {
|
|
61
|
+
const data = publicShellPageData(
|
|
62
|
+
resolvedWith({ screenMode: "contained" }),
|
|
63
|
+
{},
|
|
64
|
+
undefined,
|
|
65
|
+
undefined,
|
|
66
|
+
);
|
|
67
|
+
expect(data.fullScreen).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a page declaring nothing omits the flag so the render-mode default still applies", () => {
|
|
71
|
+
const data = publicShellPageData(resolvedWith({}), {}, undefined, undefined);
|
|
72
|
+
// Deliberately absent rather than false: an explicit false would override
|
|
73
|
+
// the public/gpts default and regress every page that never set the prop.
|
|
74
|
+
expect("fullScreen" in data).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("publicShellPageData titles pages with the trading name", () => {
|
|
79
|
+
test("the friendly name wins over the legal entity name", () => {
|
|
80
|
+
const data = publicShellPageData(
|
|
81
|
+
resolvedWith({
|
|
82
|
+
companyName: "GMG APAC",
|
|
83
|
+
companyFriendlyName: "launcha",
|
|
84
|
+
}),
|
|
85
|
+
{},
|
|
86
|
+
undefined,
|
|
87
|
+
true,
|
|
88
|
+
);
|
|
89
|
+
const title = (data.meta as { title?: string }).title ?? "";
|
|
90
|
+
expect(title).toContain("launcha");
|
|
91
|
+
expect(title).not.toContain("GMG APAC");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the legal name is still used when no friendly name is set", () => {
|
|
95
|
+
const data = publicShellPageData(
|
|
96
|
+
resolvedWith({ companyName: "GMG APAC" }),
|
|
97
|
+
{},
|
|
98
|
+
undefined,
|
|
99
|
+
true,
|
|
100
|
+
);
|
|
101
|
+
const title = (data.meta as { title?: string }).title ?? "";
|
|
102
|
+
expect(title).toContain("GMG APAC");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -95,6 +95,14 @@ export function publicShellPageData(
|
|
|
95
95
|
const overrideHeading = pickRootProp<string>(regions, "headingFont");
|
|
96
96
|
const overrideBody = pickRootProp<string>(regions, "bodyFont");
|
|
97
97
|
|
|
98
|
+
// The page's own screenMode, threaded the same way Branch 1 threads it
|
|
99
|
+
// (single-page-data.ts). Dropping it here is not neutral: build-page-tree
|
|
100
|
+
// falls back to `fullScreen: renderMode === "public" || "gpts"`, and for a
|
|
101
|
+
// shell page that resolves false — which wraps the body in a centred card
|
|
102
|
+
// and puts the site header INSIDE that card instead of across the top of
|
|
103
|
+
// the page. A page that declares "full-screen" was rendering as a form.
|
|
104
|
+
const screenMode = pickRootProp<string>(regions, "screenMode");
|
|
105
|
+
|
|
98
106
|
const pageName = (page.name as string) || null;
|
|
99
107
|
const entityTitle = resolveEntityTitle(resolved);
|
|
100
108
|
const bodyMetaTitle = regions.find((r) => r.slot === "body")?.metaTitle;
|
|
@@ -151,13 +159,22 @@ export function publicShellPageData(
|
|
|
151
159
|
favicon: (brand.favicon as string) ?? null,
|
|
152
160
|
},
|
|
153
161
|
theme,
|
|
162
|
+
...(screenMode ? { fullScreen: screenMode === "full-screen" } : {}),
|
|
154
163
|
meta: {
|
|
155
164
|
title: composePageTitle(
|
|
156
165
|
{
|
|
157
166
|
pageMetaTitle: bodyMetaTitle,
|
|
158
167
|
templateTitle: (meta.title as string) || undefined,
|
|
159
168
|
websiteName: (website.name as string) || undefined,
|
|
160
|
-
|
|
169
|
+
// The FRIENDLY name is what the public sees — the trading brand,
|
|
170
|
+
// not the legal entity. Reading companyName here put "GMG APAC"
|
|
171
|
+
// in the title of every launcha page, which the operator has
|
|
172
|
+
// corrected repeatedly; the friendly name is "launcha". Falls
|
|
173
|
+
// back to companyName only where no friendly name is set.
|
|
174
|
+
organisationName:
|
|
175
|
+
(brand.companyFriendlyName as string) ||
|
|
176
|
+
(brand.companyName as string) ||
|
|
177
|
+
undefined,
|
|
161
178
|
pageName: pageName ?? pageRootTitle,
|
|
162
179
|
entityTitle: entityTitle || undefined,
|
|
163
180
|
},
|
|
@@ -33,12 +33,13 @@ import type {
|
|
|
33
33
|
MountRegistry,
|
|
34
34
|
WsRouteHandler,
|
|
35
35
|
} from "@teamscala/os/runtime-contracts/mount-context";
|
|
36
|
+
import type {
|
|
37
|
+
UpgradeWebSocket,
|
|
38
|
+
WSEvents,
|
|
39
|
+
} from "@teamscala/hono-boundary/websocket-upgrade";
|
|
36
40
|
import type { Context, Hono } from "hono";
|
|
37
|
-
import type { createBunWebSocket } from "hono/bun";
|
|
38
41
|
import * as v from "valibot";
|
|
39
42
|
|
|
40
|
-
export type UpgradeWebSocket = ReturnType<typeof createBunWebSocket>["upgradeWebSocket"];
|
|
41
|
-
|
|
42
43
|
const ORPC_WS_PATH = "/api/rpc/ws";
|
|
43
44
|
|
|
44
45
|
export async function registerWsRoutes(
|
|
@@ -63,12 +64,12 @@ export async function registerWsRoutes(
|
|
|
63
64
|
// republish of @teamscala/orpc. Missing/empty row falls back to the
|
|
64
65
|
// primitive's default interval.
|
|
65
66
|
const wsCfg = await loadOrpcWsConfig(log, serviceSlug);
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
mountOrpcWs(app, router as never, upgradeWebSocket
|
|
67
|
+
// Router cast: getAppRouter's AppRouter is not statically the
|
|
68
|
+
// `Router<any, TContext>` mountOrpcWs is generic over at this
|
|
69
|
+
// configured-primitive boundary; the runtime shape is correct. The
|
|
70
|
+
// upgrade function needs no cast — both sides read UpgradeWebSocket
|
|
71
|
+
// from the generated boundary package.
|
|
72
|
+
mountOrpcWs(app, router as never, upgradeWebSocket, {
|
|
72
73
|
path: ORPC_WS_PATH,
|
|
73
74
|
pingIntervalMs: wsCfg.pingIntervalMs,
|
|
74
75
|
});
|
|
@@ -120,12 +121,12 @@ function adaptLegacyWsHandler(
|
|
|
120
121
|
pattern: string,
|
|
121
122
|
upgradeWebSocket: UpgradeWebSocket,
|
|
122
123
|
) {
|
|
123
|
-
return upgradeWebSocket((c: Context) => {
|
|
124
|
+
return upgradeWebSocket((c: Context): WSEvents => {
|
|
124
125
|
const params = c.req.param() as Record<string, string>;
|
|
125
126
|
const upgradeReq = c.req.raw;
|
|
126
127
|
let extraData: Record<string, unknown> = {};
|
|
127
128
|
return {
|
|
128
|
-
async onOpen(_evt
|
|
129
|
+
async onOpen(_evt, wsc) {
|
|
129
130
|
const ws = wsc.raw as never as { data: Record<string, unknown> };
|
|
130
131
|
if (legacy.upgradeData) {
|
|
131
132
|
extraData = await legacy.upgradeData(upgradeReq, params);
|
|
@@ -133,10 +134,7 @@ function adaptLegacyWsHandler(
|
|
|
133
134
|
ws.data = { ...ws.data, route: pattern, params, ...extraData };
|
|
134
135
|
await legacy.open?.(ws as never);
|
|
135
136
|
},
|
|
136
|
-
async onMessage(
|
|
137
|
-
evt: MessageEvent<string | ArrayBuffer>,
|
|
138
|
-
wsc: { raw?: unknown },
|
|
139
|
-
) {
|
|
137
|
+
async onMessage(evt, wsc) {
|
|
140
138
|
const ws = wsc.raw as never;
|
|
141
139
|
const msg =
|
|
142
140
|
typeof evt.data === "string"
|
|
@@ -144,15 +142,11 @@ function adaptLegacyWsHandler(
|
|
|
144
142
|
: Buffer.from(evt.data as ArrayBuffer);
|
|
145
143
|
await legacy.message?.(ws, msg);
|
|
146
144
|
},
|
|
147
|
-
async onClose(evt
|
|
145
|
+
async onClose(evt, wsc) {
|
|
148
146
|
const ws = wsc.raw as never;
|
|
149
147
|
await legacy.close?.(ws, evt.code, evt.reason);
|
|
150
148
|
},
|
|
151
|
-
}
|
|
152
|
-
c: infer _Context,
|
|
153
|
-
) => infer R
|
|
154
|
-
? R
|
|
155
|
-
: never;
|
|
149
|
+
};
|
|
156
150
|
});
|
|
157
151
|
}
|
|
158
152
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The served <head> carries every font family the css_theme row declares —
|
|
7
|
+
* derived from the row (one source), never a hard-coded family list. The old
|
|
8
|
+
* envelope loaded only Inter, so the proposal heading variant's manuscript
|
|
9
|
+
* serif never loaded and fell back to Times (2026-09-02).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
type ConfigRow = { fonts?: Record<string, string> } | undefined;
|
|
15
|
+
let injectedConfig: ConfigRow;
|
|
16
|
+
|
|
17
|
+
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
18
|
+
getEntryConfig: (_type: string, _slug: string) => injectedConfig,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const { readHtmlTemplate } = await import("./read-html-template.ts");
|
|
22
|
+
const { primaryFontFamily, themeFontFamilies, themeFontsLink } = await import(
|
|
23
|
+
"./theme-fonts-link.ts"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const { mkdir, mkdtemp, rm } = await import("node:fs/promises");
|
|
27
|
+
const { tmpdir } = await import("node:os");
|
|
28
|
+
const { join } = await import("node:path");
|
|
29
|
+
const { beforeEach, afterEach } = await import("bun:test");
|
|
30
|
+
|
|
31
|
+
let dir: string;
|
|
32
|
+
let origCwd: string;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
origCwd = process.cwd();
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), "theme-fonts-link-"));
|
|
37
|
+
await mkdir(join(dir, "dist", "client"), { recursive: true });
|
|
38
|
+
process.chdir(dir);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
process.chdir(origCwd);
|
|
43
|
+
await rm(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function writeManifest(): Promise<void> {
|
|
47
|
+
await Bun.file(
|
|
48
|
+
join(dir, "dist", "client", "asset-manifest.json"),
|
|
49
|
+
).write(
|
|
50
|
+
`${JSON.stringify({ stylesPath: "/assets/styles.css", hydratePath: "/assets/hydrate.js" }, null, 2)}\n`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function servedHead(): Promise<string> {
|
|
55
|
+
await writeManifest();
|
|
56
|
+
const html = await readHtmlTemplate();
|
|
57
|
+
return html.slice(0, html.indexOf("</head>"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("theme-fonts-link", () => {
|
|
61
|
+
test("primaryFontFamily takes the first family of a stack", () => {
|
|
62
|
+
expect(primaryFontFamily("Inter, sans-serif")).toBe("Inter");
|
|
63
|
+
expect(primaryFontFamily("'Playfair Display'")).toBe("Playfair Display");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("families derive from the css_theme row, deduplicated", () => {
|
|
67
|
+
injectedConfig = {
|
|
68
|
+
fonts: {
|
|
69
|
+
body: "Inter, sans-serif",
|
|
70
|
+
heading: "Inter, sans-serif",
|
|
71
|
+
manuscript: "Playfair Display",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
expect(themeFontFamilies()).toEqual(["Inter", "Playfair Display"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the served head carries every family the row declares", async () => {
|
|
78
|
+
injectedConfig = {
|
|
79
|
+
fonts: {
|
|
80
|
+
body: "Inter, sans-serif",
|
|
81
|
+
heading: "Inter, sans-serif",
|
|
82
|
+
manuscript: "Playfair Display",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const head = await servedHead();
|
|
86
|
+
expect(head).toContain("family=Inter");
|
|
87
|
+
expect(head).toContain(encodeURIComponent("Playfair Display"));
|
|
88
|
+
expect(head).not.toContain("<!--theme-fonts-->");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("an absent row degrades to no fonts link — never throws", async () => {
|
|
92
|
+
injectedConfig = undefined;
|
|
93
|
+
expect(themeFontsLink()).toBe("");
|
|
94
|
+
const head = await servedHead();
|
|
95
|
+
expect(head).not.toContain("fonts.googleapis.com/css2");
|
|
96
|
+
expect(head).toContain("<!--head-outlet-->");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Derives the document head's Google Fonts link from the `css_theme` row's
|
|
7
|
+
* `fonts` map — the ONE source for the families a page's variants reference
|
|
8
|
+
* (`the-theme-decides-colour-not-the-component` extends to type). The old
|
|
9
|
+
* envelope hard-coded Inter, so a proposal heading naming a manuscript serif
|
|
10
|
+
* ("Playfair Display") never had its family loaded and fell back to Times
|
|
11
|
+
* (2026-09-02). Now every family the row declares is loaded; a family a
|
|
12
|
+
* variant names that the row does not declare is a row defect, caught at the
|
|
13
|
+
* row's schema boundary.
|
|
14
|
+
*
|
|
15
|
+
* Read from the boot registry cache (captured at service boot, the same
|
|
16
|
+
* semantics as the resource_loader capture) and guarded: a missing or
|
|
17
|
+
* unreadable row degrades to NO fonts link — system fonts render, the page
|
|
18
|
+
* still works. This module must never throw into the document builder.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getEntryConfig } from "@teamscala/db/registry/accessors";
|
|
22
|
+
|
|
23
|
+
interface ThemeFontsConfig {
|
|
24
|
+
fonts?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The primary family name of a CSS font stack: "Inter, sans-serif" →
|
|
28
|
+
* "Inter"; "Playfair Display" → "Playfair Display". */
|
|
29
|
+
export function primaryFontFamily(stack: string): string {
|
|
30
|
+
const first = stack.split(",")[0] ?? "";
|
|
31
|
+
return first.trim().replace(/^['"]|['"]$/g, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The declared theme families, deduplicated by primary family, in row
|
|
35
|
+
* order (body, heading, manuscript, mono…). Empty when the row is absent. */
|
|
36
|
+
export function themeFontFamilies(): string[] {
|
|
37
|
+
try {
|
|
38
|
+
const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
|
|
39
|
+
const fonts = config?.fonts ?? {};
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
const families: string[] = [];
|
|
42
|
+
for (const value of Object.values(fonts)) {
|
|
43
|
+
if (typeof value !== "string" || value.trim() === "") continue;
|
|
44
|
+
const primary = primaryFontFamily(value);
|
|
45
|
+
if (primary === "" || seen.has(primary)) continue;
|
|
46
|
+
seen.add(primary);
|
|
47
|
+
families.push(primary);
|
|
48
|
+
}
|
|
49
|
+
return families;
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The <link> tag loading every declared theme family, or "" when none. */
|
|
56
|
+
export function themeFontsLink(): string {
|
|
57
|
+
const families = themeFontFamilies();
|
|
58
|
+
if (families.length === 0) return "";
|
|
59
|
+
const WEIGHTS = "400;500;600;700";
|
|
60
|
+
const params = families
|
|
61
|
+
.map((family) => `family=${encodeURIComponent(family)}:wght@${WEIGHTS}`)
|
|
62
|
+
.join("&");
|
|
63
|
+
return `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${params}&display=optional" />`;
|
|
64
|
+
}
|
|
@@ -100,7 +100,7 @@ export async function buildMinimalApp(
|
|
|
100
100
|
config: ServiceInstallConfig,
|
|
101
101
|
serviceSlug: string,
|
|
102
102
|
databaseUrl: string,
|
|
103
|
-
upgradeWebSocket: import("
|
|
103
|
+
upgradeWebSocket: import("@teamscala/hono-boundary/websocket-upgrade").UpgradeWebSocket,
|
|
104
104
|
getSharedDb?: () => unknown,
|
|
105
105
|
): Promise<MinimalAppResult> {
|
|
106
106
|
const log = getAppLogger();
|
package/src/ui-service.ts
CHANGED
|
@@ -58,7 +58,7 @@ import { setReady } from "./readiness-gate.ts";
|
|
|
58
58
|
import { resolveServicePort } from "./resolve-service-port.ts";
|
|
59
59
|
import { buildMinimalApp } from "./two-phase-minimal-app.ts";
|
|
60
60
|
import { buildFullApp, swapToFullApp } from "./two-phase-full-app.ts";
|
|
61
|
-
import type { UpgradeWebSocket } from "
|
|
61
|
+
import type { UpgradeWebSocket } from "@teamscala/hono-boundary/websocket-upgrade";
|
|
62
62
|
|
|
63
63
|
export interface ResolvedBlock {
|
|
64
64
|
slug: string;
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* `fn.cacheAdmin` — ORPC procedures for in-process cache enable/disable/flush.
|
|
7
|
-
* Replaces the former `/api/data/cache-monitor/*` HANDLER_MAP entries
|
|
8
|
-
* (orpc-is-the-only-api: every API surface is a typed ORPC procedure, never a
|
|
9
|
-
* hand-written Hono route). Calls through @teamscala/cache/registry — the
|
|
10
|
-
* platform-singleton cache surface (cache-is-the-only-cache).
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { os } from "@orpc/server";
|
|
14
|
-
import * as v from "valibot";
|
|
15
|
-
import { authed } from "./auth-router.ts";
|
|
16
|
-
|
|
17
|
-
const nameSchema = v.object({
|
|
18
|
-
name: v.pipe(v.string(), v.minLength(1)),
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
export const cacheAdminRouter = {
|
|
22
|
-
enable: authed
|
|
23
|
-
.input(nameSchema)
|
|
24
|
-
.handler(async ({ input }) => {
|
|
25
|
-
const { cacheRegistry } = await import("@teamscala/cache/registry");
|
|
26
|
-
cacheRegistry.enable(input.name);
|
|
27
|
-
return { ok: true, name: input.name };
|
|
28
|
-
}),
|
|
29
|
-
|
|
30
|
-
disable: authed
|
|
31
|
-
.input(nameSchema)
|
|
32
|
-
.handler(async ({ input }) => {
|
|
33
|
-
const { cacheRegistry } = await import("@teamscala/cache/registry");
|
|
34
|
-
cacheRegistry.disable(input.name);
|
|
35
|
-
return { ok: true, name: input.name };
|
|
36
|
-
}),
|
|
37
|
-
|
|
38
|
-
flush: authed
|
|
39
|
-
.input(v.object({ name: v.optional(v.string()) }))
|
|
40
|
-
.handler(async ({ input }) => {
|
|
41
|
-
const { cacheRegistry } = await import("@teamscala/cache/registry");
|
|
42
|
-
if (input.name) {
|
|
43
|
-
cacheRegistry.flush(input.name);
|
|
44
|
-
return { flushed: 1 };
|
|
45
|
-
}
|
|
46
|
-
const count = cacheRegistry.getAll().length;
|
|
47
|
-
cacheRegistry.flush();
|
|
48
|
-
return { flushed: count };
|
|
49
|
-
}),
|
|
50
|
-
};
|