@teamscala/service-boot 0.23.444 → 0.23.447

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.444",
9
+ "version": "0.23.447",
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 procedures for auth checks (isOrgAdmin, resolveAdminOverride).
7
- * Extracted from @teamscala/orpc/fn-router — primitives tier must not
8
- * contain application-specific auth logic.
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 };
@@ -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,
@@ -48,7 +48,7 @@ mock.module("@teamscala/logger/app-loggers", () => ({
48
48
 
49
49
  mock.module("@teamscala/api-resend", () => ({
50
50
  createResendAdapter: () => ({ bridged: true }),
51
- sendEmail: async (_client: unknown, args: CapturedEmail) => {
51
+ emailsSend: async (_client: unknown, _params: unknown, args: CapturedEmail) => {
52
52
  capturedEmail = args;
53
53
  emailResolve?.(args);
54
54
  return { id: "re_test_123" };
@@ -8,12 +8,13 @@
8
8
  * the module-global @teamscala/api-resend/send-email wrapper a regen wiped;
9
9
  * failure routes through safe-fallback (fail-open-noisy).
10
10
  */
11
- import { createResendAdapter, sendEmail } from "@teamscala/api-resend";
11
+ import { createResendAdapter, emailsSend } from "@teamscala/api-resend";
12
12
  import { createSafeFallback } from "@teamscala/safe-fallback/create-safe-fallback";
13
13
 
14
14
  /** The Resend send body (from/to/subject + html/text/reply_to) — forwarded
15
- * to the adapter operation verbatim; nothing is assembled here. */
16
- export type ResendSendBody = Parameters<typeof sendEmail>[1];
15
+ * to the adapter operation verbatim; nothing is assembled here. Index 2 is
16
+ * the body arg; index 1 is the spec-derived params (headers) object. */
17
+ export type ResendSendBody = Parameters<typeof emailsSend>[2];
17
18
 
18
19
  export interface ResendSendResult {
19
20
  success: boolean;
@@ -31,7 +32,7 @@ const sendLayer = createSafeFallback<ResendSendResult>({
31
32
  export async function sendResendEmail(token: string, body: ResendSendBody): Promise<ResendSendResult> {
32
33
  const result = await sendLayer.call(async () => {
33
34
  const client = createResendAdapter({ apiKey: token });
34
- const res = await sendEmail(client, body);
35
+ const res = await emailsSend(client, {}, body);
35
36
  return { success: true, id: res.id } satisfies ResendSendResult;
36
37
  });
37
38
  return result;
@@ -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
- organisationName: (brand.companyName as string) || undefined,
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
- // Casts: mountOrpcWs's hand-rolled UpgradeWebSocket/Router types can't
67
- // exactly mirror the injected hono upgradeWebSocket + getAppRouter's
68
- // AppRouter at this configured-primitive boundary (mirroring would need
69
- // importing hono/bun, unpairing createBunWebSocket). orpc type cleanup is
70
- // a follow-up; the runtime shapes are correct.
71
- mountOrpcWs(app, router as never, upgradeWebSocket as never, {
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: Event, wsc: { raw?: unknown }) {
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: CloseEvent, wsc: { raw?: unknown }) {
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
- } as Parameters<typeof upgradeWebSocket>[0] extends (
152
- c: infer _Context,
153
- ) => infer R
154
- ? R
155
- : never;
149
+ };
156
150
  });
157
151
  }
158
152
 
@@ -100,7 +100,7 @@ export async function buildMinimalApp(
100
100
  config: ServiceInstallConfig,
101
101
  serviceSlug: string,
102
102
  databaseUrl: string,
103
- upgradeWebSocket: import("./register-ws-routes.ts").UpgradeWebSocket,
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 "./register-ws-routes.ts";
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
- };