@betterportal/theme-bootstrap1 0.0.1

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.
@@ -0,0 +1,1029 @@
1
+ import { createConfigSchema, createEventSchemas } from "@bsb/base";
2
+ import * as av from "anyvali";
3
+ import { InMemoryServiceConfigStore, buildServiceViewUrl, buildOriginPolicy, htmlResponse, inferServicePathFromViewId, registerServiceConfigRoutes, resolveServiceForTenant, resolveAppRoute, resolveThemeRequestContext, serviceBaseUrl, eventObservability, eventHeaders, jsonResponse, resolveThemeHostname, withObservedEvent } from "@betterportal/framework";
4
+ import { BPService, BetterPortalConfigSchema } from "@betterportal/plugin-bsb";
5
+ import { renderBootstrap1HostPage, renderNavItems, shellStyles, renderBrand } from "./theme/index.js";
6
+ import { toHtmlString } from "@betterportal/framework";
7
+ import { loadBootstrap1Asset } from "./assets.js";
8
+ const PluginConfigSchema = av.object({
9
+ host: av.string().minLength(1).default("0.0.0.0"),
10
+ port: av.int().min(1).default(3100),
11
+ betterportal: BetterPortalConfigSchema,
12
+ defaultMode: av.enum_(["light", "dark", "system"]).default("system"),
13
+ brandName: av.string().minLength(1).default("BetterPortal"),
14
+ defaultGreetingName: av.string().minLength(1).default("Mitchell")
15
+ }, { unknownKeys: "strip" });
16
+ const THEME_CONFIG_SCHEMAS = [
17
+ {
18
+ id: "theme.bootstrap1.app",
19
+ title: "Theme - Branding & Palette",
20
+ description: "Per-app branding, palette, and mode for the bootstrap1 theme.",
21
+ scope: "app",
22
+ jsonSchema: {
23
+ brandName: "string", lightLogoUrl: "string", darkLogoUrl: "string", mode: "string",
24
+ primary: "string", secondary: "string", success: "string",
25
+ info: "string", warning: "string", danger: "string"
26
+ },
27
+ fields: [
28
+ { key: "brandName", title: "Brand Name", description: "Name shown in the top bar.", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false },
29
+ { key: "lightLogoUrl", title: "Light Logo URL", description: "Logo used in light mode.", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, ui: { control: "url" } },
30
+ { key: "darkLogoUrl", title: "Dark Logo URL", description: "Logo used in dark mode. Falls back to light logo when empty.", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, ui: { control: "url" } },
31
+ { key: "mode", title: "Default Mode", description: "Theme mode.", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, defaultValue: "system", ui: { control: "select", options: [{ value: "light", label: "Light" }, { value: "dark", label: "Dark" }, { value: "system", label: "System" }] } },
32
+ { key: "primary", title: "Primary Color", description: "Bootstrap primary palette color (hex).", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, defaultValue: "#3b82f6", ui: { control: "color" } },
33
+ { key: "secondary", title: "Secondary Color", description: "Bootstrap secondary palette color (hex).", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, defaultValue: "#64748b", ui: { control: "color" } },
34
+ { key: "success", title: "Success Color", description: "Bootstrap success palette color (hex).", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, defaultValue: "#22c55e", ui: { control: "color" } },
35
+ { key: "info", title: "Info Color", description: "Bootstrap info palette color (hex).", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, defaultValue: "#38bdf8", ui: { control: "color" } },
36
+ { key: "warning", title: "Warning Color", description: "Bootstrap warning palette color (hex).", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, defaultValue: "#f59e0b", ui: { control: "color" } },
37
+ { key: "danger", title: "Danger Color", description: "Bootstrap danger palette color (hex).", scope: "app", visibility: "protected", ownership: "mixed", sourceOfTruth: "bp", required: false, defaultValue: "#ef4444", ui: { control: "color" } }
38
+ ]
39
+ }
40
+ ];
41
+ function parseAbsoluteHttpUrl(value) {
42
+ if (!/^https?:\/\//i.test(value))
43
+ return null;
44
+ try {
45
+ return new URL(value);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function normalizeOrigin(value) {
52
+ const parsed = parseAbsoluteHttpUrl(value);
53
+ return parsed?.origin ?? null;
54
+ }
55
+ function sameOrigin(a, b) {
56
+ if (a.toLowerCase() === b.toLowerCase())
57
+ return true;
58
+ try {
59
+ return new URL(a).host.toLowerCase() === new URL(b).host.toLowerCase();
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ function resolveConcreteMode(mode, fallback) {
66
+ if (mode === "dark" || mode === "light")
67
+ return mode;
68
+ if (fallback === "dark" || fallback === "light")
69
+ return fallback;
70
+ return "light";
71
+ }
72
+ function resolveSafeServiceTarget(service, path, themeOrigin) {
73
+ const baseUrl = serviceBaseUrl(service);
74
+ const serviceOrigin = normalizeOrigin(baseUrl);
75
+ if (!serviceOrigin) {
76
+ return {
77
+ ok: false,
78
+ error: "Invalid BetterPortal route: content service must use an absolute http(s) origin."
79
+ };
80
+ }
81
+ if (sameOrigin(serviceOrigin, themeOrigin)) {
82
+ return {
83
+ ok: false,
84
+ error: "Invalid BetterPortal route: content service resolves to the theme origin."
85
+ };
86
+ }
87
+ const resolvedPath = path.startsWith("/") ? path : `/${path}`;
88
+ return {
89
+ ok: true,
90
+ origin: serviceOrigin,
91
+ path: resolvedPath,
92
+ url: `${baseUrl}${resolvedPath}`
93
+ };
94
+ }
95
+ /**
96
+ * hx-trigger spec for a fragment wrapper. Besides the initial load, every
97
+ * fragment listens for conventional reload events on <body>:
98
+ * bp:fragment:<location>.<fragmentId> - reload one specific fragment
99
+ * bp:fragments:<pluginId> - reload all fragments of a service
100
+ * Any service response can fire these via the HX-Trigger header (e.g. auth
101
+ * after login/logout); fragments that listen reload, everyone else ignores.
102
+ */
103
+ function fragmentTriggerSpec(fragmentKey, pluginId) {
104
+ const triggers = ["load", `bp:fragment:${fragmentKey} from:body`];
105
+ if (pluginId)
106
+ triggers.push(`bp:fragments:${pluginId} from:body`);
107
+ return triggers.join(", ");
108
+ }
109
+ function resolveSafeServiceViewTarget(service, route, currentPath, themeOrigin) {
110
+ const viewUrl = buildServiceViewUrl(service, route, currentPath);
111
+ const parsed = parseAbsoluteHttpUrl(viewUrl);
112
+ if (!parsed) {
113
+ return {
114
+ ok: false,
115
+ error: "Invalid BetterPortal route: content service must use an absolute http(s) origin."
116
+ };
117
+ }
118
+ if (sameOrigin(parsed.origin, themeOrigin)) {
119
+ return {
120
+ ok: false,
121
+ error: "Invalid BetterPortal route: content service resolves to the theme origin."
122
+ };
123
+ }
124
+ return {
125
+ ok: true,
126
+ origin: parsed.origin,
127
+ path: `${parsed.pathname}${parsed.search}`,
128
+ url: viewUrl
129
+ };
130
+ }
131
+ const Config = createConfigSchema({
132
+ name: "service-betterportal-theme-bootstrap1",
133
+ description: "Bootstrap 5 and HTMX based BetterPortal theme",
134
+ tags: ["betterportal", "theme", "bootstrap", "htmx"],
135
+ documentation: ["./README.md"]
136
+ }, PluginConfigSchema);
137
+ const EventSchemas = createEventSchemas({
138
+ emitEvents: {},
139
+ onEvents: {},
140
+ emitReturnableEvents: {},
141
+ onReturnableEvents: {},
142
+ emitBroadcast: {},
143
+ onBroadcast: {}
144
+ });
145
+ export class Plugin extends BPService {
146
+ static Config = Config;
147
+ static EventSchemas = EventSchemas;
148
+ configStore = new InMemoryServiceConfigStore();
149
+ constructor(cfg) {
150
+ super({ ...cfg, eventSchemas: EventSchemas });
151
+ }
152
+ definition() {
153
+ return {
154
+ manifest: {
155
+ pluginId: "service.betterportal.theme.bootstrap1",
156
+ title: "Bootstrap1 Theme",
157
+ description: "Bootstrap 5 + htmx theme that renders BetterPortal app shells.",
158
+ category: "theme",
159
+ capabilities: ["theme"],
160
+ configSchemas: THEME_CONFIG_SCHEMAS
161
+ },
162
+ // Theme exposes manual routes (not bp-routes/) - registered in onRegistered.
163
+ registry: { routes: [] }
164
+ };
165
+ }
166
+ get betterportal() {
167
+ return this.bp;
168
+ }
169
+ headerTrustOptions() {
170
+ return {
171
+ trustedProxyHeaders: this.betterportal.trustedProxyHeaders,
172
+ cfProxy: this.betterportal.cfProxy
173
+ };
174
+ }
175
+ // Called by BPService.init after framework setup. Theme registers its
176
+ // manual routes here so they sit alongside the auto-mounted /.well-known/* set.
177
+ async onRegistered(_registry, obs) {
178
+ this.registerRoutes();
179
+ obs.log.info("Bootstrap1 theme initialized with default mode {mode}", {
180
+ mode: this.config.defaultMode
181
+ });
182
+ }
183
+ requirePortalConfig() {
184
+ const cfg = this.getPortalConfig();
185
+ if (!cfg) {
186
+ throw new Error("Bootstrap1 theme has no portal config yet - waiting for control-plane sync. Verify the theme is installed and the CP is reachable.");
187
+ }
188
+ return cfg;
189
+ }
190
+ internalConfigTicket(tenantId) {
191
+ const now = Math.floor(Date.now() / 1000);
192
+ return {
193
+ iss: "internal",
194
+ aud: ["theme"],
195
+ sub: "theme-render",
196
+ exp: now + 60,
197
+ iat: now,
198
+ jti: `theme-render-${now}`,
199
+ realm: "control-plane",
200
+ tenantId,
201
+ serviceId: "service.betterportal.theme.bootstrap1",
202
+ actions: ["config.read"]
203
+ };
204
+ }
205
+ readStoredThemeValues(tenantId, appId) {
206
+ return this.configStore.read(this.internalConfigTicket(tenantId)).app[appId] ?? {};
207
+ }
208
+ applyThemeServiceConfig(base, values) {
209
+ const next = {
210
+ ...base,
211
+ bootstrap: { ...base.bootstrap },
212
+ light: { ...base.light },
213
+ dark: { ...base.dark }
214
+ };
215
+ if (typeof values.brandName === "string")
216
+ next.brandName = values.brandName;
217
+ if (typeof values.lightLogoUrl === "string")
218
+ next.lightLogoUrl = values.lightLogoUrl;
219
+ if (typeof values.darkLogoUrl === "string")
220
+ next.darkLogoUrl = values.darkLogoUrl;
221
+ if (values.mode === "light" || values.mode === "dark" || values.mode === "system")
222
+ next.mode = values.mode;
223
+ for (const colorKey of ["primary", "secondary", "success", "info", "warning", "danger"]) {
224
+ if (typeof values[colorKey] === "string")
225
+ next.bootstrap[colorKey] = values[colorKey];
226
+ }
227
+ return next;
228
+ }
229
+ registerRoutes() {
230
+ this.app.get("/_themes/bootstrap1/assets/**", (event) => this.handleAsset(event));
231
+ this.app.get("/llms.txt", (event) => this.handleLlmsTxt(event));
232
+ this.app.get("/.well-known/bp/ai.json", (event) => this.handleAiManifest(event));
233
+ this.app.get("/.well-known/bp/manifest", (event) => this.handleManifest(event));
234
+ this.app.get("/.well-known/bp/public", (event) => this.handlePublicDiscovery(event));
235
+ this.app.get("/.well-known/bp/health", (event) => this.handleHealth(event));
236
+ registerServiceConfigRoutes({
237
+ app: this.app,
238
+ serviceId: "service.betterportal.theme.bootstrap1",
239
+ configSchemas: THEME_CONFIG_SCHEMAS,
240
+ mode: "hybrid",
241
+ customUiPath: "/.well-known/bp/config/ui",
242
+ writeSuccessHeaders: { "HX-Trigger": "bp:theme-changed" },
243
+ validateTicket: (ticketValue, event, action) => this.validateConfigTicket(ticketValue, event, action),
244
+ validateScope: (scope) => this.validateConfigScope(scope.tenantId, scope.appId),
245
+ readConfig: ({ ticket }) => this.configStore.read(ticket),
246
+ writeConfig: ({ tenantId, appId, values }, { ticket }) => this.configStore.write(tenantId, appId, values, ticket),
247
+ clearConfigKey: ({ tenantId, appId, key }, { ticket }) => this.configStore.clearKey?.(tenantId, appId, key, ticket) ?? this.configStore.read(ticket)
248
+ });
249
+ this.app.get("/.well-known/bp/config/ui", (event) => this.handleConfigUi(event));
250
+ this.app.post("/.well-known/bp/config/ui/save", (event) => this.handleConfigUiSave(event));
251
+ this.app.post("/.well-known/bp/config/ui/reset", (event) => this.handleConfigUiReset(event));
252
+ this.app.get("/.well-known/bp/theme/style", (event) => this.handleThemeStyle(event));
253
+ this.app.get("/.well-known/bp/theme/brand", (event) => this.handleThemeBrand(event));
254
+ this.app.get("/.well-known/bp/theme/nav", (event) => this.handleThemeNav(event));
255
+ this.app.get("/.well-known/bp/theme/fragments", (event) => this.handleThemeFragments(event));
256
+ this.app.get("/**", (event) => this.handleIndex(event));
257
+ }
258
+ resolveConfigManagerUrl(portalConfig, tenantId) {
259
+ const tenant = portalConfig.tenants.find((entry) => entry.id === tenantId);
260
+ const direct = tenant?.services.find((service) => service.enabled && service.serviceId === "service.betterportal.config-manager");
261
+ if (direct)
262
+ return direct.hostname;
263
+ for (const activation of portalConfig.sharedServiceActivations.filter((entry) => entry.tenantId === tenantId && entry.enabled)) {
264
+ const shared = portalConfig.sharedServiceCatalog.find((service) => service.id === activation.sharedServiceId && service.enabled && service.serviceId === "service.betterportal.config-manager");
265
+ if (shared)
266
+ return shared.baseUrl;
267
+ }
268
+ return undefined;
269
+ }
270
+ appPublicUrl(app) {
271
+ const hostname = app?.hostnames[0];
272
+ if (!hostname)
273
+ return undefined;
274
+ return /^https?:\/\//i.test(hostname) ? hostname : `https://${hostname}`;
275
+ }
276
+ resolveManagementApp(portalConfig) {
277
+ const appId = portalConfig.configManagement.managementAppId;
278
+ const app = appId ? portalConfig.apps.find((entry) => entry.id === appId) : undefined;
279
+ return { appId, tenantId: app?.tenantId, url: this.appPublicUrl(app) };
280
+ }
281
+ discoveryUrls(portalConfig, tenantId, tenantUrl) {
282
+ const configManagerUrl = this.resolveConfigManagerUrl(portalConfig, tenantId);
283
+ return {
284
+ configManagerUrl,
285
+ catalogUrl: configManagerUrl ? `${configManagerUrl}/.well-known/bp/automation/catalog?tenantUrl=${encodeURIComponent(tenantUrl)}` : undefined,
286
+ managementDiscoveryUrl: configManagerUrl ? `${configManagerUrl}/.well-known/bp/management` : undefined,
287
+ managementCurrentUrl: configManagerUrl ? `${configManagerUrl}/.well-known/bp/manage/current?tenantUrl=${encodeURIComponent(tenantUrl)}` : undefined
288
+ };
289
+ }
290
+ resolveThemeAiContext(activeEvent) {
291
+ const portalConfig = this.getPortalConfig();
292
+ if (!portalConfig)
293
+ return null;
294
+ const context = resolveThemeRequestContext(portalConfig, eventHeaders(activeEvent), activeEvent.req.headers.get("host") ?? undefined, this.headerTrustOptions());
295
+ if (!context)
296
+ return null;
297
+ const tenantUrl = activeEvent.url.origin;
298
+ return {
299
+ tenant: { id: context.tenant.id, title: context.tenant.title },
300
+ app: { id: context.app.id, title: context.app.title },
301
+ tenantUrl,
302
+ urls: this.discoveryUrls(portalConfig, context.tenant.id, tenantUrl),
303
+ management: this.resolveManagementApp(portalConfig)
304
+ };
305
+ }
306
+ aiManifest(context, traceId) {
307
+ return {
308
+ protocol: "betterportal-ai.v1",
309
+ tenant: context.tenant,
310
+ app: { ...context.app, url: context.tenantUrl },
311
+ configManagerUrl: context.urls.configManagerUrl,
312
+ automation: { catalogUrl: context.urls.catalogUrl },
313
+ management: {
314
+ appUrl: context.management.url,
315
+ appId: context.management.appId,
316
+ tenantId: context.management.tenantId,
317
+ discoveryUrl: context.urls.managementDiscoveryUrl,
318
+ currentUrl: context.urls.managementCurrentUrl,
319
+ platformAdmin: {
320
+ available: true,
321
+ usage: "operator-only",
322
+ aiPolicy: "do-not-use-for-user-tasks"
323
+ }
324
+ },
325
+ ...(traceId ? { traceId } : {})
326
+ };
327
+ }
328
+ async handleAiManifest(event) {
329
+ return withObservedEvent(event, this.observability, "theme.bootstrap1.ai_manifest", (activeEvent, span) => {
330
+ const context = this.resolveThemeAiContext(activeEvent);
331
+ if (!context)
332
+ return jsonResponse({ error: "Unable to resolve tenant/app AI context" }, 404);
333
+ return jsonResponse(this.aiManifest(context, span.traceId));
334
+ });
335
+ }
336
+ async handleLlmsTxt(event) {
337
+ return withObservedEvent(event, this.observability, "theme.bootstrap1.llms_txt", (activeEvent) => {
338
+ const context = this.resolveThemeAiContext(activeEvent);
339
+ if (!context)
340
+ return new Response("BetterPortal app context is not available yet.\n", { status: 404, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
341
+ const lines = [
342
+ `# ${context.app.title}`,
343
+ "",
344
+ "This is a BetterPortal tenant app.",
345
+ "",
346
+ `Tenant ID: ${context.tenant.id}`,
347
+ `Tenant Title: ${context.tenant.title}`,
348
+ `App ID: ${context.app.id}`,
349
+ `App URL: ${context.tenantUrl}`,
350
+ "",
351
+ "Discovery:",
352
+ "- AI manifest: /.well-known/bp/ai.json",
353
+ `- Automation catalog: ${context.urls.catalogUrl ?? "not available"}`,
354
+ `- Management discovery: ${context.urls.managementDiscoveryUrl ?? "not available"}`,
355
+ `- Management app URL: ${context.management.url ?? "not configured"}`,
356
+ "",
357
+ "Use the automation catalog for business/service actions.",
358
+ "Use management discovery and the management app URL for user-owned app, tenant, service, route, menu, fragment, and theme configuration.",
359
+ "Platform admin is operator-only. AI agents must not use platform admin for user tasks.",
360
+ "If an action schema has missing required values, ask the user for those values before calling the API.",
361
+ "Persist BetterPortal response headers from BP-SetHeader until expiry and send live headers on later BP API calls. Apply BP-RemoveHeader when returned.",
362
+ "Referer and Origin help BetterPortal resolve tenant/app context; explicit discovered URLs and BP headers are preferred for API calls.",
363
+ ""
364
+ ];
365
+ return new Response(lines.join("\n"), { headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
366
+ });
367
+ }
368
+ async handlePublicDiscovery(event) {
369
+ return withObservedEvent(event, this.observability, "theme.bootstrap1.public_discovery", (activeEvent, span) => {
370
+ const context = this.resolveThemeAiContext(activeEvent);
371
+ if (!context)
372
+ return jsonResponse({ error: "Unable to resolve tenant/app context" }, 404);
373
+ return jsonResponse({
374
+ protocol: "betterportal-automation.v1",
375
+ tenantId: context.tenant.id,
376
+ appId: context.app.id,
377
+ tenantUrl: context.tenantUrl,
378
+ configManagerUrl: context.urls.configManagerUrl,
379
+ catalogUrl: context.urls.catalogUrl,
380
+ aiManifestUrl: "/.well-known/bp/ai.json",
381
+ managementDiscoveryUrl: context.urls.managementDiscoveryUrl,
382
+ traceId: span.traceId
383
+ });
384
+ });
385
+ }
386
+ async resolveEffectiveTheme(event) {
387
+ const portalConfig = this.requirePortalConfig();
388
+ const reqCtx = resolveThemeRequestContext(portalConfig, eventHeaders(event), event.req.headers.get("host") ?? undefined, this.headerTrustOptions());
389
+ if (!reqCtx) {
390
+ this.logThemeContextResolutionFailure(event);
391
+ return null;
392
+ }
393
+ this.tagRequestContext(event, reqCtx.tenant.id, reqCtx.app.id);
394
+ const base = this.applyThemeServiceConfig(reqCtx.app.themeConfig, this.readStoredThemeValues(reqCtx.tenant.id, reqCtx.app.id));
395
+ const themeConfig = {
396
+ ...base,
397
+ bootstrap: { ...base.bootstrap }
398
+ };
399
+ const effectiveMode = resolveConcreteMode(themeConfig.mode, this.config.defaultMode);
400
+ return {
401
+ brandName: base.brandName ?? reqCtx.tenant.branding.brandName ?? this.config.brandName,
402
+ logoUrl: effectiveMode === "dark"
403
+ ? base.darkLogoUrl ?? base.lightLogoUrl
404
+ : base.lightLogoUrl ?? base.darkLogoUrl,
405
+ mode: effectiveMode,
406
+ themeConfig,
407
+ tenantId: reqCtx.tenant.id,
408
+ appId: reqCtx.app.id
409
+ };
410
+ }
411
+ async handleThemeStyle(event) {
412
+ const eff = await this.resolveEffectiveTheme(event);
413
+ if (!eff)
414
+ return new Response("", { status: 404 });
415
+ const css = shellStyles(eff.mode, eff.themeConfig);
416
+ return htmlResponse(`<style id="bp-theme-style" hx-get="/.well-known/bp/theme/style" hx-trigger="bp:theme-changed from:body" hx-swap="outerHTML">${css}</style>`, 200, "text/html; mode=fragment", { "cache-control": "no-store" });
417
+ }
418
+ async handleThemeBrand(event) {
419
+ const eff = await this.resolveEffectiveTheme(event);
420
+ if (!eff)
421
+ return new Response("", { status: 404 });
422
+ return htmlResponse(toHtmlString(renderBrand(eff.brandName, eff.logoUrl)), 200, "text/html; mode=fragment", { "cache-control": "no-store" });
423
+ }
424
+ async handleThemeNav(event) {
425
+ const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
426
+ const mobile = url.searchParams.get("mobile") === "1";
427
+ const portalConfig = this.requirePortalConfig();
428
+ const requestContext = resolveThemeRequestContext(portalConfig, eventHeaders(event), event.req.headers.get("host") ?? undefined, this.headerTrustOptions());
429
+ if (!requestContext) {
430
+ this.logThemeContextResolutionFailure(event);
431
+ return htmlResponse("", 200, "text/html; mode=fragment");
432
+ }
433
+ this.tagRequestContext(event, requestContext.tenant.id, requestContext.app.id);
434
+ const currentPath = event.req.headers.get("hx-current-url")
435
+ ? new URL(event.req.headers.get("hx-current-url")).pathname
436
+ : requestContext.app.defaultRoute;
437
+ const navItems = this.buildAppNavItems(portalConfig, requestContext, currentPath, url.origin);
438
+ const rendered = renderNavItems(navItems, mobile);
439
+ const html = Array.isArray(rendered) ? rendered.map((r) => toHtmlString(r)).join("") : toHtmlString(rendered);
440
+ return htmlResponse(html, 200, "text/html; mode=fragment", { "cache-control": "no-store" });
441
+ }
442
+ buildLocationFragments(portalConfig, requestContext, location, themeOrigin) {
443
+ const appFragments = requestContext.app.fragments;
444
+ const assignments = appFragments?.[location] ?? [];
445
+ return assignments
446
+ .filter((a) => a.enabled)
447
+ .map((a) => {
448
+ const binding = resolveServiceForTenant(portalConfig, a.serviceId, requestContext);
449
+ if (!binding)
450
+ return null;
451
+ const safeTarget = resolveSafeServiceTarget(binding.service, a.targetPath, themeOrigin);
452
+ if (!safeTarget.ok)
453
+ return null;
454
+ // Load-triggered fragments must be absolute before client JS runs.
455
+ return {
456
+ fragmentId: a.fragmentId,
457
+ serviceId: a.serviceId,
458
+ pluginId: binding.service.serviceId,
459
+ url: safeTarget.url,
460
+ fragmentKey: `${location}.${a.fragmentId}`
461
+ };
462
+ })
463
+ .filter((x) => x !== null);
464
+ }
465
+ async handleThemeFragments(event) {
466
+ const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
467
+ const location = url.searchParams.get("location") ?? "nav";
468
+ const portalConfig = this.requirePortalConfig();
469
+ const requestContext = resolveThemeRequestContext(portalConfig, eventHeaders(event), event.req.headers.get("host") ?? undefined, this.headerTrustOptions());
470
+ if (!requestContext) {
471
+ this.logThemeContextResolutionFailure(event);
472
+ return htmlResponse("", 200, "text/html; mode=fragment");
473
+ }
474
+ this.tagRequestContext(event, requestContext.tenant.id, requestContext.app.id);
475
+ const frags = this.buildLocationFragments(portalConfig, requestContext, location, url.origin);
476
+ const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
477
+ const appendFragmentKey = (targetUrl, fragmentKey) => `${targetUrl}${targetUrl.includes("?") ? "&" : "?"}_f=${fragmentKey}`;
478
+ const html = frags.map((f) => `<div data-bp-fragment="${escape(f.fragmentId)}" data-bp-fragment-location="${escape(location)}" data-bp-service="${escape(f.serviceId)}" hx-get="${escape(appendFragmentKey(f.url, f.fragmentKey))}" hx-trigger="${escape(fragmentTriggerSpec(f.fragmentKey, f.pluginId))}" hx-target="this" hx-swap="innerHTML"><span class="placeholder-glow"><span class="placeholder col-12 rounded-pill"></span></span></div>`).join("");
479
+ return htmlResponse(html, 200, "text/html; mode=fragment", { "cache-control": "no-store" });
480
+ }
481
+ logThemeContextResolutionFailure(event, error) {
482
+ const obs = eventObservability(event);
483
+ if (!obs)
484
+ return;
485
+ const normalizedError = error instanceof Error ? error : null;
486
+ obs.logger.warn("BetterPortal theme context not resolved for request host={host} origin={origin} referer={referer}: {reason}", {
487
+ host: event.req.headers.get("host") ?? "",
488
+ origin: event.req.headers.get("origin") ?? "",
489
+ referer: event.req.headers.get("referer") ?? "",
490
+ reason: normalizedError?.message ?? "no active app matched request host/origin/referer"
491
+ });
492
+ }
493
+ tagRequestContext(event, tenantId, appId) {
494
+ event.__bpTenantId = tenantId;
495
+ event.__bpAppId = appId;
496
+ }
497
+ buildAppNavItems(portalConfig, requestContext, currentPath, themeOrigin) {
498
+ const routesById = new Map(requestContext.app.routes.map((r) => [r.id, r]));
499
+ const buildLinkFromRoute = (route, displayTitle) => {
500
+ const routeBinding = resolveServiceForTenant(portalConfig, route.serviceId, requestContext);
501
+ if (!routeBinding)
502
+ return null;
503
+ const safeTarget = resolveSafeServiceViewTarget(routeBinding.service, route, route.path, themeOrigin);
504
+ // Nav links keep absolute service URLs so route metadata can build the
505
+ // service map, but unsafe theme-origin targets are withheld.
506
+ return {
507
+ id: route.id,
508
+ title: displayTitle ?? route.title ?? route.id,
509
+ href: route.path,
510
+ requestUrl: safeTarget.ok ? safeTarget.url : undefined,
511
+ serviceId: route.serviceId,
512
+ active: route.path === currentPath,
513
+ error: safeTarget.ok ? undefined : safeTarget.error
514
+ };
515
+ };
516
+ const menu = (requestContext.app.menu ?? []);
517
+ const buildLeaf = (m) => {
518
+ if (m.type !== "link" || !m.routeId)
519
+ return null;
520
+ const r = routesById.get(m.routeId);
521
+ if (!r || !r.enabled)
522
+ return null;
523
+ const link = buildLinkFromRoute(r, m.title);
524
+ if (!link)
525
+ return null;
526
+ return { kind: "route", route: link, breadcrumb: "" };
527
+ };
528
+ const buildTree = (items) => items
529
+ .filter((m) => m.enabled !== false)
530
+ .map((m) => {
531
+ if (m.type === "group") {
532
+ const leaves = (m.children ?? [])
533
+ .filter((c) => c.enabled !== false)
534
+ .map((c) => {
535
+ const leaf = buildLeaf(c);
536
+ if (!leaf)
537
+ return null;
538
+ return { kind: "route", route: leaf.route, breadcrumb: `${m.title ?? ""} / ${leaf.route.title}` };
539
+ })
540
+ .filter((x) => x !== null);
541
+ if (leaves.length === 0)
542
+ return null;
543
+ return { kind: "group", id: m.id, title: m.title ?? "Group", items: leaves, active: leaves.some((x) => x.route.active) };
544
+ }
545
+ return buildLeaf(m);
546
+ })
547
+ .filter((x) => x !== null);
548
+ return buildTree(menu);
549
+ }
550
+ renderConfigUiForm(adminApiBase, tenantId, appId, eff, storedKeys, savedFlash = false) {
551
+ const safeAttr = (v) => String(v).replace(/"/g, "&quot;");
552
+ const isStored = (k) => storedKeys.has(k);
553
+ const saveUrl = `${adminApiBase.replace(/\/+$/, "")}/apps/${encodeURIComponent(appId)}/theme-config/bootstrap1`;
554
+ const resetForm = (key) => isStored(key)
555
+ ? `<form hx-post="${saveUrl}" hx-target="#bp-theme-save-status" hx-swap="outerHTML" class="d-inline">
556
+ <input type="hidden" name="tenantId" value="${safeAttr(tenantId)}" />
557
+ <input type="hidden" name="appId" value="${safeAttr(appId)}" />
558
+ <input type="hidden" name="resetKey" value="${safeAttr(key)}" />
559
+ <button type="submit" class="btn btn-sm btn-link p-0">Reset to default</button>
560
+ </form>`
561
+ : "";
562
+ const colorRow = (key, label) => `
563
+ <div class="mb-3">
564
+ <label class="form-label d-flex justify-content-between align-items-center">
565
+ <span>${label}</span>
566
+ ${resetForm(key)}
567
+ </label>
568
+ <div class="input-group">
569
+ <input type="color" class="form-control form-control-color" style="width:3.5rem" value="${safeAttr(eff[key])}"
570
+ oninput="this.nextElementSibling.value=this.value" />
571
+ <input type="text" class="form-control font-monospace" name="${key}" value="${safeAttr(eff[key])}"
572
+ pattern="^#[0-9a-fA-F]{6}$"
573
+ oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value" />
574
+ </div>
575
+ </div>`;
576
+ return `<div id="bp-theme-designer" class="container-fluid px-0">
577
+ <form hx-post="${saveUrl}" hx-target="#bp-theme-save-status" hx-swap="outerHTML">
578
+ <input type="hidden" name="tenantId" value="${safeAttr(tenantId)}" />
579
+ <input type="hidden" name="appId" value="${safeAttr(appId)}" />
580
+
581
+ <div class="d-flex justify-content-between align-items-center mb-4">
582
+ <div>
583
+ <h2 class="mb-1">Theme Designer</h2>
584
+ <p class="text-secondary mb-0">Tenant: <code>${safeAttr(tenantId)}</code> - App: <code>${safeAttr(appId)}</code></p>
585
+ </div>
586
+ ${savedFlash
587
+ ? `<button type="submit" class="btn btn-success">Saved OK</button>`
588
+ : `<button type="submit" class="btn btn-success">Save Theme</button>`}
589
+ </div>
590
+ <div id="bp-theme-save-status" class="mb-3"></div>
591
+
592
+ <div class="row g-4">
593
+ <div class="col-12 col-lg-6">
594
+ <div class="card border-0 shadow-sm h-100">
595
+ <div class="card-header"><strong>Branding</strong></div>
596
+ <div class="card-body">
597
+ <div class="mb-3">
598
+ <label class="form-label d-flex justify-content-between align-items-center">
599
+ <span>Brand Name</span>
600
+ ${resetForm("brandName")}
601
+ </label>
602
+ <input type="text" class="form-control" name="brandName" value="${safeAttr(eff.brandName)}" />
603
+ <div class="form-text">Shown in the top bar.</div>
604
+ </div>
605
+ <div class="mb-3">
606
+ <label class="form-label d-flex justify-content-between align-items-center">
607
+ <span>Light Logo URL</span>
608
+ ${resetForm("lightLogoUrl")}
609
+ </label>
610
+ <input type="url" class="form-control" name="lightLogoUrl" value="${safeAttr(eff.lightLogoUrl)}" />
611
+ </div>
612
+ <div class="mb-3">
613
+ <label class="form-label d-flex justify-content-between align-items-center">
614
+ <span>Dark Logo URL</span>
615
+ ${resetForm("darkLogoUrl")}
616
+ </label>
617
+ <input type="url" class="form-control" name="darkLogoUrl" value="${safeAttr(eff.darkLogoUrl)}" />
618
+ <div class="form-text">Falls back to the light logo when empty.</div>
619
+ </div>
620
+ </div>
621
+ </div>
622
+ </div>
623
+
624
+ <div class="col-12 col-lg-6">
625
+ <div class="card border-0 shadow-sm h-100">
626
+ <div class="card-header"><strong>Display</strong></div>
627
+ <div class="card-body">
628
+ <div class="mb-3">
629
+ <label class="form-label d-flex justify-content-between align-items-center">
630
+ <span>Default Mode</span>
631
+ ${resetForm("mode")}
632
+ </label>
633
+ <select class="form-select" name="mode">
634
+ <option value="light"${eff.mode === "light" ? " selected" : ""}>Light</option>
635
+ <option value="dark"${eff.mode === "dark" ? " selected" : ""}>Dark</option>
636
+ <option value="system"${eff.mode === "system" ? " selected" : ""}>System (follow OS)</option>
637
+ </select>
638
+ </div>
639
+ </div>
640
+ </div>
641
+ </div>
642
+
643
+ <div class="col-12">
644
+ <div class="card border-0 shadow-sm">
645
+ <div class="card-header"><strong>Bootstrap Palette</strong></div>
646
+ <div class="card-body">
647
+ <div class="row g-3">
648
+ <div class="col-md-6 col-xl-4">${colorRow("primary", "Primary")}</div>
649
+ <div class="col-md-6 col-xl-4">${colorRow("secondary", "Secondary")}</div>
650
+ <div class="col-md-6 col-xl-4">${colorRow("success", "Success")}</div>
651
+ <div class="col-md-6 col-xl-4">${colorRow("info", "Info")}</div>
652
+ <div class="col-md-6 col-xl-4">${colorRow("warning", "Warning")}</div>
653
+ <div class="col-md-6 col-xl-4">${colorRow("danger", "Danger")}</div>
654
+ </div>
655
+ </div>
656
+ </div>
657
+ </div>
658
+ </div>
659
+ </form>
660
+ </div>`;
661
+ }
662
+ async computeEffectiveAndStored(tenantId, appId) {
663
+ const portalConfig = this.requirePortalConfig();
664
+ const appDef = portalConfig.apps.find((a) => a.id === appId);
665
+ const tenant = portalConfig.tenants.find((t) => t.id === tenantId);
666
+ if (!appDef || !tenant)
667
+ return { eff: {}, storedKeys: new Set(), valid: false };
668
+ const storedValues = this.readStoredThemeValues(tenantId, appId);
669
+ const base = this.applyThemeServiceConfig(appDef.themeConfig, storedValues);
670
+ const eff = {
671
+ brandName: base.brandName ?? tenant.branding.brandName ?? this.config.brandName,
672
+ lightLogoUrl: base.lightLogoUrl ?? "",
673
+ darkLogoUrl: base.darkLogoUrl ?? "",
674
+ mode: base.mode ?? "system",
675
+ primary: base.bootstrap.primary ?? "#3b82f6",
676
+ secondary: base.bootstrap.secondary ?? "#64748b",
677
+ success: base.bootstrap.success ?? "#22c55e",
678
+ info: base.bootstrap.info ?? "#38bdf8",
679
+ warning: base.bootstrap.warning ?? "#f59e0b",
680
+ danger: base.bootstrap.danger ?? "#ef4444"
681
+ };
682
+ return {
683
+ eff,
684
+ storedKeys: new Set([
685
+ ...Object.keys(storedValues)
686
+ ]),
687
+ valid: true
688
+ };
689
+ }
690
+ themeBaseUrl(event) {
691
+ const proto = event.req.headers.get("x-forwarded-proto") ?? "http";
692
+ const host = event.req.headers.get("host") ?? `localhost:${this.config.port}`;
693
+ return `${proto}://${host}`;
694
+ }
695
+ async handleConfigUi(event) {
696
+ const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
697
+ const tenantId = url.searchParams.get("tenantId") ?? "";
698
+ const appId = url.searchParams.get("appId") ?? "";
699
+ const adminApiBase = url.searchParams.get("adminApiBase") ?? "/.well-known/bp/admin";
700
+ if (!tenantId || !appId) {
701
+ return htmlResponse(`<div class="alert alert-danger">Missing tenantId or appId.</div>`, 200, "text/html; mode=fragment");
702
+ }
703
+ const { eff, storedKeys, valid } = await this.computeEffectiveAndStored(tenantId, appId);
704
+ if (!valid) {
705
+ return htmlResponse(`<div class="alert alert-danger">App or tenant not found.</div>`, 200, "text/html; mode=fragment");
706
+ }
707
+ return htmlResponse(this.renderConfigUiForm(adminApiBase, tenantId, appId, eff, storedKeys), 200, "text/html; mode=fragment");
708
+ }
709
+ async handleConfigUiSave(event) {
710
+ const formData = await event.req.formData();
711
+ const tenantId = String(formData.get("tenantId") ?? "");
712
+ const appId = String(formData.get("appId") ?? "");
713
+ if (!tenantId || !appId) {
714
+ return htmlResponse(`<div class="alert alert-danger">Missing tenantId or appId.</div>`, 200, "text/html; mode=fragment");
715
+ }
716
+ if (!(await this.validateConfigScope(tenantId, appId))) {
717
+ return htmlResponse(`<div class="alert alert-danger">App or tenant not found.</div>`, 200, "text/html; mode=fragment");
718
+ }
719
+ const values = {};
720
+ formData.forEach((v, k) => {
721
+ if (k === "tenantId" || k === "appId")
722
+ return;
723
+ if (typeof v === "string" && v !== "")
724
+ values[k] = v;
725
+ });
726
+ const now = Math.floor(Date.now() / 1000);
727
+ this.configStore.write(tenantId, appId, values, {
728
+ iss: "internal", aud: ["theme"], sub: "save", exp: now + 60, iat: now,
729
+ jti: `save-${now}`, realm: "control-plane",
730
+ tenantId,
731
+ serviceId: "service.betterportal.theme.bootstrap1",
732
+ actions: ["config.write"]
733
+ });
734
+ const { eff, storedKeys } = await this.computeEffectiveAndStored(tenantId, appId);
735
+ return htmlResponse(this.renderConfigUiForm(this.themeBaseUrl(event), tenantId, appId, eff, storedKeys, true), 200, "text/html; mode=fragment", { "HX-Trigger": "bp:theme-changed" });
736
+ }
737
+ async handleConfigUiReset(event) {
738
+ const formData = await event.req.formData();
739
+ const tenantId = String(formData.get("tenantId") ?? "");
740
+ const appId = String(formData.get("appId") ?? "");
741
+ const key = String(formData.get("key") ?? "");
742
+ if (!tenantId || !appId || !key) {
743
+ return htmlResponse(`<div class="alert alert-danger">Missing fields.</div>`, 200, "text/html; mode=fragment");
744
+ }
745
+ if (!(await this.validateConfigScope(tenantId, appId))) {
746
+ return htmlResponse(`<div class="alert alert-danger">App or tenant not found.</div>`, 200, "text/html; mode=fragment");
747
+ }
748
+ const now = Math.floor(Date.now() / 1000);
749
+ const ticket = {
750
+ iss: "internal", aud: ["theme"], sub: "reset", exp: now + 60, iat: now,
751
+ jti: `reset-${now}`, realm: "control-plane",
752
+ tenantId, appId,
753
+ serviceId: "service.betterportal.theme.bootstrap1",
754
+ actions: ["config.write"]
755
+ };
756
+ this.configStore.clearKey?.(tenantId, appId, key, ticket);
757
+ const { eff, storedKeys } = await this.computeEffectiveAndStored(tenantId, appId);
758
+ return htmlResponse(this.renderConfigUiForm(this.themeBaseUrl(event), tenantId, appId, eff, storedKeys, true), 200, "text/html; mode=fragment", { "HX-Trigger": "bp:theme-changed" });
759
+ }
760
+ async handleAsset(event) {
761
+ return withObservedEvent(event, this.observability, "theme.bootstrap1.asset", async (activeEvent) => {
762
+ const assetPath = activeEvent.url.pathname.replace(/^\/_themes\/bootstrap1\/assets\/?/, "");
763
+ const asset = await loadBootstrap1Asset(assetPath);
764
+ if (!asset) {
765
+ return jsonResponse({
766
+ error: "Theme asset not found"
767
+ }, 404);
768
+ }
769
+ return htmlResponse(asset.body, 200, asset.contentType, {
770
+ // The shell runtime (standalone or inside the core bundle) changes with
771
+ // theme deploys - never let browsers serve a stale copy.
772
+ "cache-control": assetPath === "bootstrap1-shell.js" || assetPath === "bootstrap1-core.js"
773
+ ? "no-store"
774
+ : "public, max-age=3600"
775
+ });
776
+ });
777
+ }
778
+ async handleIndex(event) {
779
+ return withObservedEvent(event, this.observability, "theme.bootstrap1.index", async (activeEvent, span) => {
780
+ const sourceHostname = resolveThemeHostname(eventHeaders(activeEvent), this.headerTrustOptions());
781
+ // Theme reads config from the synced cache delivered by CM. If the first sync
782
+ // hasn't completed yet (fresh service, CP unreachable), surface a friendly hint.
783
+ const portalConfig = this.getPortalConfig();
784
+ if (!portalConfig) {
785
+ return new Response("<!doctype html><html><body style=\"font-family:sans-serif;padding:2rem;max-width:600px;margin:0 auto;\">" +
786
+ "<h2>BetterPortal not yet bootstrapped</h2>" +
787
+ "<p>The theme has not received its config from the control plane yet.</p>" +
788
+ "<p>If this is a fresh install, open the config-manager bootstrap wizard and complete setup, then return here.</p>" +
789
+ "<p>Otherwise check the control-plane URL + API key in this service's logs.</p>" +
790
+ "</body></html>", { status: 503, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
791
+ }
792
+ const requestContext = resolveThemeRequestContext(portalConfig, eventHeaders(activeEvent), activeEvent.req.headers.get("host") ?? undefined, this.headerTrustOptions());
793
+ if (!requestContext) {
794
+ this.logThemeContextResolutionFailure(activeEvent);
795
+ return jsonResponse({
796
+ error: "Unable to resolve tenant/app context for theme request"
797
+ }, 404);
798
+ }
799
+ this.tagRequestContext(activeEvent, requestContext.tenant.id, requestContext.app.id);
800
+ const themeOrigin = activeEvent.url.origin;
801
+ const currentRoute = resolveAppRoute(requestContext.app, activeEvent.url.pathname);
802
+ const routeNotFound = !currentRoute;
803
+ const routesById = new Map(requestContext.app.routes.map((r) => [r.id, r]));
804
+ const enabledRoutes = requestContext.app.routes.filter((r) => r.enabled);
805
+ const buildLinkFromRoute = (route, displayTitle) => {
806
+ const routeBinding = resolveServiceForTenant(portalConfig, route.serviceId, requestContext);
807
+ if (!routeBinding)
808
+ return null;
809
+ const safeTarget = resolveSafeServiceViewTarget(routeBinding.service, route, route.path, themeOrigin);
810
+ return {
811
+ id: route.id,
812
+ title: displayTitle ?? route.title ?? route.id,
813
+ href: route.path,
814
+ requestUrl: safeTarget.ok ? safeTarget.url : undefined,
815
+ serviceId: route.serviceId,
816
+ active: route.path === (currentRoute?.path ?? requestContext.app.defaultRoute),
817
+ error: safeTarget.ok ? undefined : safeTarget.error
818
+ };
819
+ };
820
+ const menu = (requestContext.app.menu ?? []);
821
+ const buildLeafFromMenu = (m) => {
822
+ if (m.type !== "link" || !m.routeId)
823
+ return null;
824
+ const r = routesById.get(m.routeId);
825
+ if (!r || !r.enabled)
826
+ return null;
827
+ const link = buildLinkFromRoute(r, m.title);
828
+ if (!link)
829
+ return null;
830
+ return { kind: "route", route: link, breadcrumb: "" };
831
+ };
832
+ const buildNavTree = (items) => {
833
+ return items
834
+ .filter((m) => m.enabled !== false)
835
+ .map((m) => {
836
+ if (m.type === "group") {
837
+ const leaves = (m.children ?? [])
838
+ .filter((c) => c.enabled !== false)
839
+ .map((c) => {
840
+ const leaf = buildLeafFromMenu(c);
841
+ if (!leaf || !leaf.route)
842
+ return null;
843
+ return {
844
+ kind: "route",
845
+ route: leaf.route,
846
+ breadcrumb: `${m.title ?? ""} / ${leaf.route.title}`
847
+ };
848
+ })
849
+ .filter((x) => x !== null);
850
+ if (leaves.length === 0)
851
+ return null;
852
+ return {
853
+ kind: "group",
854
+ id: m.id,
855
+ title: m.title ?? "Group",
856
+ items: leaves,
857
+ active: leaves.some((x) => x.route.active),
858
+ defaultExpanded: m.defaultExpanded === true
859
+ };
860
+ }
861
+ const leaf = buildLeafFromMenu(m);
862
+ return leaf && leaf.route ? leaf : null;
863
+ })
864
+ .filter((x) => x !== null);
865
+ };
866
+ const navItems = buildNavTree(menu);
867
+ // routeLinks: every enabled app route - NOT just menu leaves - so the
868
+ // client service map (data-bp-services) covers all bound services. Menu-
869
+ // less routes like the auth service's /login and /register must still
870
+ // resolve a service origin for header ownership/scoping on the client.
871
+ const routeLinks = enabledRoutes
872
+ .map((r) => buildLinkFromRoute(r))
873
+ .filter((x) => x !== null);
874
+ const initialRouteBinding = currentRoute
875
+ ? resolveServiceForTenant(portalConfig, currentRoute.serviceId, requestContext)
876
+ : null;
877
+ const initialSafeTarget = currentRoute && initialRouteBinding
878
+ ? resolveSafeServiceViewTarget(initialRouteBinding.service, currentRoute, activeEvent.url.pathname, themeOrigin)
879
+ : null;
880
+ // Carry the query string through to the service (e.g. /login?next=...) -
881
+ // the tenant URL's search params belong to the view, not the shell.
882
+ const initialRouteUrl = initialSafeTarget?.ok
883
+ ? initialSafeTarget.url + activeEvent.url.search
884
+ : undefined;
885
+ const initialRouteError = routeNotFound
886
+ ? "No enabled route matches this path."
887
+ : initialSafeTarget && !initialSafeTarget.ok
888
+ ? initialSafeTarget.error
889
+ : undefined;
890
+ const resolvedFragments = {};
891
+ // New fragments config
892
+ const appFragments = requestContext.app.fragments;
893
+ if (appFragments && Object.keys(appFragments).length > 0) {
894
+ for (const [location, assignments] of Object.entries(appFragments)) {
895
+ resolvedFragments[location] = assignments
896
+ .filter(a => a.enabled)
897
+ .map(a => {
898
+ const binding = resolveServiceForTenant(portalConfig, a.serviceId, requestContext);
899
+ if (!binding)
900
+ return null;
901
+ const safeTarget = resolveSafeServiceTarget(binding.service, a.targetPath, themeOrigin);
902
+ if (!safeTarget.ok)
903
+ return null;
904
+ // Load-triggered fragments must be absolute before client JS runs.
905
+ return {
906
+ fragmentId: a.fragmentId,
907
+ serviceId: a.serviceId,
908
+ pluginId: binding.service.serviceId,
909
+ url: safeTarget.url,
910
+ fragmentKey: `${location}.${a.fragmentId}`
911
+ };
912
+ })
913
+ .filter((x) => x !== null);
914
+ }
915
+ }
916
+ else {
917
+ // Backward compat: map old slots to fragments
918
+ for (const slot of requestContext.app.slots) {
919
+ if (!slot.enabled)
920
+ continue;
921
+ const dotIdx = slot.slotId.indexOf(".");
922
+ if (dotIdx === -1)
923
+ continue;
924
+ const location = slot.slotId.slice(0, dotIdx);
925
+ const id = slot.slotId.slice(dotIdx + 1);
926
+ const binding = resolveServiceForTenant(portalConfig, slot.serviceId, requestContext);
927
+ if (!binding)
928
+ continue;
929
+ if (!resolvedFragments[location])
930
+ resolvedFragments[location] = [];
931
+ const viewPath = inferServicePathFromViewId(slot.viewId);
932
+ const safeTarget = resolveSafeServiceTarget(binding.service, viewPath, themeOrigin);
933
+ if (!safeTarget.ok)
934
+ continue;
935
+ resolvedFragments[location].push({
936
+ fragmentId: id,
937
+ serviceId: slot.serviceId,
938
+ pluginId: binding.service.serviceId,
939
+ url: safeTarget.url,
940
+ fragmentKey: slot.slotId
941
+ });
942
+ }
943
+ }
944
+ const originPolicy = buildOriginPolicy(requestContext);
945
+ const baseTheme = this.applyThemeServiceConfig(requestContext.app.themeConfig, this.readStoredThemeValues(requestContext.tenant.id, requestContext.app.id));
946
+ const mergedThemeConfig = {
947
+ ...baseTheme,
948
+ bootstrap: { ...baseTheme.bootstrap }
949
+ };
950
+ const effectiveMode = resolveConcreteMode(mergedThemeConfig.mode, this.config.defaultMode);
951
+ // Resolve the login URL from the app's auth config. The theme is the only
952
+ // party that knows where the auth provider lives - services only know its
953
+ // JWKS for token validation, not a URL to navigate to. The client shell
954
+ // redirects here on a 401 (see assets.ts htmx_before_swap).
955
+ let loginUrl;
956
+ const appAuth = requestContext.app.auth;
957
+ const fullScreen = currentRoute?.chrome?.fullScreen === true;
958
+ const discoveryUrls = this.discoveryUrls(portalConfig, requestContext.tenant.id, activeEvent.url.origin);
959
+ if (appAuth?.serviceId) {
960
+ const authBinding = resolveServiceForTenant(portalConfig, appAuth.serviceId, requestContext);
961
+ if (authBinding) {
962
+ const loginPath = appAuth.loginViewId
963
+ ? inferServicePathFromViewId(appAuth.loginViewId)
964
+ : "/login";
965
+ const safeLogin = resolveSafeServiceTarget(authBinding.service, loginPath, themeOrigin);
966
+ if (safeLogin.ok)
967
+ loginUrl = safeLogin.url;
968
+ }
969
+ }
970
+ return new Response(renderBootstrap1HostPage({
971
+ title: requestContext.app.title,
972
+ brandName: baseTheme.brandName ?? requestContext.tenant.branding.brandName ?? this.config.brandName,
973
+ logoUrl: effectiveMode === "dark"
974
+ ? baseTheme.darkLogoUrl ?? baseTheme.lightLogoUrl
975
+ : baseTheme.lightLogoUrl ?? baseTheme.darkLogoUrl,
976
+ themeMode: effectiveMode,
977
+ themeConfig: mergedThemeConfig,
978
+ assetBaseUrl: "/_themes/bootstrap1/assets",
979
+ currentPath: activeEvent.url.pathname,
980
+ initialRouteUrl,
981
+ initialRouteError,
982
+ initialRouteStatus: routeNotFound ? 404 : undefined,
983
+ initialServiceId: currentRoute?.serviceId,
984
+ routeLinks,
985
+ navItems: navItems,
986
+ resolvedFragments,
987
+ loginUrl,
988
+ fullScreen,
989
+ aiManifestUrl: "/.well-known/bp/ai.json",
990
+ automationCatalogUrl: discoveryUrls.catalogUrl,
991
+ managementDiscoveryUrl: discoveryUrls.managementDiscoveryUrl
992
+ }), {
993
+ status: routeNotFound ? 404 : 200,
994
+ headers: {
995
+ "content-type": "text/html; charset=utf-8",
996
+ ...(sourceHostname ? { "x-bp-source-hostname": sourceHostname } : {}),
997
+ "cache-control": "no-store",
998
+ "x-bp-allowed-origin": originPolicy.allowedOrigins[0] ?? "",
999
+ "x-bp-trace-id": span.traceId
1000
+ }
1001
+ });
1002
+ });
1003
+ }
1004
+ async handleManifest(event) {
1005
+ return withObservedEvent(event, this.observability, "theme.bootstrap1.manifest", (_activeEvent, span) => {
1006
+ return jsonResponse({
1007
+ ...this.manifest,
1008
+ traceId: span.traceId
1009
+ });
1010
+ });
1011
+ }
1012
+ async handleHealth(event) {
1013
+ return withObservedEvent(event, this.observability, "theme.bootstrap1.health", () => {
1014
+ return jsonResponse({
1015
+ ok: true,
1016
+ plugin: "service-betterportal-theme-bootstrap1",
1017
+ port: this.config.port
1018
+ });
1019
+ });
1020
+ }
1021
+ async run(obs) {
1022
+ await super.run(obs);
1023
+ }
1024
+ async dispose() {
1025
+ await super.dispose();
1026
+ }
1027
+ }
1028
+ export { Config, EventSchemas };
1029
+ //# sourceMappingURL=index.js.map