@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.
- package/README.md +10 -0
- package/bsb-plugin.json +23 -0
- package/bsb-tests.json +14 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.cleanup.md +84 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.d.ts +6 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.js +2094 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.js.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.d.ts +106 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.js +1029 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.d.ts +72 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.js +1942 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.js.map +1 -0
- package/lib/schemas/service-betterportal-theme-bootstrap1.json +131 -0
- package/lib/schemas/service-betterportal-theme-bootstrap1.plugin.json +144 -0
- package/package.json +57 -0
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, """);
|
|
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
|