@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.
- package/package.json +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- 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
|
+
}
|