@teamscala/service-boot 0.23.284

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Walks a resource_loader.sources[] graph and resolves each source in
7
+ * dependency order. Returns an assembled data object keyed by source key.
8
+ *
9
+ * Source types: "orpc", "prisma", "resolver_fn".
10
+ * Each source may declare depends_on for ordering.
11
+ * Params are interpolated from URL params and prior resolved data.
12
+ */
13
+
14
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
15
+ import { ResolverFnConfigSchema } from "@teamscala/db-validation/registry-schemas/resolver-fn";
16
+ import { getServerClient } from "@teamscala/service-boot/page-server-orpc/server-client";
17
+
18
+ interface SourceConfig {
19
+ key: string;
20
+ source: "orpc" | "prisma" | "resolver_fn";
21
+ model?: string;
22
+ where?: Record<string, unknown>;
23
+ select?: string[];
24
+ depends_on?: string[];
25
+ optional?: boolean;
26
+ resolver_fn?: string;
27
+ resolver_input?: Record<string, unknown>;
28
+ registry_filter?: Record<string, unknown>;
29
+ transforms?: string[];
30
+ }
31
+
32
+ interface LoaderConfig {
33
+ sources: SourceConfig[];
34
+ merge_strategy?: Record<string, unknown>;
35
+ }
36
+
37
+ function resolveParamRef(
38
+ ref: string,
39
+ params: Record<string, unknown>,
40
+ resolved: Record<string, unknown>,
41
+ ): unknown {
42
+ const parts = ref.split(".");
43
+ // A bare (no-dot) ref can name either a resolved source key (e.g. `:response`
44
+ // passes the whole resolved source object to a resolver_fn) or a URL param
45
+ // (e.g. `:id`). Prefer the resolved source key, then fall back to the param.
46
+ // Dotted refs (`:response.total_score`) always read from `resolved`.
47
+ if (parts.length === 1) {
48
+ if (resolved != null && typeof resolved === "object" && ref in resolved) {
49
+ return (resolved as Record<string, unknown>)[ref];
50
+ }
51
+ if (params != null && typeof params === "object" && ref in params) {
52
+ return (params as Record<string, unknown>)[ref];
53
+ }
54
+ return undefined;
55
+ }
56
+ let current: unknown = resolved;
57
+ for (const part of parts) {
58
+ if (current == null || typeof current !== "object") return undefined;
59
+ current = (current as Record<string, unknown>)[part];
60
+ }
61
+ return current;
62
+ }
63
+
64
+ function interpolateTemplate(
65
+ template: unknown,
66
+ params: Record<string, unknown>,
67
+ resolved: Record<string, unknown>,
68
+ ): unknown {
69
+ if (typeof template === "string") {
70
+ const exactMatch = template.match(/^:([a-zA-Z0-9_.]+)$/);
71
+ if (exactMatch) {
72
+ return resolveParamRef(exactMatch[1], params, resolved);
73
+ }
74
+ if (template.includes(":")) {
75
+ return template.replace(
76
+ /:([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)/g,
77
+ (_match, ref: string) => {
78
+ const value = resolveParamRef(ref, params, resolved);
79
+ return value != null ? String(value) : _match;
80
+ },
81
+ );
82
+ }
83
+ return template;
84
+ }
85
+ if (Array.isArray(template)) {
86
+ return template.map((v) => interpolateTemplate(v, params, resolved));
87
+ }
88
+ if (template != null && typeof template === "object") {
89
+ const out: Record<string, unknown> = {};
90
+ for (const [k, v] of Object.entries(template)) {
91
+ const result = interpolateTemplate(v, params, resolved);
92
+ if (result !== undefined) out[k] = result;
93
+ }
94
+ return out;
95
+ }
96
+ return template;
97
+ }
98
+
99
+ function toSelectMap(
100
+ fields: string[] | undefined,
101
+ ): Record<string, boolean> | undefined {
102
+ if (!fields || fields.length === 0) return undefined;
103
+ const map: Record<string, boolean> = {};
104
+ for (const f of fields) map[f] = true;
105
+ return map;
106
+ }
107
+
108
+ /**
109
+ * resolver_fn executor — registry-driven dispatch (business-logic-is-rows-plus-executors).
110
+ * Reads the resolver_fn row (module+export) + dynamic-imports + calls it. Replaces the
111
+ * hardcoded if(resolver_fn==='X') chain — adding a resolver is now a registry_edit row.
112
+ */
113
+ async function resolveByResolverFn(
114
+ slug: string,
115
+ input: Record<string, unknown>,
116
+ params: Record<string, unknown>,
117
+ ): Promise<unknown> {
118
+ if (!slug) throw new Error("[resource_loader] resolver_fn source missing resolver_fn slug");
119
+ const row = await loadRegistryConfig("resolver_fn", slug, ResolverFnConfigSchema);
120
+ if (!row) {
121
+ throw new Error(
122
+ `[resource_loader] resolver_fn "${slug}" has no resolver_fn registry row — create one (module+export) or fix the resource_loader row`,
123
+ );
124
+ }
125
+ const mod = await import(row.module);
126
+ const fn = (mod as Record<string, unknown>)[row.export] as (args: {
127
+ input: Record<string, unknown>;
128
+ client: unknown;
129
+ params: Record<string, unknown>;
130
+ }) => Promise<unknown> | unknown;
131
+ if (typeof fn !== "function") {
132
+ throw new Error(
133
+ `[resource_loader] resolver_fn "${slug}": export "${row.export}" not a function in ${row.module}`,
134
+ );
135
+ }
136
+ return fn({ input, client: await getServerClient(), params });
137
+ }
138
+
139
+ async function resolveSource(
140
+ source: SourceConfig,
141
+ params: Record<string, unknown>,
142
+ resolved: Record<string, unknown>,
143
+ ): Promise<unknown> {
144
+ const client = await getServerClient();
145
+
146
+ switch (source.source) {
147
+ case "orpc":
148
+ case "prisma": {
149
+ if (!source.model) return null;
150
+ const where = source.where
151
+ ? (interpolateTemplate(source.where, params, resolved) as Record<
152
+ string,
153
+ unknown
154
+ >)
155
+ : ({} as Record<string, unknown>);
156
+ const model = client[source.model as keyof typeof client] as
157
+ | {
158
+ findFirst?: (args: {
159
+ where: unknown;
160
+ select: unknown;
161
+ }) => Promise<unknown>;
162
+ }
163
+ | undefined;
164
+ if (!model?.findFirst) return null;
165
+ return model.findFirst({
166
+ where,
167
+ select: toSelectMap(source.select),
168
+ });
169
+ }
170
+ case "resolver_fn": {
171
+ const input = source.resolver_input
172
+ ? (interpolateTemplate(
173
+ source.resolver_input,
174
+ params,
175
+ resolved,
176
+ ) as Record<string, unknown>)
177
+ : {};
178
+
179
+ return resolveByResolverFn(source.resolver_fn ?? "", input, params);
180
+ }
181
+ default:
182
+ throw new Error(
183
+ `[resource_loader] Unknown source type "${source.source}" on source "${source.key}".`,
184
+ );
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Dependencies a source has on sibling sources, beyond its declared
190
+ * `depends_on`: any `:key.field` template ref whose head names another source
191
+ * resolves from `resolved` (see resolveParamRef), so it is a real ordering
192
+ * edge even when the row forgot to declare it. Inferring these keeps the
193
+ * parallel waves safe against under-declared rows — under the old serial
194
+ * loop, row order silently masked missing declarations.
195
+ */
196
+ function sourceDeps(source: SourceConfig, sourceKeys: Set<string>): string[] {
197
+ const deps = new Set<string>(source.depends_on ?? []);
198
+ const scan = (template: unknown): void => {
199
+ if (typeof template === "string") {
200
+ for (const m of template.matchAll(
201
+ /:([a-zA-Z0-9_]+)\.[a-zA-Z0-9_.]+/g,
202
+ )) {
203
+ const head = m[1];
204
+ if (head && sourceKeys.has(head)) deps.add(head);
205
+ }
206
+ } else if (Array.isArray(template)) {
207
+ for (const v of template) scan(v);
208
+ } else if (template != null && typeof template === "object") {
209
+ for (const v of Object.values(template)) scan(v);
210
+ }
211
+ };
212
+ scan(source.where);
213
+ scan(source.resolver_input);
214
+ scan(source.registry_filter);
215
+ deps.delete(source.key);
216
+ return [...deps];
217
+ }
218
+
219
+ export async function resolveFromLoaderConfig(
220
+ config: LoaderConfig,
221
+ params: Record<string, unknown>,
222
+ ): Promise<Record<string, unknown>> {
223
+ const sources = config.sources ?? [];
224
+ const resolved: Record<string, unknown> = {};
225
+ const resolvedKeys = new Set<string>();
226
+
227
+ // Wave-parallel resolution per `loading-shape` (unrelated roots resolve
228
+ // concurrently): each wave gathers every source whose deps are satisfied
229
+ // and resolves them via Promise.allSettled — wall-clock per wave is the
230
+ // slowest source, not the sum. The previous strictly-serial loop cost
231
+ // N × DB-round-trip per page (~2.3s for a 5-source loader from the dev
232
+ // host); waves cut it to the dependency depth (typically 2).
233
+ const sourceKeys = new Set(sources.map((s) => s.key));
234
+ const depsByKey = new Map(
235
+ sources.map((s) => [s.key, sourceDeps(s, sourceKeys)]),
236
+ );
237
+
238
+ const maxIterations = sources.length + 1;
239
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
240
+ const wave = sources.filter(
241
+ (s) =>
242
+ !resolvedKeys.has(s.key) &&
243
+ (depsByKey.get(s.key) ?? []).every((d) => resolvedKeys.has(d)),
244
+ );
245
+ if (wave.length === 0) {
246
+ if (resolvedKeys.size === sources.length) break;
247
+ const unresolved = sources
248
+ .filter((s) => !resolvedKeys.has(s.key))
249
+ .map((s) => s.key);
250
+ throw new Error(
251
+ `Circular or unresolvable dependency in resource_loader: ${unresolved.join(", ")}`,
252
+ );
253
+ }
254
+
255
+ const results = await Promise.allSettled(
256
+ wave.map((source) => resolveSource(source, params, resolved)),
257
+ );
258
+ for (let i = 0; i < wave.length; i++) {
259
+ const source = wave[i];
260
+ const result = results[i];
261
+ if (!source || !result) continue;
262
+ if (result.status === "fulfilled") {
263
+ resolved[source.key] = result.value;
264
+ } else if (source.optional) {
265
+ resolved[source.key] = null;
266
+ } else {
267
+ throw result.reason;
268
+ }
269
+ resolvedKeys.add(source.key);
270
+ }
271
+ if (resolvedKeys.size === sources.length) break;
272
+ }
273
+
274
+ return resolved;
275
+ }
276
+
277
+ export function interpolateMeta(
278
+ template: Record<string, unknown>,
279
+ data: Record<string, unknown>,
280
+ ): Record<string, unknown> {
281
+ const out: Record<string, unknown> = {};
282
+ for (const [k, v] of Object.entries(template)) {
283
+ if (v != null && typeof v === "object" && !Array.isArray(v)) {
284
+ out[k] = interpolateMeta(v as Record<string, unknown>, data);
285
+ } else {
286
+ const result = interpolateTemplate(v, {}, data);
287
+ out[k] = result ?? v;
288
+ }
289
+ }
290
+ return out;
291
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Resolves the minimum service context the bin needs to choose its launch
7
+ * path: connect DB, load registry, read service_install row, return kind +
8
+ * port. Caller is responsible for disconnecting.
9
+ */
10
+
11
+ import { createDbClient } from "@teamscala/db/create-db-client";
12
+ import type { PrismaClient } from "@teamscala/db/client";
13
+ import { loadServicePrismaClient } from "@teamscala/db/load-service-prisma-client";
14
+ import { registerPrismaClient } from "@teamscala/db/prisma-registry";
15
+ import { loadRegistryEntries } from "@teamscala/db/registry/loader/load-entries";
16
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
17
+ import { loadServiceInstallRow } from "@teamscala/db/registry/loader/load-service-install-row";
18
+
19
+ export interface ServiceContext {
20
+ kind: "ui" | "backend" | "runtime";
21
+ port: number;
22
+ config: ServiceInstallConfig;
23
+ disconnect: () => Promise<void>;
24
+ }
25
+
26
+ export async function resolveServiceContext(
27
+ serviceSlug: string,
28
+ databaseUrl: string,
29
+ ): Promise<ServiceContext> {
30
+ const PrismaClientConstructor = await loadServicePrismaClient(serviceSlug);
31
+ const dbClient = await createDbClient({
32
+ connectionString: databaseUrl,
33
+ serviceName: serviceSlug,
34
+ PrismaClientConstructor: PrismaClientConstructor as never,
35
+ });
36
+ registerPrismaClient(dbClient.prisma as never as PrismaClient);
37
+ await loadRegistryEntries(dbClient.prisma as never);
38
+
39
+ const rowResult = await loadServiceInstallRow(serviceSlug);
40
+ if (!rowResult.success) {
41
+ throw new Error(`[scala-install:${serviceSlug}] ${rowResult.message}`);
42
+ }
43
+ const cfg = rowResult.config;
44
+ const port = Number(cfg.port);
45
+ if (!Number.isFinite(port) || port <= 0) {
46
+ throw new Error(
47
+ `[scala-install:${serviceSlug}] invalid port "${cfg.port}" in service_install row`,
48
+ );
49
+ }
50
+ // Export the resolved port to process.env so loopback code reads the REAL
51
+ // port without a separate PORT injection. The central-tool-forwarder (the
52
+ // chat tool dispatch) falls back to http://localhost:${PORT} for the
53
+ // co-located /mcp; without this export every chat tool call fails with
54
+ // "cannot resolve the /mcp base URL" (the highlight-ring end-to-end blocker,
55
+ // 2026-07-02). Railway's $PORT (when present) is respected — only set when
56
+ // unset so prod's Railway-assigned port wins.
57
+ if (!process.env.PORT) process.env.PORT = String(port);
58
+
59
+ return {
60
+ kind: cfg.kind,
61
+ port,
62
+ config: cfg,
63
+ disconnect: async () => {
64
+ await dbClient.disconnect?.();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The ONE theme-mode decider for every page render, across every UI app
7
+ * (portal, proposals, sign, forms, quiz, gpt, public). Every route handler in service-boot
8
+ * resolves light/dark through this single function — never its own inline
9
+ * `(... === "dark" ? "dark" : "light")` chain. That inline form was duplicated
10
+ * across every route branch, so flipping one app's theme required editing
11
+ * every copy (the heavy lifting this removes).
12
+ *
13
+ * Resolution order (first match wins):
14
+ * 1. ?theme=light|dark on the URL — the embed override. Set when a page is
15
+ * framed inside another shell (a form in a GPT tool panel) so the framed
16
+ * page matches the host's scheme. Also marks the render `embedded` so the
17
+ * page can drop its own brand page-surface and render the host's clean
18
+ * surface.
19
+ * 2. candidates[] — page rootProps.theme, brand theme, website default, in
20
+ * whatever priority the caller passes them.
21
+ * 3. "light".
22
+ *
23
+ * Pairs with the brand/token decider (resolveBrand → computeThemeVars,
24
+ * `unified-brand-theme`): that owns the SURFACE, this owns the MODE.
25
+ */
26
+
27
+ export interface ResolvedThemeMode {
28
+ theme: "light" | "dark";
29
+ /** True iff the mode came from the ?theme= embed override. */
30
+ embedded: boolean;
31
+ }
32
+
33
+ function asMode(value: unknown): "light" | "dark" | undefined {
34
+ return value === "light" || value === "dark" ? value : undefined;
35
+ }
36
+
37
+ export function resolveThemeMode(opts: {
38
+ /** Request URL (or its href) — read for the ?theme= embed override. */
39
+ url?: URL | string | null;
40
+ /**
41
+ * Explicit embed override, when the caller already extracted ?theme= from
42
+ * the URL upstream (e.g. a pure data-shaper that has no Request). Same
43
+ * effect and priority as the URL form.
44
+ */
45
+ override?: unknown;
46
+ /** Theme candidates in priority order: rootTheme, brandTheme, websiteTheme. */
47
+ candidates?: Array<unknown>;
48
+ }): ResolvedThemeMode {
49
+ let override = asMode(opts.override);
50
+ if (!override && opts.url) {
51
+ const u = typeof opts.url === "string" ? new URL(opts.url) : opts.url;
52
+ override = asMode(u.searchParams.get("theme"));
53
+ }
54
+ if (override) return { theme: override, embedded: true };
55
+
56
+ for (const candidate of opts.candidates ?? []) {
57
+ const mode = asMode(candidate);
58
+ if (mode) return { theme: mode, embedded: false };
59
+ }
60
+ return { theme: "light", embedded: false };
61
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * fetch-org-brand.ts — org brand resolver with page-org-first fallback.
7
+ *
8
+ * Wraps @teamscala/resolver-engine's fetchOrgBrand with the page-org-first
9
+ * fallback (portal-branding-resolves-page-org-first): if the page (center) org
10
+ * carried no brand row, fall back to the website owning-org so pages with no
11
+ * tenant org render the website's brand, not a blank. Extracted from the
12
+ * hardcoded `fetchOrgBrand` arm in resolve-from-loader-config.ts.
13
+ */
14
+ import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
15
+ import type { ResolverArgs } from "./portal-route-paths.ts";
16
+
17
+ export async function fetchOrgBrandWithFallback({ input, client }: ResolverArgs): Promise<unknown> {
18
+ const orgId = input.orgId as string | undefined;
19
+ if (orgId) {
20
+ const result = await fetchOrgBrand(client as Parameters<typeof fetchOrgBrand>[0], orgId);
21
+ if (result) return result;
22
+ }
23
+ const fallbackOrgId = input.fallbackOrgId as string | undefined;
24
+ if (fallbackOrgId && fallbackOrgId !== orgId) {
25
+ return fetchOrgBrand(client as Parameters<typeof fetchOrgBrand>[0], fallbackOrgId);
26
+ }
27
+ return null;
28
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * portal-route-paths.test.ts — locks the portal-path shape contract that both
7
+ * the route resolver and the /sign-in brand extractor (bind-page-handler) share:
8
+ * the canonical portal URL is /<websiteId>/<orgId>/<path>, so the ORG is segment
9
+ * 1, never segment 0. The fixture pairs below are the regression guard for the
10
+ * two cross-tenant defects this centralises (found 2026-07-21): reading segment 0
11
+ * fed a website id into brand resolution and silently rendered org-less portal
12
+ * URLs unscoped.
13
+ */
14
+
15
+ import { describe, expect, test } from "bun:test";
16
+ import {
17
+ extractOrgIdFromCallbackPath,
18
+ resolvePortalRoutePaths,
19
+ } from "./portal-route-paths";
20
+
21
+ const WEBSITE = "a9a28515-4ad5-405c-a74d-bb83fed079d0"; // scala Portal
22
+ const ORG = "5b742740-0f37-494c-a059-d8399ae4b350"; // GMG APAC (tenant)
23
+ const RESOURCE = "019e9124-af4c-7000-bfaf-2ae97fb64772"; // a non-org resource id
24
+
25
+ describe("extractOrgIdFromCallbackPath (portal-branding-resolves-page-org-first)", () => {
26
+ test("canonical portal shape /<websiteId>/<orgId>/<path> → ORG at segment 1 (not the website id at segment 0)", () => {
27
+ // CLEAN: returns the org. The violating behaviour this locks out returned
28
+ // WEBSITE (segment 0) → brand resolved no org → fell back to super-admin.
29
+ expect(extractOrgIdFromCallbackPath(`/${WEBSITE}/${ORG}/dashboard`)).toBe(ORG);
30
+ expect(extractOrgIdFromCallbackPath(`/${WEBSITE}/${ORG}/dashboard`)).not.toBe(WEBSITE);
31
+ });
32
+
33
+ test("scala-projects single-UUID shape /<orgId>/<path> → segment 0 (unchanged)", () => {
34
+ expect(extractOrgIdFromCallbackPath(`/${ORG}/projects`)).toBe(ORG);
35
+ });
36
+
37
+ test("proposal/form/quiz resource host /<resourceId> → segment 0 (non-org, resolves no brand, falls back)", () => {
38
+ expect(extractOrgIdFromCallbackPath(`/${RESOURCE}`)).toBe(RESOURCE);
39
+ });
40
+
41
+ test("query string is ignored", () => {
42
+ expect(extractOrgIdFromCallbackPath(`/${WEBSITE}/${ORG}/dashboard?right=chat`)).toBe(ORG);
43
+ });
44
+
45
+ test("non-UUID path → undefined (no brand override, falls back as before)", () => {
46
+ expect(extractOrgIdFromCallbackPath("/sign-in")).toBeUndefined();
47
+ expect(extractOrgIdFromCallbackPath("/dashboard")).toBeUndefined();
48
+ });
49
+
50
+ test("empty / malformed → undefined", () => {
51
+ expect(extractOrgIdFromCallbackPath("")).toBeUndefined();
52
+ expect(extractOrgIdFromCallbackPath("/")).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ describe("resolvePortalRoutePaths", () => {
57
+ test("canonical two-UUID shape → strips portal+org, center_path is the builda path", () => {
58
+ const out = resolvePortalRoutePaths({
59
+ input: { path: `/${WEBSITE}/${ORG}/hub/portal-integrations` },
60
+ client: undefined,
61
+ params: {},
62
+ });
63
+ expect(out.portal_id).toBe(WEBSITE);
64
+ expect(out.org_id).toBe(ORG);
65
+ expect(out.center_path).toBe("/hub/portal-integrations");
66
+ expect(out.toolbar_path).toBe("/portal-integrations-toolbar");
67
+ });
68
+
69
+ test("org-less portal shape /<websiteId>/<path> → portal_id populated, org_id null, center_path is the real path", () => {
70
+ // CLEAN: portal_id is the website, center_path strips it. The violating
71
+ // behaviour this locks out left portal_id null and center_path as the
72
+ // literal "/<websiteId>/dashboard" (so it silently rendered unscoped).
73
+ const out = resolvePortalRoutePaths({
74
+ input: { path: `/${WEBSITE}/dashboard` },
75
+ client: undefined,
76
+ params: {},
77
+ });
78
+ expect(out.portal_id).toBe(WEBSITE);
79
+ expect(out.org_id).toBeNull();
80
+ expect(out.center_path).toBe("/dashboard");
81
+ });
82
+
83
+ test("bare path (no UUID prefix) → portal_id/org_id null, path untouched", () => {
84
+ const out = resolvePortalRoutePaths({
85
+ input: { path: "/hub/portal-integrations" },
86
+ client: undefined,
87
+ params: {},
88
+ });
89
+ expect(out.portal_id).toBeNull();
90
+ expect(out.org_id).toBeNull();
91
+ expect(out.center_path).toBe("/hub/portal-integrations");
92
+ });
93
+
94
+ test('"/" falls back to the home path', () => {
95
+ const out = resolvePortalRoutePaths({
96
+ input: { path: `/${WEBSITE}/${ORG}/`, home_path: "/dashboard" },
97
+ client: undefined,
98
+ params: {},
99
+ });
100
+ expect(out.center_path).toBe("/dashboard");
101
+ });
102
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * portal-route-paths.ts — pure path mapper for portal region routing.
7
+ *
8
+ * Extracted from resolve-from-loader-config.ts (the hardcoded
9
+ * `resolvePortalRoutePaths` arm) so it can be invoked via a resolver_fn
10
+ * registry row instead of an if-chain. Faithful extraction — no logic change.
11
+ * The request path is the center page's builda path ("/" falls back to
12
+ * home_path), and the per-route toolbar page follows the flat naming convention
13
+ * `/<last-segment>-toolbar`. Pages without a matching toolbar page simply
14
+ * resolve an empty toolbar region (the source is declared optional).
15
+ */
16
+ export interface ResolverArgs {
17
+ input: Record<string, unknown>;
18
+ client: unknown;
19
+ params: Record<string, unknown>;
20
+ }
21
+
22
+ // Canonical portal URL shape: /<websiteId>/<organisationId>/<builda path...>.
23
+ // Segment 0 is the portal WEBSITE id; segment 1 is the ORG id. Shared by the
24
+ // resolver below and by extractOrgIdFromCallbackPath (the /sign-in brand path).
25
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+
27
+ /**
28
+ * Extract the organisation id from a portal-shaped callback/path — the single
29
+ * authority for "which segment is the org" so the sign-in brand resolver and the
30
+ * route resolver cannot drift on the answer (portal-branding-resolves-page-org-first).
31
+ *
32
+ * The canonical portal shape is `/<websiteId>/<orgId>/<path>` — the org is
33
+ * segment 1, NOT segment 0. Reading segment 0 fed the WEBSITE id into brand
34
+ * resolution, which resolved no org and fell back to the super-admin org — the
35
+ * exact cross-tenant mis-branding this exists to prevent (bind-page-handler.ts,
36
+ * found 2026-07-21).
37
+ *
38
+ * Single-UUID hosts keep segment 0 unchanged: scala-projects routes `/<orgId>/…`
39
+ * and proposal/form/quiz hosts route `/<resourceId>/…` (a non-org first segment
40
+ * that resolves to no brand and falls back as before — no regression, only the
41
+ * correct-org improvement for the two-UUID portal shape). Returns undefined when
42
+ * neither leading segment is a UUID.
43
+ */
44
+ export function extractOrgIdFromCallbackPath(callbackUrl: string): string | undefined {
45
+ const segs = callbackUrl.split("?")[0].split("/").filter(Boolean);
46
+ if (UUID_RE.test(segs[1] ?? "")) return segs[1];
47
+ if (UUID_RE.test(segs[0] ?? "")) return segs[0];
48
+ return undefined;
49
+ }
50
+
51
+ export function resolvePortalRoutePaths({ input, params }: ResolverArgs): Record<string, unknown> {
52
+ const rawPath = (input.path as string | undefined) ?? ":path";
53
+ let path = rawPath === ":path" ? ((params.path as string) || "/") : rawPath;
54
+ // Canonical portal URL shape: /<websiteId>/<orgId>/<builda path...>. Segment 0
55
+ // is the portal website id; segment 1 is the org id. Two cases:
56
+ // - both leading segments UUID → canonical scoped URL, strip both.
57
+ // - segment 0 UUID, segment 1 NOT → ORG-LESS portal URL (/<websiteId>/<path>).
58
+ // portal_id is still populated (it IS the requested portal) and the website
59
+ // id is stripped so center_path is the real builda path; org_id stays null
60
+ // as the signal. The org-less shape is gated to redirect/404 upstream in
61
+ // resolve-page-state (portalHomeRedirect) so it never silently renders.
62
+ const segments = path.split("/").filter(Boolean);
63
+ let portalId: string | null = null;
64
+ let orgId: string | null = null;
65
+ if (UUID_RE.test(segments[0] ?? "")) {
66
+ portalId = segments[0] ?? null;
67
+ if (UUID_RE.test(segments[1] ?? "")) {
68
+ orgId = segments[1] ?? null;
69
+ path = `/${segments.slice(2).join("/")}`;
70
+ } else {
71
+ path = `/${segments.slice(1).join("/")}`;
72
+ }
73
+ }
74
+ const homePath = (input.home_path as string | undefined) || "/dashboard";
75
+ const centerPath = path === "/" ? homePath : path;
76
+ const lastSegment = centerPath.split("/").filter(Boolean).pop() ?? "";
77
+ return {
78
+ center_path: centerPath,
79
+ toolbar_path: `/${lastSegment}-toolbar`,
80
+ portal_id: portalId,
81
+ org_id: orgId,
82
+ };
83
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * website-by-host-map.ts — resolves the website + body path for a host.
7
+ *
8
+ * Extracted from resolve-from-loader-config.ts (the hardcoded
9
+ * `resolveWebsiteByHostMap` arm). Faithful extraction — no logic change.
10
+ */
11
+ import type { ResolverArgs } from "./portal-route-paths.ts";
12
+
13
+ type BrandWebsite = {
14
+ id: string;
15
+ name: string | null;
16
+ slug: string;
17
+ organisation_id: string | null;
18
+ layout_settings: unknown;
19
+ header_settings: unknown;
20
+ footer_settings: unknown;
21
+ };
22
+
23
+ export async function resolveWebsiteByHostMap({ input, client, params }: ResolverArgs): Promise<Record<string, unknown> | null> {
24
+ const host = input.host as string | undefined;
25
+ const hostsMap = input.hosts_map as Record<string, { website: string; body_path?: string }> | null;
26
+ const defaultWebsite = input.default_website as string | undefined;
27
+ if (!host || !hostsMap) return null;
28
+ const entry = hostsMap[host];
29
+ const websiteSlug = entry?.website ?? defaultWebsite;
30
+ if (!websiteSlug) return null;
31
+ const rawBodyPath = entry?.body_path ?? (input.default_body_path as string | undefined) ?? ":path";
32
+ const bodyPath = rawBodyPath === ":path" ? ((params.path as string) || "/") : rawBodyPath;
33
+ const website = await (client as never as {
34
+ builda_websites: { findFirst: (args: Record<string, unknown>) => Promise<BrandWebsite | null> };
35
+ }).builda_websites.findFirst({
36
+ where: { slug: websiteSlug },
37
+ select: {
38
+ id: true,
39
+ name: true,
40
+ slug: true,
41
+ organisation_id: true,
42
+ layout_settings: true,
43
+ header_settings: true,
44
+ footer_settings: true,
45
+ },
46
+ });
47
+ if (!website) return null;
48
+ return { ...website, body_path: bodyPath, host };
49
+ }