@betterportal/config-manager 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.
Files changed (154) hide show
  1. package/README.md +3 -0
  2. package/bsb-plugin.json +23 -0
  3. package/bsb-tests.json +14 -0
  4. package/lib/.bsb/clients/service-betterportal-config-manager.d.ts +37 -0
  5. package/lib/.bsb/clients/service-betterportal-config-manager.d.ts.map +1 -0
  6. package/lib/.bsb/clients/service-betterportal-config-manager.js +40 -0
  7. package/lib/.bsb/clients/service-betterportal-config-manager.js.map +1 -0
  8. package/lib/index.d.ts +2 -0
  9. package/lib/index.d.ts.map +1 -0
  10. package/lib/index.js +2 -0
  11. package/lib/index.js.map +1 -0
  12. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts +3 -0
  13. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts.map +1 -0
  14. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js +235 -0
  15. package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js.map +1 -0
  16. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts +4 -0
  17. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -0
  18. package/lib/plugins/service-betterportal-config-manager/adminApi.js +2319 -0
  19. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -0
  20. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.d.ts +21 -0
  21. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.d.ts.map +1 -0
  22. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js +269 -0
  23. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js.map +1 -0
  24. package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.d.ts +19 -0
  25. package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.d.ts.map +1 -0
  26. package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.js +329 -0
  27. package/lib/plugins/service-betterportal-config-manager/bootstrapWizardHtml.js.map +1 -0
  28. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.d.ts +4 -0
  29. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.d.ts.map +1 -0
  30. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.js +38 -0
  31. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/index.js.map +1 -0
  32. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.d.ts +96 -0
  33. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.d.ts.map +1 -0
  34. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.js +78 -0
  35. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/index.js.map +1 -0
  36. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.d.ts +4 -0
  37. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.d.ts.map +1 -0
  38. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.js +62 -0
  39. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.bootstrap1/index.js.map +1 -0
  40. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.d.ts +5 -0
  41. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.d.ts.map +1 -0
  42. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.js +5 -0
  43. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/_theme.embedded.js.map +1 -0
  44. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.d.ts +43 -0
  45. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.d.ts.map +1 -0
  46. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.js +68 -0
  47. package/lib/plugins/service-betterportal-config-manager/bp-routes/config/index.js.map +1 -0
  48. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.d.ts +5 -0
  49. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.d.ts.map +1 -0
  50. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.js +11 -0
  51. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/_theme.bootstrap1/index.js.map +1 -0
  52. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.d.ts +32 -0
  53. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.d.ts.map +1 -0
  54. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.js +32 -0
  55. package/lib/plugins/service-betterportal-config-manager/bp-routes/fragments/index.js.map +1 -0
  56. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.d.ts +4 -0
  57. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.d.ts.map +1 -0
  58. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.js +170 -0
  59. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/_theme.bootstrap1/index.js.map +1 -0
  60. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.d.ts +60 -0
  61. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.d.ts.map +1 -0
  62. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.js +48 -0
  63. package/lib/plugins/service-betterportal-config-manager/bp-routes/menu/index.js.map +1 -0
  64. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.d.ts +4 -0
  65. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.d.ts.map +1 -0
  66. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.js +28 -0
  67. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/_theme.bootstrap1/index.js.map +1 -0
  68. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.d.ts +48 -0
  69. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.d.ts.map +1 -0
  70. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.js +40 -0
  71. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview/index.js.map +1 -0
  72. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.d.ts +5 -0
  73. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.d.ts.map +1 -0
  74. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.js +194 -0
  75. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/index.js.map +1 -0
  76. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.d.ts +80 -0
  77. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.d.ts.map +1 -0
  78. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.js +59 -0
  79. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/index.js.map +1 -0
  80. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.d.ts +5 -0
  81. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.d.ts.map +1 -0
  82. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.js +167 -0
  83. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/index.js.map +1 -0
  84. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.d.ts +128 -0
  85. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.d.ts.map +1 -0
  86. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.js +89 -0
  87. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/index.js.map +1 -0
  88. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.d.ts +5 -0
  89. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.d.ts.map +1 -0
  90. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.js +8 -0
  91. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/_theme.bootstrap1/index.js.map +1 -0
  92. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.d.ts +89 -0
  93. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.d.ts.map +1 -0
  94. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.js +93 -0
  95. package/lib/plugins/service-betterportal-config-manager/bp-routes/settings/index.js.map +1 -0
  96. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.d.ts +4 -0
  97. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.d.ts.map +1 -0
  98. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.js +61 -0
  99. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/_theme.bootstrap1/index.js.map +1 -0
  100. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.d.ts +180 -0
  101. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.d.ts.map +1 -0
  102. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.js +405 -0
  103. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/index.js.map +1 -0
  104. package/lib/plugins/service-betterportal-config-manager/cpBootstrap.d.ts +26 -0
  105. package/lib/plugins/service-betterportal-config-manager/cpBootstrap.d.ts.map +1 -0
  106. package/lib/plugins/service-betterportal-config-manager/cpBootstrap.js +58 -0
  107. package/lib/plugins/service-betterportal-config-manager/cpBootstrap.js.map +1 -0
  108. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.d.ts +3 -0
  109. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.d.ts.map +1 -0
  110. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js +365 -0
  111. package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js.map +1 -0
  112. package/lib/plugins/service-betterportal-config-manager/index.d.ts +143 -0
  113. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -0
  114. package/lib/plugins/service-betterportal-config-manager/index.js +696 -0
  115. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -0
  116. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts +3 -0
  117. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts.map +1 -0
  118. package/lib/plugins/service-betterportal-config-manager/menuEditor.js +823 -0
  119. package/lib/plugins/service-betterportal-config-manager/menuEditor.js.map +1 -0
  120. package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts +10 -0
  121. package/lib/plugins/service-betterportal-config-manager/routeContext.d.ts.map +1 -0
  122. package/lib/plugins/service-betterportal-config-manager/routeContext.js +11 -0
  123. package/lib/plugins/service-betterportal-config-manager/routeContext.js.map +1 -0
  124. package/lib/plugins/service-betterportal-config-manager/setupTokens.d.ts +18 -0
  125. package/lib/plugins/service-betterportal-config-manager/setupTokens.d.ts.map +1 -0
  126. package/lib/plugins/service-betterportal-config-manager/setupTokens.js +245 -0
  127. package/lib/plugins/service-betterportal-config-manager/setupTokens.js.map +1 -0
  128. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +41 -0
  129. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -0
  130. package/lib/plugins/service-betterportal-config-manager/storage/core.js +396 -0
  131. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -0
  132. package/lib/plugins/service-betterportal-config-manager/storage/file.d.ts +10 -0
  133. package/lib/plugins/service-betterportal-config-manager/storage/file.d.ts.map +1 -0
  134. package/lib/plugins/service-betterportal-config-manager/storage/file.js +30 -0
  135. package/lib/plugins/service-betterportal-config-manager/storage/file.js.map +1 -0
  136. package/lib/plugins/service-betterportal-config-manager/storage/index.d.ts +36 -0
  137. package/lib/plugins/service-betterportal-config-manager/storage/index.d.ts.map +1 -0
  138. package/lib/plugins/service-betterportal-config-manager/storage/index.js +52 -0
  139. package/lib/plugins/service-betterportal-config-manager/storage/index.js.map +1 -0
  140. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +15 -0
  141. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -0
  142. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +60 -0
  143. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -0
  144. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +44 -0
  145. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -0
  146. package/lib/plugins/service-betterportal-config-manager/syncApi.js +280 -0
  147. package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -0
  148. package/lib/plugins/service-betterportal-config-manager/webhooks.d.ts +6 -0
  149. package/lib/plugins/service-betterportal-config-manager/webhooks.d.ts.map +1 -0
  150. package/lib/plugins/service-betterportal-config-manager/webhooks.js +372 -0
  151. package/lib/plugins/service-betterportal-config-manager/webhooks.js.map +1 -0
  152. package/lib/schemas/service-betterportal-config-manager.json +157 -0
  153. package/lib/schemas/service-betterportal-config-manager.plugin.json +135 -0
  154. package/package.json +69 -0
@@ -0,0 +1,2319 @@
1
+ import { htmlResponse, jsonResponse, signServiceConfigTicket, uuidv7 } from "@betterportal/framework";
2
+ import { generateApiKey, hashApiKey } from "./storage/index.js";
3
+ import { getManifestCache } from "./syncApi.js";
4
+ const API_BASE = "/.well-known/bp/admin";
5
+ const CONFIG_TICKET_TTL_SECONDS = 5 * 60;
6
+ async function readFormBody(event) {
7
+ const fd = await event.req.formData().catch(() => null);
8
+ if (!fd)
9
+ return {};
10
+ const out = {};
11
+ fd.forEach((v, k) => { if (typeof v === "string")
12
+ out[k] = v; });
13
+ return out;
14
+ }
15
+ function escapeHtml(s) {
16
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
17
+ }
18
+ async function readJsonBody(event) {
19
+ const parsed = await event.req.json().catch(() => null);
20
+ return (parsed && typeof parsed === "object" && !Array.isArray(parsed))
21
+ ? parsed
22
+ : {};
23
+ }
24
+ async function readFormOrJsonBody(event) {
25
+ const contentType = event.req.headers.get("content-type") ?? "";
26
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
27
+ return readFormBody(event);
28
+ }
29
+ return readJsonBody(event);
30
+ }
31
+ function wantsHtmx(event) {
32
+ return event.req.headers.get("hx-request") === "true"
33
+ || (event.req.headers.get("accept") ?? "").includes("text/html");
34
+ }
35
+ function htmxReload(path) {
36
+ return htmlResponse("", 200, "text/html; mode=fragment", {
37
+ "HX-Location": JSON.stringify({ path, target: "#bp-main", swap: "innerHTML" })
38
+ });
39
+ }
40
+ function htmxError(message, status = 400) {
41
+ return htmlResponse(`<div class="alert alert-danger">${escapeHtml(message)}</div>`, status, "text/html; mode=fragment");
42
+ }
43
+ function validationError(event, message) {
44
+ return wantsHtmx(event) ? htmxError(message, 400) : jsonResponse({ error: message }, 400);
45
+ }
46
+ function trimmedString(body, key) {
47
+ const value = body[key];
48
+ return typeof value === "string" ? value.trim() : undefined;
49
+ }
50
+ function requiredRouteString(body, key, label) {
51
+ const value = trimmedString(body, key);
52
+ if (!value)
53
+ return { error: `${label} is required.` };
54
+ return { value };
55
+ }
56
+ function routeMethodsFromManifest(methods) {
57
+ const normalized = (methods ?? []).filter((method) => method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE" || method === "OPTIONS");
58
+ return normalized.length ? normalized : ["GET"];
59
+ }
60
+ function appMatchesTenantUrl(app, tenantUrl) {
61
+ let host = "";
62
+ try {
63
+ host = new URL(tenantUrl).host.toLowerCase();
64
+ }
65
+ catch {
66
+ host = tenantUrl.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
67
+ }
68
+ return app.hostnames.some((hostname) => {
69
+ const value = hostname.toLowerCase();
70
+ if (value === host)
71
+ return true;
72
+ try {
73
+ return new URL(value).host.toLowerCase() === host;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ });
79
+ }
80
+ function appPublicUrl(app) {
81
+ const hostname = app?.hostnames[0];
82
+ if (!hostname)
83
+ return undefined;
84
+ return /^https?:\/\//i.test(hostname) ? hostname : `https://${hostname}`;
85
+ }
86
+ function currentAppFromRequest(config, event) {
87
+ const url = new URL(event.req.url, `http://${event.req.headers.get("host") ?? "localhost"}`);
88
+ const appId = url.searchParams.get("appId") ?? event.req.headers.get("x-bp-app-id") ?? "";
89
+ const tenantUrl = url.searchParams.get("tenantUrl") ?? event.req.headers.get("referer") ?? event.req.headers.get("origin") ?? "";
90
+ return appId
91
+ ? config.apps.find((entry) => entry.id === appId)
92
+ : config.apps.find((entry) => tenantUrl && appMatchesTenantUrl(entry, tenantUrl));
93
+ }
94
+ function managementDiscovery(config, event) {
95
+ const managementApp = config.configManagement.managementAppId
96
+ ? config.apps.find((app) => app.id === config.configManagement.managementAppId)
97
+ : undefined;
98
+ const base = new URL(event.req.url, `http://${event.req.headers.get("host") ?? "localhost"}`);
99
+ const origin = base.origin;
100
+ return {
101
+ protocol: "betterportal-management.v1",
102
+ managementApp: {
103
+ tenantId: managementApp?.tenantId,
104
+ appId: managementApp?.id ?? config.configManagement.managementAppId,
105
+ url: appPublicUrl(managementApp)
106
+ },
107
+ platformAdmin: {
108
+ available: true,
109
+ usage: "operator-only",
110
+ aiPolicy: "do-not-use-for-user-tasks"
111
+ },
112
+ endpoints: {
113
+ current: `${origin}/.well-known/bp/manage/current`,
114
+ services: `${origin}/.well-known/bp/manage/services`,
115
+ routes: `${origin}/.well-known/bp/manage/routes`,
116
+ fragments: `${origin}/.well-known/bp/manage/fragments`,
117
+ theme: `${origin}/.well-known/bp/manage/theme`,
118
+ webhooks: `${origin}/.well-known/bp/manage/webhooks/targets`
119
+ }
120
+ };
121
+ }
122
+ function automationServiceCatalog(config, appDef) {
123
+ const tenant = config.tenants.find((entry) => entry.id === appDef.tenantId);
124
+ const manifestCache = getManifestCache();
125
+ const sharedById = new Map(config.sharedServiceCatalog.map((service) => [service.id, service]));
126
+ const services = [
127
+ ...(tenant?.services ?? []).filter((service) => service.enabled).map((service) => ({
128
+ id: service.id,
129
+ serviceId: service.serviceId ?? service.id,
130
+ title: service.title ?? service.serviceId ?? service.id,
131
+ url: service.hostname,
132
+ source: "tenant"
133
+ })),
134
+ ...config.sharedServiceActivations
135
+ .filter((activation) => activation.enabled && activation.tenantId === appDef.tenantId && (!activation.appId || activation.appId === appDef.id))
136
+ .map((activation) => {
137
+ const shared = sharedById.get(activation.sharedServiceId);
138
+ return shared && shared.enabled ? {
139
+ id: activation.id,
140
+ serviceId: shared.serviceId ?? shared.id,
141
+ title: shared.title,
142
+ url: shared.baseUrl,
143
+ source: "shared"
144
+ } : null;
145
+ })
146
+ .filter((service) => service !== null)
147
+ ];
148
+ return {
149
+ protocol: "betterportal-automation.v1",
150
+ tenantId: appDef.tenantId,
151
+ appId: appDef.id,
152
+ services: services.map((service) => {
153
+ const manifest = manifestCache.get(service.id) ?? manifestCache.get(service.serviceId);
154
+ return {
155
+ ...service,
156
+ manifestSynced: Boolean(manifest),
157
+ capabilities: manifest?.capabilities ?? [],
158
+ configSchemas: manifest?.configSchemas ?? [],
159
+ webhooks: manifest?.webhooks ?? [],
160
+ actions: Object.values(manifest?.viewIndex ?? {}).map((view) => ({
161
+ viewId: view.viewId,
162
+ path: view.path,
163
+ methods: view.methods,
164
+ title: view.viewId,
165
+ renderable: view.renderable,
166
+ permissions: view.permissions,
167
+ role: view.role,
168
+ chrome: view.chrome,
169
+ dependencies: view.dependencies,
170
+ schemas: view.schemas,
171
+ raw: view.raw === true,
172
+ demoScenarios: view.demoScenarios
173
+ }))
174
+ };
175
+ })
176
+ };
177
+ }
178
+ function parseRouteCreateBody(body) {
179
+ const serviceId = requiredRouteString(body, "serviceId", "Service");
180
+ if (serviceId.error)
181
+ return { error: serviceId.error };
182
+ const viewId = requiredRouteString(body, "viewId", "View");
183
+ if (viewId.error)
184
+ return { error: viewId.error };
185
+ const manifestView = getManifestCache().get(serviceId.value)?.viewIndex[viewId.value];
186
+ const renderable = manifestView?.renderable !== false;
187
+ const path = renderable ? requiredRouteString(body, "path", "Mount path") : { value: manifestView?.path ?? `/${viewId.value}` };
188
+ if (path.error)
189
+ return { error: path.error };
190
+ if (!path.value.startsWith("/"))
191
+ return { error: "Mount path must start with /." };
192
+ const title = renderable ? requiredRouteString(body, "title", "Display title") : { value: manifestView?.viewId ?? viewId.value };
193
+ if (title.error)
194
+ return { error: title.error };
195
+ const route = {
196
+ path: path.value,
197
+ serviceId: serviceId.value,
198
+ viewId: viewId.value,
199
+ title: title.value,
200
+ enabled: true,
201
+ methods: routeMethodsFromManifest(manifestView?.methods)
202
+ };
203
+ if (manifestView?.path)
204
+ route.targetPath = manifestView.path;
205
+ const query = trimmedString(body, "query");
206
+ if (query && renderable)
207
+ route.query = query.replace(/^\?+/, "");
208
+ return { route };
209
+ }
210
+ function countMenuRouteReferences(items, routeId) {
211
+ if (!Array.isArray(items))
212
+ return 0;
213
+ let count = 0;
214
+ for (const item of items) {
215
+ if (!item || typeof item !== "object")
216
+ continue;
217
+ const menuItem = item;
218
+ if (menuItem.routeId === routeId)
219
+ count += 1;
220
+ count += countMenuRouteReferences(menuItem.children, routeId);
221
+ }
222
+ return count;
223
+ }
224
+ function removeMenuRoutes(items, routeIds, serviceTitle) {
225
+ if (!Array.isArray(items))
226
+ return [];
227
+ const out = [];
228
+ for (const item of items) {
229
+ if (!item || typeof item !== "object")
230
+ continue;
231
+ const menuItem = item;
232
+ if (typeof menuItem.routeId === "string" && routeIds.has(menuItem.routeId))
233
+ continue;
234
+ const next = { ...menuItem };
235
+ if (Array.isArray(menuItem.children)) {
236
+ next.children = removeMenuRoutes(menuItem.children, routeIds, serviceTitle);
237
+ if (next.type === "group"
238
+ && typeof serviceTitle === "string"
239
+ && next.title === serviceTitle
240
+ && Array.isArray(next.children)
241
+ && next.children.length === 0) {
242
+ continue;
243
+ }
244
+ }
245
+ out.push(next);
246
+ }
247
+ return out;
248
+ }
249
+ function cleanupProvisionalTenantService(config, tenantId, serviceInstanceId) {
250
+ const tenant = config.tenants.find((candidate) => candidate.id === tenantId);
251
+ if (!tenant)
252
+ return { removed: false, error: "Tenant not found" };
253
+ const service = tenant.services.find((candidate) => candidate.id === serviceInstanceId);
254
+ if (!service)
255
+ return { removed: false };
256
+ if (service.apiKeyHash)
257
+ return { removed: false, error: "Service is already installed and cannot be cleaned up as provisional." };
258
+ tenant.services = tenant.services.filter((candidate) => candidate.id !== serviceInstanceId);
259
+ for (const appDef of config.apps.filter((candidate) => candidate.tenantId === tenantId)) {
260
+ const routeIds = new Set(appDef.routes.filter((route) => route.serviceId === serviceInstanceId).map((route) => route.id));
261
+ if (routeIds.size === 0)
262
+ continue;
263
+ appDef.routes = appDef.routes.filter((route) => route.serviceId !== serviceInstanceId);
264
+ appDef.menu = removeMenuRoutes(appDef.menu, routeIds, service.title);
265
+ }
266
+ return { removed: true };
267
+ }
268
+ function addRouteDependencies(appDef, route) {
269
+ const manifest = getManifestCache().get(route.serviceId);
270
+ const view = manifest?.viewIndex[route.viewId];
271
+ if (!manifest || !view)
272
+ return;
273
+ for (const dependencyViewId of view.dependencies) {
274
+ const dependency = manifest.viewIndex[dependencyViewId];
275
+ if (!dependency)
276
+ continue;
277
+ if (appDef.routes.some((candidate) => candidate.serviceId === route.serviceId && candidate.viewId === dependencyViewId))
278
+ continue;
279
+ appDef.routes.push({
280
+ id: uuidv7(),
281
+ path: dependency.path,
282
+ serviceId: route.serviceId,
283
+ viewId: dependencyViewId,
284
+ targetPath: dependency.path,
285
+ title: dependency.viewId,
286
+ enabled: true,
287
+ methods: routeMethodsFromManifest(dependency.methods)
288
+ });
289
+ }
290
+ }
291
+ function validateRegisteredRouteService(config, appDef, serviceId) {
292
+ const tenant = config.tenants.find((candidate) => candidate.id === appDef.tenantId);
293
+ if (!tenant)
294
+ return `App tenant not found: ${appDef.tenantId}`;
295
+ if (tenant.services.some((service) => service.enabled && service.id === serviceId))
296
+ return undefined;
297
+ const platformService = config.platformServices.find((service) => service.enabled && service.id === serviceId);
298
+ if (platformService && tenant.activatedPlatformServices.includes(serviceId))
299
+ return undefined;
300
+ const sharedActivation = config.sharedServiceActivations.find((activation) => activation.enabled
301
+ && activation.id === serviceId
302
+ && activation.tenantId === appDef.tenantId
303
+ && (!activation.appId || activation.appId === appDef.id));
304
+ if (sharedActivation) {
305
+ const shared = config.sharedServiceCatalog.find((service) => service.enabled && service.id === sharedActivation.sharedServiceId);
306
+ if (shared)
307
+ return undefined;
308
+ }
309
+ return "Route service must be registered or activated for this app's tenant.";
310
+ }
311
+ function htmxAlert(message, kind = "danger") {
312
+ return htmlResponse(`<div class="alert alert-${kind}">${escapeHtml(message)}</div>`, 200, "text/html; mode=fragment");
313
+ }
314
+ function getParam(event, name) {
315
+ return event.context?.params?.[name];
316
+ }
317
+ function signConfigTicket(cpState, input) {
318
+ // RS256 JWT signed with the CP key. Services verify it against the CP JWKS,
319
+ // so there is no shared secret to leak and only the CP can mint tickets.
320
+ return signServiceConfigTicket({
321
+ privateKeyPem: cpState.keyPair.privateKeyPem,
322
+ kid: cpState.keyPair.kid,
323
+ issuer: cpState.issuer,
324
+ tenantId: input.tenantId,
325
+ serviceId: input.serviceId,
326
+ actions: input.actions,
327
+ subject: input.subject ?? "admin",
328
+ expiresInSeconds: CONFIG_TICKET_TTL_SECONDS
329
+ });
330
+ }
331
+ function findRegisteredService(config, tenantId, hostname, serviceInstanceId) {
332
+ if (serviceInstanceId) {
333
+ const tenant = (config.tenants ?? []).find((t) => t.id === tenantId);
334
+ const tenantService = (tenant?.services ?? []).find((svc) => svc.id === serviceInstanceId);
335
+ if (tenantService)
336
+ return tenantService;
337
+ const platformService = (config.platformServices ?? []).find((svc) => svc.id === serviceInstanceId);
338
+ if (platformService)
339
+ return platformService;
340
+ const sharedActivation = (config.sharedServiceActivations ?? []).find((activation) => activation.enabled
341
+ && activation.tenantId === tenantId
342
+ && activation.id === serviceInstanceId);
343
+ if (sharedActivation) {
344
+ const shared = (config.sharedServiceCatalog ?? []).find((svc) => svc.enabled
345
+ && svc.id === sharedActivation.sharedServiceId);
346
+ if (shared) {
347
+ return {
348
+ id: sharedActivation.id,
349
+ serviceId: shared.serviceId ?? shared.id,
350
+ hostname: shared.baseUrl,
351
+ title: shared.title,
352
+ capabilities: shared.tags ?? []
353
+ };
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+ const normalizedHostname = hostname.replace(/\/+$/, "");
359
+ const tenant = (config.tenants ?? []).find((t) => t.id === tenantId);
360
+ const tenantService = (tenant?.services ?? []).find((svc) => (svc.hostname ?? "").replace(/\/+$/, "") === normalizedHostname);
361
+ if (tenantService)
362
+ return tenantService;
363
+ const platformService = (config.platformServices ?? []).find((svc) => (svc.hostname ?? "").replace(/\/+$/, "") === normalizedHostname);
364
+ if (platformService)
365
+ return platformService;
366
+ const sharedActivations = (config.sharedServiceActivations ?? []).filter((activation) => activation.enabled
367
+ && activation.tenantId === tenantId);
368
+ for (const sharedActivation of sharedActivations) {
369
+ const shared = (config.sharedServiceCatalog ?? []).find((svc) => svc.enabled
370
+ && svc.id === sharedActivation.sharedServiceId
371
+ && (svc.baseUrl ?? "").replace(/\/+$/, "") === normalizedHostname);
372
+ if (shared) {
373
+ return {
374
+ id: sharedActivation.id,
375
+ serviceId: shared.serviceId ?? shared.id,
376
+ hostname: shared.baseUrl,
377
+ title: shared.title,
378
+ capabilities: shared.tags ?? []
379
+ };
380
+ }
381
+ }
382
+ return null;
383
+ }
384
+ function normalizeHostname(hostname) {
385
+ return (hostname ?? "").replace(/\/+$/, "");
386
+ }
387
+ function stringArray(value) {
388
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.length > 0) : [];
389
+ }
390
+ function objectValue(value) {
391
+ return value && typeof value === "object" && !Array.isArray(value)
392
+ ? value
393
+ : undefined;
394
+ }
395
+ function deploymentModes(value) {
396
+ const allowed = new Set(["bp-hosted", "customer-hosted", "third-party-saas", "self-hosted", "saas-managed"]);
397
+ return stringArray(value).filter((entry) => allowed.has(entry));
398
+ }
399
+ function duplicateTenantService(tenant, hostname, serviceId) {
400
+ const normalized = normalizeHostname(hostname);
401
+ const sameHost = tenant.services.find((service) => normalizeHostname(service.hostname) === normalized);
402
+ if (sameHost)
403
+ return `Service URL is already registered for this tenant: ${normalized}`;
404
+ if (serviceId) {
405
+ const samePlugin = tenant.services.find((service) => service.serviceId === serviceId);
406
+ if (samePlugin)
407
+ return `Service plugin is already registered for this tenant: ${serviceId}`;
408
+ }
409
+ return null;
410
+ }
411
+ function collectServiceDeleteBlockers(config, tenantId, serviceId) {
412
+ const blockers = [];
413
+ const apps = config.apps.filter((app) => app.tenantId === tenantId);
414
+ const add = (app, label, id) => {
415
+ blockers.push(`${app.title || app.id}: ${label} ${id}`);
416
+ };
417
+ for (const app of apps) {
418
+ if (app.shell?.serviceId === serviceId)
419
+ add(app, "shell", serviceId);
420
+ for (const route of app.routes) {
421
+ if (route.serviceId === serviceId)
422
+ add(app, "route", route.title ?? route.path ?? route.id);
423
+ }
424
+ for (const slot of app.slots) {
425
+ if (slot.serviceId === serviceId)
426
+ add(app, "slot", slot.slotId);
427
+ }
428
+ for (const [location, fragments] of Object.entries(app.fragments)) {
429
+ for (const fragment of fragments) {
430
+ if (fragment.serviceId === serviceId)
431
+ add(app, "fragment", `${location}.${fragment.fragmentId}`);
432
+ }
433
+ }
434
+ if (app.auth?.serviceId === serviceId)
435
+ add(app, "auth provider", serviceId);
436
+ for (const role of app.auth?.roles ?? []) {
437
+ for (const grant of role.permissions) {
438
+ if (grant.serviceId === serviceId)
439
+ add(app, "role grant", `${role.id}:${grant.viewId}`);
440
+ }
441
+ }
442
+ }
443
+ return blockers;
444
+ }
445
+ function sharedServiceIdFor(service, requested) {
446
+ return (requested && requested.trim()) || service.serviceId || service.id;
447
+ }
448
+ function collectServiceReferences(config, tenantId, serviceId, appId) {
449
+ const refs = [];
450
+ const apps = config.apps.filter((app) => app.tenantId === tenantId && (!appId || app.id === appId));
451
+ for (const app of apps) {
452
+ const add = (kind, label) => {
453
+ refs.push(`${app.title ?? app.id}: ${kind} ${label}`);
454
+ };
455
+ if (app.shell?.serviceId === serviceId)
456
+ add("shell", serviceId);
457
+ if (app.auth?.serviceId === serviceId)
458
+ add("auth provider", serviceId);
459
+ for (const route of app.routes) {
460
+ if (route.serviceId === serviceId)
461
+ add("route", route.title ?? route.path ?? route.id);
462
+ }
463
+ for (const slot of app.slots) {
464
+ if (slot.serviceId === serviceId)
465
+ add("slot", slot.slotId);
466
+ }
467
+ for (const [location, fragments] of Object.entries(app.fragments)) {
468
+ for (const fragment of fragments) {
469
+ if (fragment.serviceId === serviceId)
470
+ add("fragment", `${location}.${fragment.fragmentId}`);
471
+ }
472
+ }
473
+ for (const role of app.auth?.roles ?? []) {
474
+ for (const grant of role.permissions) {
475
+ if (grant.serviceId === serviceId)
476
+ add("role grant", `${role.id}:${grant.viewId}`);
477
+ }
478
+ }
479
+ }
480
+ return refs;
481
+ }
482
+ function rewriteServiceReferences(config, tenantId, fromServiceId, toServiceId, appId) {
483
+ let count = 0;
484
+ const apps = config.apps.filter((app) => app.tenantId === tenantId && (!appId || app.id === appId));
485
+ const rewrite = (current) => {
486
+ if (current !== fromServiceId)
487
+ return current;
488
+ count += 1;
489
+ return toServiceId;
490
+ };
491
+ for (const app of apps) {
492
+ if (app.shell)
493
+ app.shell.serviceId = rewrite(app.shell.serviceId);
494
+ if (app.auth)
495
+ app.auth.serviceId = rewrite(app.auth.serviceId);
496
+ for (const route of app.routes)
497
+ route.serviceId = rewrite(route.serviceId);
498
+ for (const slot of app.slots)
499
+ slot.serviceId = rewrite(slot.serviceId);
500
+ for (const fragments of Object.values(app.fragments)) {
501
+ for (const fragment of fragments)
502
+ fragment.serviceId = rewrite(fragment.serviceId);
503
+ }
504
+ for (const role of app.auth?.roles ?? []) {
505
+ for (const grant of role.permissions)
506
+ grant.serviceId = rewrite(grant.serviceId);
507
+ }
508
+ }
509
+ return count;
510
+ }
511
+ function previewTenantServiceSharedMigration(config, tenantId, serviceId, options = {}) {
512
+ const tenant = config.tenants.find((candidate) => candidate.id === tenantId);
513
+ const service = tenant?.services.find((candidate) => candidate.id === serviceId);
514
+ const blockers = [];
515
+ if (!tenant)
516
+ blockers.push(`Tenant not found: ${tenantId}`);
517
+ if (!service)
518
+ blockers.push(`Tenant service not found: ${serviceId}`);
519
+ if (service && !service.serviceId)
520
+ blockers.push("Service is not linked to a BetterPortal plugin id.");
521
+ if (service && !service.hostname)
522
+ blockers.push("Service has no hostname.");
523
+ if (service && service.serviceId === "service.betterportal.config-manager") {
524
+ blockers.push("Config Manager is the control plane service and cannot be converted to shared by this migration.");
525
+ }
526
+ if (options.appId && !config.apps.some((app) => app.id === options.appId && app.tenantId === tenantId)) {
527
+ blockers.push(`App ${options.appId} does not belong to tenant ${tenantId}.`);
528
+ }
529
+ const sharedServiceId = service ? sharedServiceIdFor(service, options.sharedServiceId) : undefined;
530
+ if (sharedServiceId) {
531
+ const existing = config.sharedServiceCatalog.find((candidate) => candidate.id === sharedServiceId);
532
+ if (existing?.serviceId && service?.serviceId && existing.serviceId !== service.serviceId) {
533
+ blockers.push(`Shared service ${sharedServiceId} is linked to plugin ${existing.serviceId}, not ${service.serviceId}.`);
534
+ }
535
+ }
536
+ const references = service ? collectServiceReferences(config, tenantId, service.id, options.appId) : [];
537
+ if (service && references.length === 0) {
538
+ blockers.push("No app references were found to migrate.");
539
+ }
540
+ return { tenant, service, sharedServiceId, references, blockers };
541
+ }
542
+ function migrateTenantServiceToShared(config, tenantId, serviceId, options = {}) {
543
+ const preview = previewTenantServiceSharedMigration(config, tenantId, serviceId, options);
544
+ if (preview.blockers.length > 0 || !preview.tenant || !preview.service || !preview.sharedServiceId) {
545
+ throw new Error(preview.blockers.join("\n") || "Service cannot be migrated.");
546
+ }
547
+ const { tenant, service } = preview;
548
+ const sharedServiceId = preview.sharedServiceId;
549
+ const now = new Date().toISOString();
550
+ let reusedCatalog = true;
551
+ let shared = config.sharedServiceCatalog.find((candidate) => candidate.id === sharedServiceId);
552
+ if (!shared) {
553
+ reusedCatalog = false;
554
+ shared = {
555
+ id: sharedServiceId,
556
+ serviceId: service.serviceId,
557
+ title: service.title ?? service.serviceId ?? service.hostname,
558
+ description: service.description,
559
+ baseUrl: service.hostname,
560
+ apiKeyHash: service.apiKeyHash,
561
+ publicKeyPem: service.publicKeyPem,
562
+ keyId: service.keyId,
563
+ supportedDeploymentModes: [service.deploymentMode],
564
+ owner: "bp",
565
+ category: service.capabilities.includes("auth") ? "auth" : service.capabilities.includes("theme") ? "theme" : undefined,
566
+ tags: [...new Set(service.capabilities)],
567
+ enabled: true
568
+ };
569
+ config.sharedServiceCatalog.push(shared);
570
+ }
571
+ else {
572
+ shared.serviceId = shared.serviceId ?? service.serviceId;
573
+ shared.baseUrl = service.hostname;
574
+ shared.apiKeyHash = service.apiKeyHash || shared.apiKeyHash;
575
+ shared.publicKeyPem = service.publicKeyPem ?? shared.publicKeyPem;
576
+ shared.keyId = service.keyId ?? shared.keyId;
577
+ shared.title = shared.title || service.title || service.serviceId || service.hostname;
578
+ shared.description = shared.description ?? service.description;
579
+ shared.supportedDeploymentModes = [...new Set([...(shared.supportedDeploymentModes ?? []), service.deploymentMode])];
580
+ shared.tags = [...new Set([...(shared.tags ?? []), ...service.capabilities])];
581
+ shared.enabled = true;
582
+ }
583
+ const existingActivation = config.sharedServiceActivations.find((activation) => activation.sharedServiceId === sharedServiceId
584
+ && activation.tenantId === tenantId
585
+ && activation.appId === options.appId);
586
+ const activationId = existingActivation?.id ?? uuidv7();
587
+ if (existingActivation) {
588
+ existingActivation.enabled = true;
589
+ existingActivation.activatedAt = now;
590
+ }
591
+ else {
592
+ config.sharedServiceActivations.push({
593
+ id: activationId,
594
+ tenantId,
595
+ appId: options.appId,
596
+ sharedServiceId,
597
+ activatedAt: now,
598
+ enabled: true
599
+ });
600
+ }
601
+ const rewrittenReferences = rewriteServiceReferences(config, tenantId, service.id, activationId, options.appId);
602
+ const shouldRemove = options.removeTenantService !== false && collectServiceReferences(config, tenantId, service.id).length === 0;
603
+ if (shouldRemove)
604
+ tenant.services = tenant.services.filter((candidate) => candidate.id !== service.id);
605
+ return {
606
+ sharedServiceId,
607
+ activationId,
608
+ rewrittenReferences,
609
+ reusedCatalog,
610
+ removedTenantService: shouldRemove
611
+ };
612
+ }
613
+ function linkedServiceError(blockers) {
614
+ return `Service is still linked and cannot be deleted. Remove these references first: ${blockers.slice(0, 8).join("; ")}${blockers.length > 8 ? `; and ${blockers.length - 8} more` : ""}`;
615
+ }
616
+ function parseWizardManifest(raw) {
617
+ const parsed = JSON.parse(raw);
618
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
619
+ ? parsed
620
+ : {};
621
+ }
622
+ function adminApiBaseFromEvent(event) {
623
+ const requestUrl = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
624
+ return new URL(API_BASE, requestUrl).toString().replace(/\/+$/, "");
625
+ }
626
+ export function registerAdminApiRoutes(app, store, cpState) {
627
+ app.get("/.well-known/bp/management", async (event) => {
628
+ const config = await store.loadConfig();
629
+ return jsonResponse(managementDiscovery(config, event));
630
+ });
631
+ app.get("/.well-known/bp/automation/catalog", async (event) => {
632
+ const url = new URL(event.req.url, `http://${event.req.headers.get("host") ?? "localhost"}`);
633
+ const tenantUrl = url.searchParams.get("tenantUrl") ?? "";
634
+ const appId = url.searchParams.get("appId") ?? "";
635
+ const config = await store.loadConfig();
636
+ const appDef = appId
637
+ ? config.apps.find((entry) => entry.id === appId)
638
+ : config.apps.find((entry) => tenantUrl && appMatchesTenantUrl(entry, tenantUrl));
639
+ if (!appDef)
640
+ return jsonResponse({ error: "Unable to resolve BetterPortal app from tenantUrl/appId" }, 404);
641
+ return jsonResponse(automationServiceCatalog(config, appDef));
642
+ });
643
+ app.get("/.well-known/bp/manage/current", async (event) => {
644
+ const config = await store.loadConfig();
645
+ const appDef = currentAppFromRequest(config, event);
646
+ if (!appDef)
647
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
648
+ const tenant = config.tenants.find((entry) => entry.id === appDef.tenantId);
649
+ return jsonResponse({
650
+ protocol: "betterportal-manage.v1",
651
+ scope: "app",
652
+ tenant: tenant ? { id: tenant.id, title: tenant.title } : undefined,
653
+ app: { id: appDef.id, tenantId: appDef.tenantId, title: appDef.title, hostnames: appDef.hostnames },
654
+ idsVisible: true,
655
+ platformAdmin: {
656
+ usage: "operator-only",
657
+ aiPolicy: "do-not-use-for-user-tasks"
658
+ },
659
+ links: {
660
+ services: "/.well-known/bp/manage/services",
661
+ routes: "/.well-known/bp/manage/routes",
662
+ fragments: "/.well-known/bp/manage/fragments",
663
+ theme: "/.well-known/bp/manage/theme",
664
+ webhooks: "/.well-known/bp/manage/webhooks/targets"
665
+ }
666
+ });
667
+ });
668
+ app.get("/.well-known/bp/manage/services", async (event) => {
669
+ const config = await store.loadConfig();
670
+ const appDef = currentAppFromRequest(config, event);
671
+ if (!appDef)
672
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
673
+ return jsonResponse(automationServiceCatalog(config, appDef));
674
+ });
675
+ app.post("/.well-known/bp/manage/services/activate", async (event) => {
676
+ const config = await store.loadConfig();
677
+ const appDef = currentAppFromRequest(config, event);
678
+ if (!appDef)
679
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
680
+ const body = await readFormOrJsonBody(event);
681
+ const sharedServiceId = trimmedString(body, "sharedServiceId");
682
+ if (!sharedServiceId)
683
+ return jsonResponse({ error: "sharedServiceId is required" }, 400);
684
+ const shared = config.sharedServiceCatalog.find((entry) => entry.id === sharedServiceId && entry.enabled);
685
+ if (!shared)
686
+ return jsonResponse({ error: "Shared service not found or disabled" }, 404);
687
+ let activation = config.sharedServiceActivations.find((entry) => entry.sharedServiceId === sharedServiceId && entry.tenantId === appDef.tenantId && entry.appId === appDef.id);
688
+ if (!activation) {
689
+ activation = {
690
+ id: uuidv7(),
691
+ tenantId: appDef.tenantId,
692
+ appId: appDef.id,
693
+ sharedServiceId,
694
+ activatedAt: new Date().toISOString(),
695
+ enabled: true
696
+ };
697
+ config.sharedServiceActivations.push(activation);
698
+ await store.saveConfig(config);
699
+ }
700
+ return jsonResponse({ ok: true, activation }, 201);
701
+ });
702
+ app.get("/.well-known/bp/manage/routes", async (event) => {
703
+ const config = await store.loadConfig();
704
+ const appDef = currentAppFromRequest(config, event);
705
+ if (!appDef)
706
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
707
+ return jsonResponse({ appId: appDef.id, routes: appDef.routes });
708
+ });
709
+ app.post("/.well-known/bp/manage/routes", async (event) => {
710
+ const config = await store.loadConfig();
711
+ const appDef = currentAppFromRequest(config, event);
712
+ if (!appDef)
713
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
714
+ const body = await readFormOrJsonBody(event);
715
+ const parsed = parseRouteCreateBody(body);
716
+ if (parsed.error || !parsed.route)
717
+ return validationError(event, parsed.error ?? "Invalid route");
718
+ const route = { id: uuidv7(), ...parsed.route };
719
+ appDef.routes.push(route);
720
+ addRouteDependencies(appDef, route);
721
+ await store.saveConfig(config);
722
+ return jsonResponse({ ok: true }, 201);
723
+ });
724
+ app.get("/.well-known/bp/manage/fragments", async (event) => {
725
+ const config = await store.loadConfig();
726
+ const appDef = currentAppFromRequest(config, event);
727
+ if (!appDef)
728
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
729
+ return jsonResponse({ appId: appDef.id, fragments: appDef.fragments });
730
+ });
731
+ app.get("/.well-known/bp/manage/theme", async (event) => {
732
+ const config = await store.loadConfig();
733
+ const appDef = currentAppFromRequest(config, event);
734
+ if (!appDef)
735
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
736
+ return jsonResponse({ appId: appDef.id, themeConfig: appDef.themeConfig });
737
+ });
738
+ app.post("/.well-known/bp/manage/theme", async (event) => {
739
+ const config = await store.loadConfig();
740
+ const appDef = currentAppFromRequest(config, event);
741
+ if (!appDef)
742
+ return jsonResponse({ error: "Unable to resolve current BetterPortal app" }, 404);
743
+ const body = await readFormOrJsonBody(event);
744
+ const mode = body.mode === "light" || body.mode === "dark" || body.mode === "system"
745
+ ? body.mode
746
+ : undefined;
747
+ appDef.themeConfig = {
748
+ ...appDef.themeConfig,
749
+ ...(mode ? { mode } : {}),
750
+ bootstrap: {
751
+ ...(appDef.themeConfig.bootstrap ?? {}),
752
+ ...Object.fromEntries(["primary", "secondary", "success", "info", "warning", "danger"].flatMap((key) => typeof body[key] === "string" ? [[key, body[key]]] : []))
753
+ }
754
+ };
755
+ await store.saveConfig(config);
756
+ return jsonResponse({ ok: true, themeConfig: appDef.themeConfig });
757
+ });
758
+ // Platform services (marketplace)
759
+ app.get(`${API_BASE}/platform-services`, async () => {
760
+ const config = await store.loadConfig();
761
+ return jsonResponse(config.platformServices.map((s) => ({
762
+ id: s.id, hostname: s.hostname, serviceId: s.serviceId, capabilities: s.capabilities,
763
+ title: s.title, description: s.description, enabled: s.enabled, createdAt: s.createdAt
764
+ })));
765
+ });
766
+ app.post(`${API_BASE}/platform-services`, async (event) => {
767
+ const body = await readJsonBody(event);
768
+ const hostname = body.hostname;
769
+ const title = body.title || hostname;
770
+ if (!hostname)
771
+ return jsonResponse({ error: "hostname is required" }, 400);
772
+ const config = await store.loadConfig();
773
+ const apiKey = generateApiKey();
774
+ const service = {
775
+ id: uuidv7(),
776
+ hostname,
777
+ apiKeyHash: hashApiKey(apiKey),
778
+ capabilities: Array.isArray(body.capabilities) ? body.capabilities.filter((value) => typeof value === "string") : [],
779
+ title,
780
+ description: body.description || undefined,
781
+ createdAt: new Date().toISOString(),
782
+ enabled: true
783
+ };
784
+ config.platformServices.push(service);
785
+ await store.saveConfig(config);
786
+ return jsonResponse({ id: service.id, hostname, apiKey, title }, 201);
787
+ });
788
+ app.post(`${API_BASE}/shared-services`, async (event) => {
789
+ const body = await readFormOrJsonBody(event);
790
+ const manifest = objectValue(body.manifest);
791
+ const manifestPluginId = typeof manifest?.pluginId === "string" ? manifest.pluginId.trim() : "";
792
+ const manifestTitle = typeof manifest?.title === "string" ? manifest.title.trim() : "";
793
+ const id = typeof body.id === "string" && body.id.trim() ? body.id.trim() : manifestPluginId;
794
+ const title = typeof body.title === "string" && body.title.trim() ? body.title.trim() : manifestTitle;
795
+ const baseUrl = typeof body.baseUrl === "string" ? normalizeHostname(body.baseUrl.trim()) : "";
796
+ if (!id || !title || !baseUrl) {
797
+ return wantsHtmx(event) ? htmxError("baseUrl and a valid service manifest are required") : jsonResponse({ error: "baseUrl and a valid service manifest are required" }, 400);
798
+ }
799
+ const config = await store.loadConfig();
800
+ if (config.sharedServiceCatalog.some((service) => service.id === id)) {
801
+ return wantsHtmx(event) ? htmxError(`Shared service already exists: ${id}`, 409) : jsonResponse({ error: `Shared service already exists: ${id}` }, 409);
802
+ }
803
+ const explicitTags = typeof body.tags === "string"
804
+ ? body.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
805
+ : stringArray(body.tags);
806
+ const capabilities = stringArray(manifest?.capabilities);
807
+ const supportedThemes = stringArray(manifest?.supportedThemes).map((theme) => `theme.${theme}`);
808
+ const tags = [...new Set([...explicitTags, ...capabilities, ...supportedThemes])];
809
+ const category = typeof body.category === "string" && body.category.trim()
810
+ ? body.category.trim()
811
+ : typeof manifest?.category === "string" && manifest.category.trim()
812
+ ? manifest.category.trim()
813
+ : undefined;
814
+ const supportedDeploymentModes = deploymentModes(manifest?.deploymentModes);
815
+ config.sharedServiceCatalog.push({
816
+ id,
817
+ serviceId: manifestPluginId || id,
818
+ title,
819
+ baseUrl,
820
+ apiKeyHash: "",
821
+ description: typeof body.description === "string" && body.description.trim()
822
+ ? body.description.trim()
823
+ : typeof manifest?.description === "string" && manifest.description.trim()
824
+ ? manifest.description.trim()
825
+ : undefined,
826
+ category,
827
+ tags,
828
+ owner: body.owner === "3p" ? "3p" : "bp",
829
+ supportedDeploymentModes: supportedDeploymentModes.length > 0 ? supportedDeploymentModes : ["bp-hosted"],
830
+ enabled: !(body.enabled === "false" || body.enabled === false)
831
+ });
832
+ await store.saveConfig(config);
833
+ if (wantsHtmx(event))
834
+ return htmxReload("/services");
835
+ return jsonResponse({ ok: true, id, title, baseUrl }, 201);
836
+ });
837
+ app.put(`${API_BASE}/shared-services/:sharedServiceId`, async (event) => {
838
+ const sharedServiceId = getParam(event, "sharedServiceId");
839
+ if (!sharedServiceId)
840
+ return jsonResponse({ error: "sharedServiceId required" }, 400);
841
+ const body = await readFormOrJsonBody(event);
842
+ const config = await store.loadConfig();
843
+ const service = config.sharedServiceCatalog.find((candidate) => candidate.id === sharedServiceId);
844
+ if (!service)
845
+ return wantsHtmx(event) ? htmxError("Shared service not found", 404) : jsonResponse({ error: "Shared service not found" }, 404);
846
+ if (typeof body.title === "string" && body.title.trim())
847
+ service.title = body.title.trim();
848
+ if (typeof body.baseUrl === "string" && body.baseUrl.trim())
849
+ service.baseUrl = normalizeHostname(body.baseUrl.trim());
850
+ if (typeof body.description === "string")
851
+ service.description = body.description.trim() || undefined;
852
+ if (typeof body.category === "string")
853
+ service.category = body.category.trim() || undefined;
854
+ if (typeof body.tags === "string")
855
+ service.tags = body.tags.split(",").map((tag) => tag.trim()).filter(Boolean);
856
+ if (body.owner === "bp" || body.owner === "3p")
857
+ service.owner = body.owner;
858
+ if (body.enabled === "true" || body.enabled === true)
859
+ service.enabled = true;
860
+ if (body.enabled === "false" || body.enabled === false)
861
+ service.enabled = false;
862
+ await store.saveConfig(config);
863
+ if (wantsHtmx(event))
864
+ return htmxReload("/services");
865
+ return jsonResponse({ ok: true });
866
+ });
867
+ app.delete(`${API_BASE}/shared-services/:sharedServiceId`, async (event) => {
868
+ const sharedServiceId = getParam(event, "sharedServiceId");
869
+ if (!sharedServiceId)
870
+ return jsonResponse({ error: "sharedServiceId required" }, 400);
871
+ const config = await store.loadConfig();
872
+ const activations = config.sharedServiceActivations.filter((activation) => activation.sharedServiceId === sharedServiceId && activation.enabled);
873
+ if (activations.length > 0) {
874
+ const message = `Shared service is activated for ${activations.length} tenant/app binding(s). Deactivate it before deleting.`;
875
+ return wantsHtmx(event) ? htmxError(message, 409) : jsonResponse({ error: message }, 409);
876
+ }
877
+ const before = config.sharedServiceCatalog.length;
878
+ config.sharedServiceCatalog = config.sharedServiceCatalog.filter((service) => service.id !== sharedServiceId);
879
+ if (config.sharedServiceCatalog.length === before) {
880
+ return wantsHtmx(event) ? htmxError("Shared service not found", 404) : jsonResponse({ error: "Shared service not found" }, 404);
881
+ }
882
+ await store.saveConfig(config);
883
+ if (wantsHtmx(event))
884
+ return htmxReload("/services");
885
+ return jsonResponse({ ok: true });
886
+ });
887
+ app.post(`${API_BASE}/shared-services/:sharedServiceId/activations`, async (event) => {
888
+ const sharedServiceId = getParam(event, "sharedServiceId");
889
+ if (!sharedServiceId)
890
+ return jsonResponse({ error: "sharedServiceId required" }, 400);
891
+ const body = await readFormOrJsonBody(event);
892
+ const tenantId = typeof body.tenantId === "string" ? body.tenantId : "";
893
+ const appId = typeof body.appId === "string" && body.appId ? body.appId : undefined;
894
+ if (!tenantId)
895
+ return wantsHtmx(event) ? htmxError("tenantId required") : jsonResponse({ error: "tenantId required" }, 400);
896
+ const config = await store.loadConfig();
897
+ const service = config.sharedServiceCatalog.find((candidate) => candidate.id === sharedServiceId && candidate.enabled);
898
+ if (!service)
899
+ return wantsHtmx(event) ? htmxError("Shared service not found or disabled", 404) : jsonResponse({ error: "Shared service not found or disabled" }, 404);
900
+ if (!config.tenants.some((tenant) => tenant.id === tenantId)) {
901
+ return wantsHtmx(event) ? htmxError("Tenant not found", 404) : jsonResponse({ error: "Tenant not found" }, 404);
902
+ }
903
+ if (appId && !config.apps.some((candidate) => candidate.id === appId && candidate.tenantId === tenantId)) {
904
+ return wantsHtmx(event) ? htmxError("App not found for tenant", 404) : jsonResponse({ error: "App not found for tenant" }, 404);
905
+ }
906
+ const existing = config.sharedServiceActivations.find((activation) => activation.sharedServiceId === sharedServiceId
907
+ && activation.tenantId === tenantId
908
+ && activation.appId === appId);
909
+ if (existing) {
910
+ existing.enabled = true;
911
+ existing.activatedAt = new Date().toISOString();
912
+ }
913
+ else {
914
+ config.sharedServiceActivations.push({
915
+ id: uuidv7(),
916
+ tenantId,
917
+ appId,
918
+ sharedServiceId,
919
+ activatedAt: new Date().toISOString(),
920
+ enabled: true
921
+ });
922
+ }
923
+ await store.saveConfig(config);
924
+ if (wantsHtmx(event))
925
+ return htmxReload(`/services?tenantId=${encodeURIComponent(tenantId)}`);
926
+ return jsonResponse({ ok: true });
927
+ });
928
+ app.delete(`${API_BASE}/shared-services/:sharedServiceId/activations`, async (event) => {
929
+ const sharedServiceId = getParam(event, "sharedServiceId");
930
+ if (!sharedServiceId)
931
+ return jsonResponse({ error: "sharedServiceId required" }, 400);
932
+ const url = new URL(event.req.url ?? "", "http://localhost");
933
+ const tenantId = url.searchParams.get("tenantId") ?? "";
934
+ const appId = url.searchParams.get("appId") ?? undefined;
935
+ if (!tenantId)
936
+ return wantsHtmx(event) ? htmxError("tenantId required") : jsonResponse({ error: "tenantId required" }, 400);
937
+ const config = await store.loadConfig();
938
+ config.sharedServiceActivations = config.sharedServiceActivations.filter((activation) => !(activation.sharedServiceId === sharedServiceId && activation.tenantId === tenantId && activation.appId === appId));
939
+ await store.saveConfig(config);
940
+ if (wantsHtmx(event))
941
+ return htmxReload(`/services?tenantId=${encodeURIComponent(tenantId)}`);
942
+ return jsonResponse({ ok: true });
943
+ });
944
+ // Tenants
945
+ app.get(`${API_BASE}/tenants/:tenantId/services`, async (event) => {
946
+ const tenantId = getParam(event, "tenantId");
947
+ if (!tenantId)
948
+ return jsonResponse({ error: "tenantId required" }, 400);
949
+ const config = await store.loadConfig();
950
+ const tenant = config.tenants.find((t) => t.id === tenantId);
951
+ if (!tenant)
952
+ return jsonResponse({ error: "Tenant not found" }, 404);
953
+ return jsonResponse(tenant.services.map((s) => ({
954
+ id: s.id, hostname: s.hostname, serviceId: s.serviceId,
955
+ title: s.title, enabled: s.enabled, createdAt: s.createdAt, lastSeenAt: s.lastSeenAt
956
+ })));
957
+ });
958
+ app.post(`${API_BASE}/tenants/:tenantId/services`, async (event) => {
959
+ const tenantId = getParam(event, "tenantId");
960
+ if (!tenantId)
961
+ return jsonResponse({ error: "tenantId required" }, 400);
962
+ const body = await readJsonBody(event);
963
+ const hostname = body.hostname;
964
+ if (!hostname)
965
+ return jsonResponse({ error: "hostname is required" }, 400);
966
+ const config = await store.loadConfig();
967
+ const tenant = config.tenants.find((t) => t.id === tenantId);
968
+ if (!tenant)
969
+ return jsonResponse({ error: "Tenant not found" }, 404);
970
+ const postedServiceId = typeof body.serviceId === "string" && body.serviceId.length > 0 ? body.serviceId : undefined;
971
+ const duplicate = duplicateTenantService(tenant, hostname, postedServiceId);
972
+ if (duplicate)
973
+ return jsonResponse({ error: duplicate }, 409);
974
+ const apiKey = generateApiKey();
975
+ const service = {
976
+ id: uuidv7(),
977
+ hostname: normalizeHostname(hostname),
978
+ apiKeyHash: hashApiKey(apiKey),
979
+ title: body.title || undefined,
980
+ serviceId: postedServiceId,
981
+ capabilities: Array.isArray(body.capabilities) ? body.capabilities.filter((value) => typeof value === "string") : [],
982
+ description: typeof body.description === "string" && body.description.length > 0 ? body.description : undefined,
983
+ deploymentMode: "self-hosted",
984
+ createdAt: new Date().toISOString(),
985
+ enabled: true
986
+ };
987
+ tenant.services.push(service);
988
+ await store.saveConfig(config);
989
+ return jsonResponse({ id: service.id, hostname: service.hostname, serviceId: service.serviceId, apiKey }, 201);
990
+ });
991
+ app.delete(`${API_BASE}/tenants/:tenantId/services/:serviceId`, async (event) => {
992
+ const tenantId = getParam(event, "tenantId");
993
+ const serviceId = getParam(event, "serviceId");
994
+ if (!tenantId || !serviceId)
995
+ return jsonResponse({ error: "tenantId and serviceId required" }, 400);
996
+ const config = await store.loadConfig();
997
+ const tenant = config.tenants.find((t) => t.id === tenantId);
998
+ if (!tenant)
999
+ return jsonResponse({ error: "Tenant not found" }, 404);
1000
+ const blockers = collectServiceDeleteBlockers(config, tenantId, serviceId);
1001
+ if (blockers.length > 0) {
1002
+ const message = linkedServiceError(blockers);
1003
+ return wantsHtmx(event) ? htmxError(message, 409) : jsonResponse({ error: message, blockers }, 409);
1004
+ }
1005
+ tenant.services = tenant.services.filter((s) => s.id !== serviceId);
1006
+ await store.saveConfig(config);
1007
+ const accept = event.req.headers.get("accept") ?? "";
1008
+ if (accept.includes("text/html") || event.req.headers.get("hx-request")) {
1009
+ return htmlResponse("", 200, "text/html; mode=fragment", {
1010
+ "HX-Location": JSON.stringify({ path: "/services", target: "#bp-main", swap: "innerHTML" })
1011
+ });
1012
+ }
1013
+ return jsonResponse({ ok: true });
1014
+ });
1015
+ // Activate/deactivate platform services for tenant
1016
+ app.get(`${API_BASE}/tenants/:tenantId/services/:serviceId/migrate-to-shared/preview`, async (event) => {
1017
+ const tenantId = getParam(event, "tenantId");
1018
+ const serviceId = getParam(event, "serviceId");
1019
+ if (!tenantId || !serviceId)
1020
+ return jsonResponse({ error: "tenantId and serviceId required" }, 400);
1021
+ const url = new URL(event.req.url ?? "", "http://localhost");
1022
+ const appId = url.searchParams.get("appId") ?? undefined;
1023
+ const sharedServiceId = url.searchParams.get("sharedServiceId") ?? undefined;
1024
+ const config = await store.loadConfig();
1025
+ const preview = previewTenantServiceSharedMigration(config, tenantId, serviceId, { appId, sharedServiceId });
1026
+ return jsonResponse({
1027
+ ok: preview.blockers.length === 0,
1028
+ tenantId,
1029
+ serviceId,
1030
+ sharedServiceId: preview.sharedServiceId,
1031
+ title: preview.service?.title ?? preview.service?.serviceId ?? preview.service?.hostname,
1032
+ pluginId: preview.service?.serviceId,
1033
+ hostname: preview.service?.hostname,
1034
+ references: preview.references,
1035
+ blockers: preview.blockers
1036
+ });
1037
+ });
1038
+ app.post(`${API_BASE}/tenants/:tenantId/services/:serviceId/migrate-to-shared`, async (event) => {
1039
+ const tenantId = getParam(event, "tenantId");
1040
+ const serviceId = getParam(event, "serviceId");
1041
+ if (!tenantId || !serviceId)
1042
+ return jsonResponse({ error: "tenantId and serviceId required" }, 400);
1043
+ const body = await readFormOrJsonBody(event);
1044
+ const appId = typeof body.appId === "string" && body.appId.length > 0 ? body.appId : undefined;
1045
+ const sharedServiceId = typeof body.sharedServiceId === "string" && body.sharedServiceId.length > 0 ? body.sharedServiceId : undefined;
1046
+ const removeTenantService = body.removeTenantService === undefined
1047
+ ? true
1048
+ : body.removeTenantService === true || body.removeTenantService === "true";
1049
+ const config = await store.loadConfig();
1050
+ const preview = previewTenantServiceSharedMigration(config, tenantId, serviceId, { appId, sharedServiceId });
1051
+ if (preview.blockers.length > 0) {
1052
+ const message = preview.blockers.join("\n");
1053
+ return wantsHtmx(event) ? htmxError(message, 409) : jsonResponse({ error: message, blockers: preview.blockers }, 409);
1054
+ }
1055
+ let result;
1056
+ try {
1057
+ result = migrateTenantServiceToShared(config, tenantId, serviceId, { appId, sharedServiceId, removeTenantService });
1058
+ await store.saveConfig(config);
1059
+ }
1060
+ catch (err) {
1061
+ return wantsHtmx(event)
1062
+ ? htmxError(err.message, 409)
1063
+ : jsonResponse({ error: err.message }, 409);
1064
+ }
1065
+ if (wantsHtmx(event))
1066
+ return htmxReload(`/services?tenantId=${encodeURIComponent(tenantId)}`);
1067
+ return jsonResponse({ ok: true, ...result });
1068
+ });
1069
+ app.post(`${API_BASE}/tenants/:tenantId/activate/:platformServiceId`, async (event) => {
1070
+ const tenantId = getParam(event, "tenantId");
1071
+ const psId = getParam(event, "platformServiceId");
1072
+ if (!tenantId || !psId)
1073
+ return jsonResponse({ error: "tenantId and platformServiceId required" }, 400);
1074
+ const config = await store.loadConfig();
1075
+ const tenant = config.tenants.find((t) => t.id === tenantId);
1076
+ if (!tenant)
1077
+ return jsonResponse({ error: "Tenant not found" }, 404);
1078
+ if (!tenant.activatedPlatformServices.includes(psId)) {
1079
+ tenant.activatedPlatformServices.push(psId);
1080
+ await store.saveConfig(config);
1081
+ }
1082
+ return jsonResponse({ ok: true });
1083
+ });
1084
+ app.delete(`${API_BASE}/tenants/:tenantId/activate/:platformServiceId`, async (event) => {
1085
+ const tenantId = getParam(event, "tenantId");
1086
+ const psId = getParam(event, "platformServiceId");
1087
+ if (!tenantId || !psId)
1088
+ return jsonResponse({ error: "tenantId and platformServiceId required" }, 400);
1089
+ const config = await store.loadConfig();
1090
+ const tenant = config.tenants.find((t) => t.id === tenantId);
1091
+ if (!tenant)
1092
+ return jsonResponse({ error: "Tenant not found" }, 404);
1093
+ tenant.activatedPlatformServices = tenant.activatedPlatformServices.filter((id) => id !== psId);
1094
+ await store.saveConfig(config);
1095
+ return jsonResponse({ ok: true });
1096
+ });
1097
+ // Apps
1098
+ app.post(`${API_BASE}/apps/:id/theme-config/bootstrap1`, async (event) => {
1099
+ const id = getParam(event, "id");
1100
+ if (!id)
1101
+ return htmlResponse(`<div class="alert alert-danger">app id required</div>`, 200, "text/html; mode=fragment");
1102
+ const form = await readFormBody(event);
1103
+ const tenantId = form.tenantId ?? "";
1104
+ const config = await store.loadConfig();
1105
+ const appDef = config.apps.find((a) => a.id === id);
1106
+ if (!appDef)
1107
+ return htmlResponse(`<div class="alert alert-danger">App not found</div>`, 200, "text/html; mode=fragment");
1108
+ if (tenantId && appDef.tenantId !== tenantId) {
1109
+ return htmlResponse(`<div class="alert alert-danger">App does not belong to tenant</div>`, 200, "text/html; mode=fragment");
1110
+ }
1111
+ const key = form.resetKey;
1112
+ const nextThemeConfig = {
1113
+ ...appDef.themeConfig,
1114
+ bootstrap: { ...appDef.themeConfig.bootstrap },
1115
+ light: { ...appDef.themeConfig.light },
1116
+ dark: { ...appDef.themeConfig.dark }
1117
+ };
1118
+ if (key) {
1119
+ if (key === "brandName" || key === "mode") {
1120
+ delete nextThemeConfig[key];
1121
+ }
1122
+ else if (key in nextThemeConfig.bootstrap) {
1123
+ delete nextThemeConfig.bootstrap[key];
1124
+ }
1125
+ }
1126
+ else {
1127
+ if (form.brandName !== undefined)
1128
+ nextThemeConfig.brandName = form.brandName;
1129
+ if (form.mode === "light" || form.mode === "dark" || form.mode === "system")
1130
+ nextThemeConfig.mode = form.mode;
1131
+ for (const colorKey of ["primary", "secondary", "success", "info", "warning", "danger"]) {
1132
+ const colorValue = form[colorKey];
1133
+ if (colorValue)
1134
+ nextThemeConfig.bootstrap[colorKey] = colorValue;
1135
+ }
1136
+ }
1137
+ appDef.themeConfig = nextThemeConfig;
1138
+ await store.saveConfig(config);
1139
+ return htmlResponse(`<div id="bp-theme-save-status" class="alert alert-success py-2 mb-0">Saved</div>`, 200, "text/html; mode=fragment", { "HX-Trigger": "bp:theme-changed" });
1140
+ });
1141
+ const getAppOr404 = async (appId) => {
1142
+ const config = await store.loadConfig();
1143
+ const appDef = config.apps.find((a) => a.id === appId);
1144
+ return { config, appDef };
1145
+ };
1146
+ const requireAuthBlock = (event, appDef) => {
1147
+ const withAuth = appDef;
1148
+ const configured = Boolean(withAuth.auth?.serviceId
1149
+ && withAuth.auth.expectedIssuer
1150
+ && withAuth.auth.expectedAudience
1151
+ && withAuth.auth.jwksUri);
1152
+ if (!configured) {
1153
+ const message = "Configure an auth provider for this app before creating roles.";
1154
+ return {
1155
+ response: wantsHtmx(event)
1156
+ ? htmxError(message, 409)
1157
+ : jsonResponse({ error: message }, 409)
1158
+ };
1159
+ }
1160
+ withAuth.auth.roles ??= [];
1161
+ return { auth: withAuth.auth };
1162
+ };
1163
+ app.get(`${API_BASE}/apps/:appId/auth/roles`, async (event) => {
1164
+ const appId = getParam(event, "appId");
1165
+ if (!appId)
1166
+ return jsonResponse({ error: "appId required" }, 400);
1167
+ const { appDef } = await getAppOr404(appId);
1168
+ if (!appDef)
1169
+ return jsonResponse({ error: "App not found" }, 404);
1170
+ const auth = appDef.auth;
1171
+ return jsonResponse((auth?.roles ?? []));
1172
+ });
1173
+ app.post(`${API_BASE}/apps/:appId/auth/roles`, async (event) => {
1174
+ const appId = getParam(event, "appId");
1175
+ if (!appId)
1176
+ return jsonResponse({ error: "appId required" }, 400);
1177
+ const body = await readFormOrJsonBody(event);
1178
+ const title = body.title;
1179
+ if (!title)
1180
+ return wantsHtmx(event) ? htmxError("title required") : jsonResponse({ error: "title required" }, 400);
1181
+ const { config, appDef } = await getAppOr404(appId);
1182
+ if (!appDef)
1183
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1184
+ const authResult = requireAuthBlock(event, appDef);
1185
+ if (authResult.response)
1186
+ return authResult.response;
1187
+ const auth = authResult.auth;
1188
+ const role = {
1189
+ id: body.id || uuidv7(),
1190
+ title,
1191
+ description: body.description,
1192
+ permissions: Array.isArray(body.permissions) ? body.permissions : []
1193
+ };
1194
+ if (auth.roles.some((r) => r.id === role.id)) {
1195
+ return wantsHtmx(event) ? htmxError("role id already exists", 409) : jsonResponse({ error: "role id already exists" }, 409);
1196
+ }
1197
+ auth.roles.push(role);
1198
+ await store.saveConfig(config);
1199
+ if (wantsHtmx(event))
1200
+ return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1201
+ return jsonResponse(role, 201);
1202
+ });
1203
+ app.put(`${API_BASE}/apps/:appId/auth/roles/:roleId`, async (event) => {
1204
+ const appId = getParam(event, "appId");
1205
+ const roleId = getParam(event, "roleId");
1206
+ if (!appId || !roleId)
1207
+ return jsonResponse({ error: "appId + roleId required" }, 400);
1208
+ const body = await readFormOrJsonBody(event);
1209
+ const { config, appDef } = await getAppOr404(appId);
1210
+ if (!appDef)
1211
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1212
+ const authResult = requireAuthBlock(event, appDef);
1213
+ if (authResult.response)
1214
+ return authResult.response;
1215
+ const auth = authResult.auth;
1216
+ const role = auth.roles.find((r) => r.id === roleId);
1217
+ if (!role)
1218
+ return wantsHtmx(event) ? htmxError("Role not found", 404) : jsonResponse({ error: "Role not found" }, 404);
1219
+ if (typeof body.title === "string")
1220
+ role.title = body.title;
1221
+ if (typeof body.description === "string" || body.description === null)
1222
+ role.description = body.description ?? undefined;
1223
+ if (Array.isArray(body.permissions)) {
1224
+ role.permissions = body.permissions;
1225
+ }
1226
+ else if (typeof body.grant === "string" || Array.isArray(body.grant)) {
1227
+ const grants = Array.isArray(body.grant) ? body.grant : [body.grant];
1228
+ const byView = new Map();
1229
+ for (const grant of grants) {
1230
+ const [serviceId, viewId, action] = String(grant).split("|");
1231
+ if (!serviceId || !viewId || !["read", "create", "update", "delete"].includes(action))
1232
+ continue;
1233
+ const key = `${serviceId}::${viewId}`;
1234
+ if (!byView.has(key))
1235
+ byView.set(key, { serviceId, viewId, permissions: [] });
1236
+ byView.get(key).permissions.push(action);
1237
+ }
1238
+ role.permissions = Array.from(byView.values());
1239
+ }
1240
+ await store.saveConfig(config);
1241
+ if (wantsHtmx(event))
1242
+ return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1243
+ return jsonResponse(role);
1244
+ });
1245
+ app.delete(`${API_BASE}/apps/:appId/auth/roles/:roleId`, async (event) => {
1246
+ const appId = getParam(event, "appId");
1247
+ const roleId = getParam(event, "roleId");
1248
+ if (!appId || !roleId)
1249
+ return jsonResponse({ error: "appId + roleId required" }, 400);
1250
+ const { config, appDef } = await getAppOr404(appId);
1251
+ if (!appDef)
1252
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1253
+ const authResult = requireAuthBlock(event, appDef);
1254
+ if (authResult.response)
1255
+ return authResult.response;
1256
+ const auth = authResult.auth;
1257
+ const before = auth.roles.length;
1258
+ auth.roles = auth.roles.filter((r) => r.id !== roleId);
1259
+ if (auth.roles.length === before)
1260
+ return wantsHtmx(event) ? htmxError("Role not found", 404) : jsonResponse({ error: "Role not found" }, 404);
1261
+ await store.saveConfig(config);
1262
+ if (wantsHtmx(event))
1263
+ return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1264
+ return jsonResponse({ ok: true });
1265
+ });
1266
+ // Routes (per app)
1267
+ app.get(`${API_BASE}/apps/:appId/routes`, async (event) => {
1268
+ const appId = getParam(event, "appId");
1269
+ if (!appId)
1270
+ return jsonResponse({ error: "appId required" }, 400);
1271
+ const config = await store.loadConfig();
1272
+ const appDef = config.apps.find((a) => a.id === appId);
1273
+ if (!appDef)
1274
+ return jsonResponse({ error: "App not found" }, 404);
1275
+ return jsonResponse(appDef.routes);
1276
+ });
1277
+ app.post(`${API_BASE}/apps/:appId/routes`, async (event) => {
1278
+ const appId = getParam(event, "appId");
1279
+ if (!appId)
1280
+ return jsonResponse({ error: "appId required" }, 400);
1281
+ const body = await readFormOrJsonBody(event);
1282
+ const config = await store.loadConfig();
1283
+ const appDef = config.apps.find((a) => a.id === appId);
1284
+ if (!appDef)
1285
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1286
+ const parsed = parseRouteCreateBody(body);
1287
+ if (parsed.error || !parsed.route)
1288
+ return validationError(event, parsed.error ?? "Invalid route.");
1289
+ const serviceError = validateRegisteredRouteService(config, appDef, parsed.route.serviceId);
1290
+ if (serviceError)
1291
+ return validationError(event, serviceError);
1292
+ const route = { ...parsed.route, id: uuidv7() };
1293
+ appDef.routes.push(route);
1294
+ addRouteDependencies(appDef, route);
1295
+ await store.saveConfig(config);
1296
+ if (wantsHtmx(event))
1297
+ return htmxReload(`/routes?appId=${encodeURIComponent(appId)}`);
1298
+ return jsonResponse({ ok: true, id: route.id }, 201);
1299
+ });
1300
+ app.put(`${API_BASE}/apps/:appId/routes/:routeId`, async (event) => {
1301
+ const appId = getParam(event, "appId");
1302
+ const routeId = getParam(event, "routeId");
1303
+ if (!appId || !routeId)
1304
+ return jsonResponse({ error: "appId and routeId required" }, 400);
1305
+ const body = await readFormOrJsonBody(event);
1306
+ const config = await store.loadConfig();
1307
+ const appDef = config.apps.find((a) => a.id === appId);
1308
+ if (!appDef)
1309
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1310
+ const route = appDef.routes.find((r) => r.id === routeId);
1311
+ if (!route)
1312
+ return wantsHtmx(event) ? htmxError("Route not found", 404) : jsonResponse({ error: "Route not found" }, 404);
1313
+ if (body.path !== undefined) {
1314
+ const path = trimmedString(body, "path");
1315
+ if (!path)
1316
+ return validationError(event, "Mount path is required.");
1317
+ if (!path.startsWith("/"))
1318
+ return validationError(event, "Mount path must start with /.");
1319
+ route.path = path;
1320
+ }
1321
+ if (body.serviceId !== undefined) {
1322
+ const serviceId = trimmedString(body, "serviceId");
1323
+ if (!serviceId)
1324
+ return validationError(event, "Service is required.");
1325
+ const serviceError = validateRegisteredRouteService(config, appDef, serviceId);
1326
+ if (serviceError)
1327
+ return validationError(event, serviceError);
1328
+ route.serviceId = serviceId;
1329
+ }
1330
+ if (body.viewId !== undefined) {
1331
+ const viewId = trimmedString(body, "viewId");
1332
+ if (!viewId)
1333
+ return validationError(event, "View is required.");
1334
+ route.viewId = viewId;
1335
+ }
1336
+ const manifestView = getManifestCache().get(route.serviceId)?.viewIndex[route.viewId];
1337
+ if (manifestView) {
1338
+ route.methods = routeMethodsFromManifest(manifestView.methods);
1339
+ route.targetPath = manifestView.path;
1340
+ if (manifestView.renderable === false) {
1341
+ route.path = manifestView.path;
1342
+ route.title = manifestView.viewId;
1343
+ delete route.query;
1344
+ }
1345
+ }
1346
+ if (body.targetPath !== undefined) {
1347
+ const targetPath = trimmedString(body, "targetPath");
1348
+ if (targetPath)
1349
+ route.targetPath = targetPath;
1350
+ else
1351
+ delete route.targetPath;
1352
+ }
1353
+ if (body.query !== undefined) {
1354
+ const query = trimmedString(body, "query");
1355
+ if (query)
1356
+ route.query = query.replace(/^\?+/, "");
1357
+ else
1358
+ delete route.query;
1359
+ }
1360
+ if (body.title !== undefined) {
1361
+ const title = trimmedString(body, "title");
1362
+ if (!title)
1363
+ return validationError(event, "Display title is required.");
1364
+ route.title = title;
1365
+ }
1366
+ if (body.enabled !== undefined)
1367
+ route.enabled = body.enabled === true || body.enabled === "true" || body.enabled === "on";
1368
+ await store.saveConfig(config);
1369
+ if (wantsHtmx(event))
1370
+ return htmxReload(`/routes?appId=${encodeURIComponent(appId)}`);
1371
+ return jsonResponse({ ok: true });
1372
+ });
1373
+ app.delete(`${API_BASE}/apps/:appId/routes/:routeId`, async (event) => {
1374
+ const appId = getParam(event, "appId");
1375
+ const routeId = getParam(event, "routeId");
1376
+ if (!appId || !routeId)
1377
+ return jsonResponse({ error: "appId and routeId required" }, 400);
1378
+ const config = await store.loadConfig();
1379
+ const appDef = config.apps.find((a) => a.id === appId);
1380
+ if (!appDef)
1381
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1382
+ const route = appDef.routes.find((r) => r.id === routeId);
1383
+ if (!route)
1384
+ return wantsHtmx(event) ? htmxAlert("Route not found.") : jsonResponse({ error: "Route not found" }, 404);
1385
+ const menuReferenceCount = countMenuRouteReferences(appDef.menu, routeId);
1386
+ if (menuReferenceCount > 0) {
1387
+ const message = `Cannot delete route "${route.title ?? route.path}" because ${menuReferenceCount} menu item${menuReferenceCount === 1 ? "" : "s"} reference it. Remove the menu reference first.`;
1388
+ return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
1389
+ }
1390
+ appDef.routes = appDef.routes.filter((r) => r.id !== routeId);
1391
+ await store.saveConfig(config);
1392
+ if (wantsHtmx(event))
1393
+ return htmxReload(`/routes?appId=${encodeURIComponent(appId)}`);
1394
+ return jsonResponse({ ok: true });
1395
+ });
1396
+ // Menu (per app)
1397
+ app.get(`${API_BASE}/apps/:appId/menu`, async (event) => {
1398
+ const appId = getParam(event, "appId");
1399
+ if (!appId)
1400
+ return jsonResponse({ error: "appId required" }, 400);
1401
+ const config = await store.loadConfig();
1402
+ const appDef = config.apps.find((a) => a.id === appId);
1403
+ if (!appDef)
1404
+ return jsonResponse({ error: "App not found" }, 404);
1405
+ return jsonResponse((appDef.menu ?? []));
1406
+ });
1407
+ app.put(`${API_BASE}/apps/:appId/menu`, async (event) => {
1408
+ const appId = getParam(event, "appId");
1409
+ if (!appId)
1410
+ return jsonResponse({ error: "appId required" }, 400);
1411
+ const body = await readJsonBody(event);
1412
+ const items = Array.isArray(body.items) ? body.items : [];
1413
+ const config = await store.loadConfig();
1414
+ const appDef = config.apps.find((a) => a.id === appId);
1415
+ if (!appDef)
1416
+ return jsonResponse({ error: "App not found" }, 404);
1417
+ appDef.menu = items.map((m) => ({
1418
+ id: m.id ?? uuidv7(),
1419
+ type: m.type ?? "link",
1420
+ title: m.title,
1421
+ icon: m.icon,
1422
+ routeId: m.routeId,
1423
+ href: m.href,
1424
+ enabled: m.enabled !== false,
1425
+ children: m.children ?? []
1426
+ }));
1427
+ await store.saveConfig(config);
1428
+ return jsonResponse({ ok: true });
1429
+ });
1430
+ // Full config (read-only)
1431
+ app.get(`${API_BASE}/config`, async () => {
1432
+ const config = await store.loadConfig();
1433
+ return jsonResponse(config);
1434
+ });
1435
+ // HTMX wizard: verify service
1436
+ // Browser-mediated: the admin UI fetches the manifest from the service
1437
+ // (CM cannot reach services) and POSTs the parsed payload here.
1438
+ app.post(`${API_BASE}/wizard/verify`, async (event) => {
1439
+ const adminApiBase = adminApiBaseFromEvent(event);
1440
+ const form = await readFormBody(event);
1441
+ const tenantId = form.tenantId ?? "";
1442
+ const hostname = normalizeHostname(form.hostname);
1443
+ if (!tenantId || !hostname) {
1444
+ return htmlResponse(renderWizardStep1(await store.loadConfig(), "Tenant and hostname are required.", undefined, adminApiBase), 200, "text/html; mode=fragment");
1445
+ }
1446
+ let manifest;
1447
+ try {
1448
+ if (!form.manifest)
1449
+ throw new Error("Manifest was not provided by the browser.");
1450
+ manifest = parseWizardManifest(form.manifest);
1451
+ }
1452
+ catch (error) {
1453
+ const config = await store.loadConfig();
1454
+ const message = error instanceof Error ? error.message : "Manifest field is not valid JSON.";
1455
+ return htmlResponse(renderWizardStep1(config, message, { tenantId, hostname }, adminApiBase), 200, "text/html; mode=fragment");
1456
+ }
1457
+ if (!manifest.pluginId || !manifest.title) {
1458
+ const config = await store.loadConfig();
1459
+ return htmlResponse(renderWizardStep1(config, "Not a valid BetterPortal service manifest.", { tenantId, hostname }, adminApiBase), 200, "text/html; mode=fragment");
1460
+ }
1461
+ let schema = form.schema ?? "";
1462
+ if (!schema) {
1463
+ schema = JSON.stringify({ manifest, routes: [] });
1464
+ }
1465
+ return htmlResponse(renderWizardStep2({
1466
+ tenantId, hostname,
1467
+ pluginId: manifest.pluginId,
1468
+ version: manifest.version ?? "unknown",
1469
+ viewCount: Array.isArray(manifest.views) ? manifest.views.length : 0,
1470
+ title: manifest.title,
1471
+ schema,
1472
+ adminApiBase
1473
+ }), 200, "text/html; mode=fragment");
1474
+ });
1475
+ // HTMX wizard: register service
1476
+ app.post(`${API_BASE}/wizard/register`, async (event) => {
1477
+ const form = await readFormBody(event);
1478
+ const tenantId = form.tenantId ?? "";
1479
+ const hostname = (form.hostname ?? "").replace(/\/+$/, "");
1480
+ const title = form.title || hostname;
1481
+ if (!tenantId || !hostname) {
1482
+ return htmlResponse(`<div class="alert alert-danger">Missing tenantId or hostname</div>`, 200, "text/html; mode=fragment");
1483
+ }
1484
+ const config = await store.loadConfig();
1485
+ const tenant = config.tenants.find((t) => t.id === tenantId);
1486
+ if (!tenant) {
1487
+ return htmlResponse(`<div class="alert alert-danger">Tenant not found</div>`, 200, "text/html; mode=fragment");
1488
+ }
1489
+ // Schema is fetched by the browser from the service (CM cannot reach services)
1490
+ // and posted to us as a JSON string in form.schema.
1491
+ let serviceId;
1492
+ let capabilities = [];
1493
+ let serviceRoutes = [];
1494
+ let viewMeta = new Map();
1495
+ if (form.schema) {
1496
+ try {
1497
+ const schema = JSON.parse(form.schema);
1498
+ serviceId = schema.manifest?.pluginId;
1499
+ capabilities = Array.isArray(schema.manifest?.capabilities) ? schema.manifest.capabilities.filter((value) => typeof value === "string") : [];
1500
+ serviceRoutes = schema.routes ?? [];
1501
+ viewMeta = new Map((schema.manifest?.views ?? []).map((v) => [v.viewId, v.title]));
1502
+ }
1503
+ catch { /* malformed schema - register without routes */ }
1504
+ }
1505
+ const duplicate = duplicateTenantService(tenant, hostname, serviceId);
1506
+ if (duplicate) {
1507
+ return htmlResponse(`<div class="alert alert-warning">${escapeHtml(duplicate)}</div>`, 200, "text/html; mode=fragment");
1508
+ }
1509
+ const newServiceId = uuidv7();
1510
+ const service = {
1511
+ id: newServiceId,
1512
+ hostname,
1513
+ apiKeyHash: "",
1514
+ title,
1515
+ serviceId,
1516
+ capabilities,
1517
+ deploymentMode: "self-hosted",
1518
+ createdAt: new Date().toISOString(),
1519
+ enabled: true
1520
+ };
1521
+ tenant.services.push(service);
1522
+ // Auto-add routes + menu group to each app in tenant
1523
+ if (serviceRoutes.length > 0) {
1524
+ const tenantApps = config.apps.filter((a) => a.tenantId === tenantId);
1525
+ for (const appDef of tenantApps) {
1526
+ const groupId = uuidv7();
1527
+ const groupChildren = [];
1528
+ const existingPaths = new Set(appDef.routes.map((r) => r.path));
1529
+ for (const r of serviceRoutes) {
1530
+ // Default mounting path is the service's own path; skip collisions
1531
+ const mountPath = existingPaths.has(r.path) ? `/${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}${r.path}` : r.path;
1532
+ if (existingPaths.has(mountPath))
1533
+ continue;
1534
+ const routeId = uuidv7();
1535
+ const routeTitle = viewMeta.get(r.viewId) ?? r.viewId;
1536
+ appDef.routes.push({
1537
+ id: routeId,
1538
+ path: mountPath,
1539
+ serviceId: newServiceId,
1540
+ viewId: r.viewId,
1541
+ targetPath: r.path,
1542
+ title: routeTitle,
1543
+ enabled: true,
1544
+ methods: ["GET"]
1545
+ });
1546
+ existingPaths.add(mountPath);
1547
+ groupChildren.push({
1548
+ id: uuidv7(),
1549
+ type: "link",
1550
+ title: routeTitle,
1551
+ routeId,
1552
+ enabled: true
1553
+ });
1554
+ }
1555
+ if (groupChildren.length > 0) {
1556
+ const menu = (appDef.menu ?? []);
1557
+ menu.push({
1558
+ id: groupId,
1559
+ type: "group",
1560
+ title,
1561
+ enabled: true,
1562
+ children: groupChildren
1563
+ });
1564
+ appDef.menu = menu;
1565
+ }
1566
+ }
1567
+ }
1568
+ await store.saveConfig(config);
1569
+ const adminApiBase = adminApiBaseFromEvent(event);
1570
+ return htmlResponse(renderWizardStep3({
1571
+ apiKey: "",
1572
+ deploymentMode: service.deploymentMode,
1573
+ tenantId,
1574
+ serviceInstanceId: newServiceId,
1575
+ serviceUrl: hostname,
1576
+ title,
1577
+ adminApiBase
1578
+ }), 200, "text/html; mode=fragment");
1579
+ });
1580
+ app.post(`${API_BASE}/wizard/cleanup-provisional-service`, async (event) => {
1581
+ const body = await readJsonBody(event);
1582
+ const tenantId = typeof body.tenantId === "string" ? body.tenantId : "";
1583
+ const serviceInstanceId = typeof body.serviceInstanceId === "string" ? body.serviceInstanceId : "";
1584
+ if (!tenantId || !serviceInstanceId) {
1585
+ return jsonResponse({ error: "tenantId and serviceInstanceId are required" }, 400);
1586
+ }
1587
+ const config = await store.loadConfig();
1588
+ const result = cleanupProvisionalTenantService(config, tenantId, serviceInstanceId);
1589
+ if (result.error)
1590
+ return jsonResponse({ error: result.error }, 409);
1591
+ if (result.removed)
1592
+ await store.saveConfig(config);
1593
+ return jsonResponse({ ok: true, removed: result.removed });
1594
+ });
1595
+ app.get(`${API_BASE}/wizard/step1`, async (event) => {
1596
+ const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
1597
+ const tenantId = url.searchParams.get("tenantId") ?? undefined;
1598
+ const hostname = url.searchParams.get("hostname") ?? undefined;
1599
+ const config = await store.loadConfig();
1600
+ return htmlResponse(renderWizardStep1(config, undefined, { tenantId, hostname }, adminApiBaseFromEvent(event)), 200, "text/html; mode=fragment");
1601
+ });
1602
+ // HTMX configure: load form
1603
+ app.get(`${API_BASE}/configure`, async (event) => {
1604
+ const url = new URL(event.req.url ?? "", `http://${event.req.headers.get("host") ?? "localhost"}`);
1605
+ const serviceInstanceId = url.searchParams.get("serviceInstanceId") ?? undefined;
1606
+ const hostname = (url.searchParams.get("hostname") ?? "").replace(/\/+$/, "");
1607
+ const tenantId = url.searchParams.get("tenantId") ?? "";
1608
+ const appId = url.searchParams.get("appId") ?? "";
1609
+ const serviceTitle = url.searchParams.get("title") ?? "Service";
1610
+ const adminApiBase = url.searchParams.get("adminApiBase") || new URL(API_BASE, url).toString();
1611
+ if (!hostname || !tenantId) {
1612
+ return htmlResponse(`<div class="alert alert-danger">Missing hostname or tenantId</div>`, 200, "text/html; mode=fragment");
1613
+ }
1614
+ const config = await store.loadConfig();
1615
+ const service = findRegisteredService(config, tenantId, hostname, serviceInstanceId);
1616
+ if (!service?.serviceId) {
1617
+ return htmlResponse(`<div class="alert alert-danger">Service is not linked to a BetterPortal service id yet. Re-register or sync the service first.</div>`, 200, "text/html; mode=fragment");
1618
+ }
1619
+ const tenantApps = config.apps
1620
+ .filter((a) => a.tenantId === tenantId)
1621
+ .map((a) => ({
1622
+ id: a.id,
1623
+ title: a.title,
1624
+ routes: a.routes
1625
+ .filter((route) => route.enabled)
1626
+ .map((route) => ({ path: route.path, title: route.title || route.path }))
1627
+ }));
1628
+ return htmlResponse(renderConfigClientShell({
1629
+ hostname,
1630
+ tenantId,
1631
+ appId,
1632
+ serviceInstanceId,
1633
+ serviceId: service.serviceId,
1634
+ serviceTitle,
1635
+ adminApiBase,
1636
+ tenantApps
1637
+ }), 200, "text/html; mode=fragment");
1638
+ });
1639
+ // HTMX configure: save
1640
+ app.post(`${API_BASE}/config-ticket`, async (event) => {
1641
+ const body = await readJsonBody(event);
1642
+ const tenantId = typeof body.tenantId === "string" ? body.tenantId : "";
1643
+ const serviceInstanceId = typeof body.serviceInstanceId === "string" ? body.serviceInstanceId : "";
1644
+ const hostname = typeof body.hostname === "string" ? body.hostname.replace(/\/+$/, "") : "";
1645
+ const serviceId = typeof body.serviceId === "string" ? body.serviceId : "";
1646
+ const actions = Array.isArray(body.actions)
1647
+ ? body.actions.filter((action) => action === "config.read" || action === "config.write")
1648
+ : [];
1649
+ if (!tenantId || (!serviceInstanceId && !hostname) || !serviceId || actions.length === 0) {
1650
+ return jsonResponse({ error: "tenantId, serviceInstanceId or hostname, serviceId, and actions are required" }, 400);
1651
+ }
1652
+ const config = await store.loadConfig();
1653
+ const service = findRegisteredService(config, tenantId, hostname, serviceInstanceId);
1654
+ if (!service || service.serviceId !== serviceId) {
1655
+ return jsonResponse({ error: "Service is not registered for this tenant/hostname/service id" }, 403);
1656
+ }
1657
+ return jsonResponse({
1658
+ token: signConfigTicket(cpState, { tenantId, serviceId, actions }),
1659
+ expiresInSeconds: CONFIG_TICKET_TTL_SECONDS
1660
+ });
1661
+ });
1662
+ app.post(`${API_BASE}/configure-save`, async () => {
1663
+ return htmlResponse(`<div class="alert alert-danger">Service config saves must be sent directly from the browser to the service using a BetterPortal config ticket.</div>`, 200, "text/html; mode=fragment");
1664
+ });
1665
+ }
1666
+ // HTML fragment renderers
1667
+ function renderWizardStep1(config, error, prefill, adminApiBase = API_BASE) {
1668
+ const selectedTenant = prefill?.tenantId
1669
+ ? config.tenants.find((t) => t.id === prefill.tenantId)
1670
+ : undefined;
1671
+ const tenantOptions = config.tenants
1672
+ .map((t) => `<option value="${escapeHtml(t.id)}"${prefill?.tenantId === t.id ? " selected" : ""}>${escapeHtml(t.title)} (${escapeHtml(t.id)})</option>`)
1673
+ .join("");
1674
+ return `<div id="bp-wizard-step">
1675
+ <div class="mb-3 text-secondary small">Step 1 of 3</div>
1676
+ <form data-bp-wizard-verify-form="" action="${escapeHtml(adminApiBase)}/wizard/verify" method="post">
1677
+ ${selectedTenant ? `
1678
+ <input type="hidden" name="tenantId" value="${escapeHtml(selectedTenant.id)}" />
1679
+ <div class="mb-3">
1680
+ <label class="form-label">Tenant</label>
1681
+ <div class="form-control bg-body-tertiary">${escapeHtml(selectedTenant.title)} <span class="font-monospace small text-secondary">${escapeHtml(selectedTenant.id)}</span></div>
1682
+ </div>
1683
+ ` : `
1684
+ <div class="mb-3">
1685
+ <label class="form-label">Tenant</label>
1686
+ <select class="form-select" name="tenantId" required>
1687
+ <option value="">Select tenant...</option>
1688
+ ${tenantOptions}
1689
+ </select>
1690
+ <div class="form-text">Which tenant owns this service?</div>
1691
+ </div>
1692
+ `}
1693
+ <input type="hidden" name="manifest" />
1694
+ <input type="hidden" name="schema" />
1695
+ <div class="mb-3">
1696
+ <label class="form-label">Service URL</label>
1697
+ <input type="url" class="form-control" name="hostname" value="${escapeHtml(prefill?.hostname ?? "")}" placeholder="http://localhost:3200" required />
1698
+ <div class="form-text">We'll verify it's a valid BetterPortal service.</div>
1699
+ </div>
1700
+ ${error ? `<div class="alert alert-danger">${escapeHtml(error)}</div>` : ""}
1701
+ <button type="submit" class="btn btn-primary w-100">
1702
+ <span id="bp-wizard-spinner" class="spinner-border spinner-border-sm htmx-indicator" role="status"></span>
1703
+ Verify Service -&gt;
1704
+ </button>
1705
+ </form>
1706
+ <script>
1707
+ (() => {
1708
+ const form = document.querySelector("[data-bp-wizard-verify-form]");
1709
+ if (!form || form.dataset.bound === "true") return;
1710
+ form.dataset.bound = "true";
1711
+ const step = document.getElementById("bp-wizard-step");
1712
+ const submit = form.querySelector("button[type='submit']");
1713
+ const setError = (message) => {
1714
+ const existing = form.querySelector("[data-bp-wizard-error]");
1715
+ if (existing) existing.remove();
1716
+ form.insertAdjacentHTML("beforeend", '<div class="alert alert-danger mt-3" data-bp-wizard-error>' + String(message).replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch]) + '</div>');
1717
+ };
1718
+ form.addEventListener("submit", async (event) => {
1719
+ event.preventDefault();
1720
+ const hostname = String(new FormData(form).get("hostname") || "").replace(/\\/+$/, "");
1721
+ if (!hostname) return setError("Service URL is required.");
1722
+ if (submit) submit.disabled = true;
1723
+ try {
1724
+ const manifestResponse = await fetch(hostname + "/.well-known/bp/manifest", { headers: { Accept: "application/json" }, cache: "no-store" });
1725
+ if (!manifestResponse.ok) throw new Error("Manifest HTTP " + manifestResponse.status);
1726
+ const manifest = await manifestResponse.json();
1727
+ form.elements.manifest.value = JSON.stringify(manifest);
1728
+ try {
1729
+ const schemaResponse = await fetch(hostname + "/.well-known/bp/schema.json", { headers: { Accept: "application/json" }, cache: "no-store" });
1730
+ form.elements.schema.value = schemaResponse.ok
1731
+ ? JSON.stringify(await schemaResponse.json())
1732
+ : JSON.stringify({ manifest, routes: [] });
1733
+ } catch {
1734
+ form.elements.schema.value = JSON.stringify({ manifest, routes: [] });
1735
+ }
1736
+ const response = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "text/html" } });
1737
+ const html = await response.text();
1738
+ if (!response.ok) throw new Error("Verify HTTP " + response.status);
1739
+ if (step) step.outerHTML = html;
1740
+ } catch (error) {
1741
+ setError(error instanceof Error ? error.message : String(error));
1742
+ } finally {
1743
+ if (submit) submit.disabled = false;
1744
+ }
1745
+ });
1746
+ })();
1747
+ </script>
1748
+ </div>`;
1749
+ }
1750
+ function renderWizardStep2(d) {
1751
+ return `<div id="bp-wizard-step">
1752
+ <div class="mb-3 text-secondary small">Step 2 of 3</div>
1753
+ <div class="alert alert-success mb-3">
1754
+ <h6 class="alert-heading">Service Verified</h6>
1755
+ <div class="small mb-1"><strong>Plugin ID:</strong> <code>${escapeHtml(d.pluginId)}</code></div>
1756
+ <div class="small mb-1"><strong>Version:</strong> ${escapeHtml(d.version)}</div>
1757
+ <div class="small mb-0"><strong>Views:</strong> ${d.viewCount}</div>
1758
+ </div>
1759
+ <form data-bp-wizard-register-form="" action="${escapeHtml(d.adminApiBase)}/wizard/register" method="post">
1760
+ <input type="hidden" name="tenantId" value="${escapeHtml(d.tenantId)}" />
1761
+ <input type="hidden" name="hostname" value="${escapeHtml(d.hostname)}" />
1762
+ <input type="hidden" name="schema" value="${escapeHtml(d.schema)}" />
1763
+ <div class="mb-3">
1764
+ <label class="form-label">Display Name</label>
1765
+ <input type="text" class="form-control" name="title" value="${escapeHtml(d.title)}" required />
1766
+ <div class="form-text">Auto-filled from manifest. Edit if needed.</div>
1767
+ </div>
1768
+ <div class="d-flex gap-2">
1769
+ <button type="button" class="btn btn-outline-secondary" hx-get="/.well-known/bp/admin/wizard/step1" hx-target="#bp-wizard-step" hx-swap="outerHTML"><- Back</button>
1770
+ <button type="submit" class="btn btn-primary flex-grow-1">Register Service</button>
1771
+ </div>
1772
+ </form>
1773
+ <script>
1774
+ (() => {
1775
+ const form = document.querySelector("[data-bp-wizard-register-form]");
1776
+ if (!form || form.dataset.bound === "true") return;
1777
+ form.dataset.bound = "true";
1778
+ const step = document.getElementById("bp-wizard-step");
1779
+ const submit = form.querySelector("button[type='submit']");
1780
+ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (ch) => ({
1781
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
1782
+ })[ch]);
1783
+ form.addEventListener("submit", async (event) => {
1784
+ event.preventDefault();
1785
+ if (submit) submit.disabled = true;
1786
+ try {
1787
+ const response = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "text/html" } });
1788
+ const html = await response.text();
1789
+ if (!response.ok) throw new Error("Register HTTP " + response.status);
1790
+ if (step) step.outerHTML = html;
1791
+ } catch (error) {
1792
+ form.insertAdjacentHTML("beforeend", '<div class="alert alert-danger mt-3">' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>');
1793
+ } finally {
1794
+ if (submit) submit.disabled = false;
1795
+ }
1796
+ });
1797
+ const back = form.querySelector("[hx-get]");
1798
+ if (back) {
1799
+ back.removeAttribute("hx-get");
1800
+ back.removeAttribute("hx-target");
1801
+ back.removeAttribute("hx-swap");
1802
+ back.addEventListener("click", async (event) => {
1803
+ event.preventDefault();
1804
+ event.stopImmediatePropagation();
1805
+ const url = ${JSON.stringify(d.adminApiBase)} + "/wizard/step1?tenantId=" + encodeURIComponent(${JSON.stringify(d.tenantId)}) + "&hostname=" + encodeURIComponent(${JSON.stringify(d.hostname)});
1806
+ const response = await fetch(url, { headers: { Accept: "text/html" } });
1807
+ const html = await response.text();
1808
+ if (step) step.outerHTML = html;
1809
+ });
1810
+ }
1811
+ })();
1812
+ </script>
1813
+ </div>`;
1814
+ }
1815
+ function renderWizardStep3(d) {
1816
+ if (d.tenantId && d.serviceInstanceId && d.serviceUrl) {
1817
+ return `<div id="bp-wizard-step">
1818
+ <div class="mb-3 text-secondary small">Step 3 of 3</div>
1819
+ <div class="alert alert-secondary" id="bp-install-status">
1820
+ <h6 class="alert-heading">Installing Service</h6>
1821
+ <p class="mb-0 small">Provisioning ${escapeHtml(d.title)} with the control plane...</p>
1822
+ </div>
1823
+ <button class="btn btn-primary w-100" id="bp-install-done" disabled data-bs-dismiss="offcanvas">Done</button>
1824
+ <script>
1825
+ (() => {
1826
+ const status = document.getElementById("bp-install-status");
1827
+ const done = document.getElementById("bp-install-done");
1828
+ const setStatus = (kind, heading, message) => {
1829
+ if (!status) return;
1830
+ status.className = "alert alert-" + kind;
1831
+ status.innerHTML = '<h6 class="alert-heading">' + heading + '</h6><p class="mb-0 small">' + message + '</p>';
1832
+ };
1833
+ const postJson = async (url, body) => {
1834
+ const response = await fetch(url, {
1835
+ method: "POST",
1836
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
1837
+ body: JSON.stringify(body)
1838
+ });
1839
+ const data = await response.json().catch(() => ({}));
1840
+ if (!response.ok) throw new Error(data.error || ("HTTP " + response.status));
1841
+ return data;
1842
+ };
1843
+ (async () => {
1844
+ try {
1845
+ const install = await postJson(${JSON.stringify((d.adminApiBase ?? API_BASE).replace(/\/+$/, ""))} + "/services/begin-install", {
1846
+ serviceUrl: ${JSON.stringify(d.serviceUrl)},
1847
+ tenantId: ${JSON.stringify(d.tenantId)},
1848
+ instanceId: ${JSON.stringify(d.serviceInstanceId)}
1849
+ });
1850
+ await postJson(${JSON.stringify(d.serviceUrl)} + "/.well-known/bp/install", {
1851
+ setupToken: install.setupToken,
1852
+ cpUrl: install.cpUrl
1853
+ });
1854
+ setStatus("success", "Service Installed", "The service is provisioned and will sync from the control plane.");
1855
+ if (done) done.disabled = false;
1856
+ } catch (error) {
1857
+ try {
1858
+ await postJson(${JSON.stringify((d.adminApiBase ?? API_BASE).replace(/\/+$/, ""))} + "/wizard/cleanup-provisional-service", {
1859
+ tenantId: ${JSON.stringify(d.tenantId)},
1860
+ serviceInstanceId: ${JSON.stringify(d.serviceInstanceId)}
1861
+ });
1862
+ } catch { /* cleanup best effort */ }
1863
+ setStatus("danger", "Install Failed", error instanceof Error ? error.message : String(error));
1864
+ if (done) {
1865
+ done.disabled = false;
1866
+ done.textContent = "Close";
1867
+ }
1868
+ }
1869
+ })();
1870
+ })();
1871
+ </script>
1872
+ </div>`;
1873
+ }
1874
+ if (!d.apiKey || d.deploymentMode !== "self-hosted") {
1875
+ return `<div id="bp-wizard-step">
1876
+ <div class="mb-3 text-secondary small">Step 3 of 3</div>
1877
+ <div class="alert alert-success">
1878
+ <h6 class="alert-heading">Service Registered</h6>
1879
+ <p class="mb-0 small">${escapeHtml(d.title)} is now available for this tenant.</p>
1880
+ </div>
1881
+ <button class="btn btn-primary w-100" data-bs-dismiss="offcanvas">Done</button>
1882
+ </div>`;
1883
+ }
1884
+ return `<div id="bp-wizard-step">
1885
+ <div class="mb-3 text-secondary small">Step 3 of 3</div>
1886
+ <div class="alert alert-success">
1887
+ <h6 class="alert-heading">Service Registered</h6>
1888
+ <p class="mb-2 small">The service was registered. Use the install flow to provision credentials.</p>
1889
+ <div class="input-group mb-3">
1890
+ <input type="text" class="form-control font-monospace small" value="${escapeHtml(d.title)}" readonly />
1891
+ <button class="btn btn-outline-secondary" type="button"
1892
+ onclick="navigator.clipboard.writeText(this.previousElementSibling.value); this.textContent='Copied!'; setTimeout(()=>{this.textContent='Copy';},1500)">Copy</button>
1893
+ </div>
1894
+ <p class="small text-secondary mb-2">Install credentials are provisioned by the control-plane install flow.</p>
1895
+ <pre class="small bg-body-tertiary border rounded p-2 mb-0"><code>betterportal:
1896
+ status: registered
1897
+ serviceUrl: ${escapeHtml(d.title)}</code></pre>
1898
+ </div>
1899
+ <button class="btn btn-primary w-100" data-bs-dismiss="offcanvas">Done</button>
1900
+ </div>`;
1901
+ }
1902
+ function renderConfigForm(d) {
1903
+ const appSelector = d.needsApp
1904
+ ? `<div class="mb-3">
1905
+ <label class="form-label">App</label>
1906
+ <select class="form-select" name="appId" required
1907
+ hx-get="/.well-known/bp/admin/configure"
1908
+ hx-trigger="change"
1909
+ hx-target="#bp-config-edit-form"
1910
+ hx-swap="innerHTML"
1911
+ hx-include="closest form"
1912
+ hx-vals='{"hostname":"${escapeHtml(d.hostname)}","tenantId":"${escapeHtml(d.tenantId)}","title":"${escapeHtml(d.serviceTitle)}"}'>
1913
+ <option value="">Select app...</option>
1914
+ ${d.tenantApps.map((a) => `<option value="${escapeHtml(a.id)}"${a.id === d.appId ? " selected" : ""}>${escapeHtml(a.title)}</option>`).join("")}
1915
+ </select>
1916
+ </div>`
1917
+ : "";
1918
+ if (d.needsApp && !d.appId) {
1919
+ return `<div class="small text-secondary mb-3">${escapeHtml(d.serviceTitle)} - <span class="font-monospace">${escapeHtml(d.hostname)}</span></div>
1920
+ ${appSelector}
1921
+ <div class="alert alert-info">Select an app to load its config</div>`;
1922
+ }
1923
+ const fieldsHtml = d.fields.map((f) => {
1924
+ const val = d.values[f.key] ?? "";
1925
+ const placeholder = f.visibility === "secret" && val === "__redacted__" ? "(unchanged)" : "";
1926
+ const renderedVal = f.visibility === "secret" && val === "__redacted__" ? "" : String(val);
1927
+ return `<div class="mb-3">
1928
+ <label class="form-label">${escapeHtml(f.title)}</label>
1929
+ ${renderConfigControl(f, renderedVal, placeholder, false)}
1930
+ ${f.description ? `<div class="form-text">${escapeHtml(f.description)}</div>` : ""}
1931
+ </div>`;
1932
+ }).join("");
1933
+ return `<div class="small text-secondary mb-3">${escapeHtml(d.serviceTitle)} - <span class="font-monospace">${escapeHtml(d.hostname)}</span></div>
1934
+ ${appSelector}
1935
+ <form hx-post="/.well-known/bp/admin/configure-save" hx-target="#bp-config-save-status" hx-swap="innerHTML"
1936
+ hx-on::after-request="if(event.detail.successful) setTimeout(()=>bootstrap.Offcanvas.getInstance(document.getElementById('bp-config-edit-panel'))?.hide(), 800)">
1937
+ <input type="hidden" name="hostname" value="${escapeHtml(d.hostname)}" />
1938
+ <input type="hidden" name="tenantId" value="${escapeHtml(d.tenantId)}" />
1939
+ <input type="hidden" name="appId" value="${escapeHtml(d.appId)}" />
1940
+ <input type="hidden" name="serviceTitle" value="${escapeHtml(d.serviceTitle)}" />
1941
+ ${fieldsHtml}
1942
+ <div id="bp-config-save-status"></div>
1943
+ <button type="submit" class="btn btn-primary w-100">Save Configuration</button>
1944
+ </form>`;
1945
+ }
1946
+ function renderConfigControl(field, value, placeholder, disabled) {
1947
+ const ui = field.ui ?? {};
1948
+ const control = field.visibility === "secret" ? "password" : ui.control ?? "text";
1949
+ const name = escapeHtml(field.key);
1950
+ const disabledAttr = disabled ? " disabled" : "";
1951
+ const placeholderAttr = escapeHtml(placeholder || ui.placeholder || "");
1952
+ const attrs = [
1953
+ ui.min !== undefined ? `min="${escapeHtml(String(ui.min))}"` : "",
1954
+ ui.max !== undefined ? `max="${escapeHtml(String(ui.max))}"` : "",
1955
+ ui.step !== undefined ? `step="${escapeHtml(String(ui.step))}"` : ""
1956
+ ].filter(Boolean).join(" ");
1957
+ if (control === "textarea") {
1958
+ return `<textarea class="form-control" name="${name}" rows="${escapeHtml(String(ui.rows ?? 3))}" placeholder="${placeholderAttr}"${disabledAttr}>${escapeHtml(value)}</textarea>`;
1959
+ }
1960
+ if (control === "select" || control === "multiselect") {
1961
+ const selectedValues = new Set(control === "multiselect" ? value.split(",").map((entry) => entry.trim()) : [value]);
1962
+ const options = (ui.options ?? []).map((option) => `<option value="${escapeHtml(option.value)}"${selectedValues.has(option.value) ? " selected" : ""}>${escapeHtml(option.label)}</option>`).join("");
1963
+ return `<select class="form-select" name="${name}"${control === "multiselect" ? " multiple" : ""}${disabledAttr}>${options}</select>`;
1964
+ }
1965
+ if (control === "checkbox") {
1966
+ const checked = value === "true" || value === "1" || value === "on";
1967
+ return `<input class="form-check-input" type="checkbox" name="${name}" value="true"${checked ? " checked" : ""}${disabledAttr} />`;
1968
+ }
1969
+ const inputType = ["number", "color", "date", "time", "datetime-local", "url", "email", "password"].includes(control) ? control : "text";
1970
+ return `<input class="form-control" type="${inputType}" name="${name}" value="${escapeHtml(value)}" placeholder="${placeholderAttr}" ${attrs}${disabledAttr} />`;
1971
+ }
1972
+ function renderConfigClientShell(d) {
1973
+ const payload = JSON.stringify({
1974
+ hostname: d.hostname,
1975
+ tenantId: d.tenantId,
1976
+ appId: d.appId,
1977
+ serviceInstanceId: d.serviceInstanceId,
1978
+ serviceId: d.serviceId,
1979
+ serviceTitle: d.serviceTitle,
1980
+ tenantApps: d.tenantApps,
1981
+ ticketUrl: `${d.adminApiBase.replace(/\/+$/, "")}/config-ticket`
1982
+ }).replace(/</g, "\\u003c");
1983
+ return `<div data-bp-config-client-editor data-config="${escapeHtml(payload)}">
1984
+ <div class="small text-secondary mb-3">${escapeHtml(d.serviceTitle)} - <span class="font-monospace">${escapeHtml(d.hostname)}</span></div>
1985
+ <div data-bp-config-status class="alert alert-secondary py-2">Loading configuration...</div>
1986
+ <div data-bp-config-body></div>
1987
+ </div>
1988
+ <script>
1989
+ (() => {
1990
+ const script = document.currentScript;
1991
+ const root = script?.previousElementSibling?.matches?.("[data-bp-config-client-editor]")
1992
+ ? script.previousElementSibling
1993
+ : null;
1994
+ if (!root) return;
1995
+ const cfg = JSON.parse(root.dataset.config || "{}");
1996
+ const status = root.querySelector("[data-bp-config-status]");
1997
+ const body = root.querySelector("[data-bp-config-body]");
1998
+ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (ch) => ({
1999
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
2000
+ })[ch]);
2001
+ const setStatus = (kind, message) => {
2002
+ if (!status) return;
2003
+ status.className = "alert py-2 " + (kind === "error" ? "alert-danger" : kind === "ok" ? "alert-success" : "alert-secondary");
2004
+ status.textContent = message;
2005
+ };
2006
+ const readJson = async (response, label) => {
2007
+ const contentType = response.headers.get("content-type") || "";
2008
+ if (contentType.includes("application/json")) return response.json();
2009
+ const text = await response.text().catch(() => "");
2010
+ throw new Error(label + " returned " + (contentType || "non-JSON") + " HTTP " + response.status + (text ? ": " + text.slice(0, 160) : ""));
2011
+ };
2012
+ const bpHeaders = () => {
2013
+ try {
2014
+ const stored = JSON.parse(localStorage.getItem("bp.headers") || "{}");
2015
+ const auth = stored.Authorization;
2016
+ return auth && typeof auth.value === "string" ? { Authorization: auth.value } : {};
2017
+ } catch {
2018
+ return {};
2019
+ }
2020
+ };
2021
+ const requestTicket = async () => {
2022
+ const response = await fetch(cfg.ticketUrl, {
2023
+ method: "POST",
2024
+ headers: { ...bpHeaders(), "Content-Type": "application/json", Accept: "application/json" },
2025
+ body: JSON.stringify({
2026
+ hostname: cfg.hostname,
2027
+ tenantId: cfg.tenantId,
2028
+ serviceInstanceId: cfg.serviceInstanceId,
2029
+ serviceId: cfg.serviceId,
2030
+ actions: ["config.read", "config.write"]
2031
+ })
2032
+ });
2033
+ const data = await readJson(response, "ticket");
2034
+ if (!response.ok) throw new Error(data.error || data.message || "ticket HTTP " + response.status);
2035
+ if (!data.token) throw new Error("ticket missing");
2036
+ return data.token;
2037
+ };
2038
+ const serviceBase = () => cfg.hostname.replace(/\\/+$/, "");
2039
+ const loadValues = async (token, appId) => {
2040
+ const headers = {
2041
+ Accept: "application/json",
2042
+ Authorization: "Bearer " + token,
2043
+ "x-bp-tenant-id": cfg.tenantId
2044
+ };
2045
+ if (appId) headers["x-bp-app-id"] = appId;
2046
+ const response = await fetch(serviceBase() + "/.well-known/bp/config", { method: "GET", headers, cache: "no-store" });
2047
+ const data = await readJson(response, "config");
2048
+ if (!response.ok) throw new Error(data.error || data.message || "config HTTP " + response.status);
2049
+ return data.values || {};
2050
+ };
2051
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key);
2052
+ const compareOrder = (left, right) => {
2053
+ const leftOrder = typeof left.order === "number" ? left.order : 0;
2054
+ const rightOrder = typeof right.order === "number" ? right.order : 0;
2055
+ if (leftOrder !== rightOrder) return leftOrder - rightOrder;
2056
+ return String(left.title || left.key || left.id || "").localeCompare(String(right.title || right.key || right.id || ""));
2057
+ };
2058
+ const fieldControl = (field) => field.visibility === "secret" ? "password" : field.ui?.control || "text";
2059
+ const fieldOptions = (field, appId) => {
2060
+ const ui = field.ui || {};
2061
+ if (ui.optionsSource === "app.routes") {
2062
+ const app = (cfg.tenantApps || []).find((entry) => entry.id === appId);
2063
+ return [{ value: "", label: "Default (/)" }].concat((app?.routes || []).map((route) => ({
2064
+ value: route.path,
2065
+ label: (route.title || route.path) + " (" + route.path + ")"
2066
+ })));
2067
+ }
2068
+ return ui.options || [];
2069
+ };
2070
+ const renderControl = (field, value, placeholder, disabled, selectedAppId) => {
2071
+ const ui = field.ui || {};
2072
+ const control = fieldControl(field);
2073
+ const name = escapeHtml(field.key);
2074
+ const disabledAttr = disabled ? " disabled" : "";
2075
+ const placeholderAttr = escapeHtml(placeholder || ui.placeholder || "");
2076
+ const attrs = [
2077
+ ui.min !== undefined ? 'min="' + escapeHtml(ui.min) + '"' : "",
2078
+ ui.max !== undefined ? 'max="' + escapeHtml(ui.max) + '"' : "",
2079
+ ui.step !== undefined ? 'step="' + escapeHtml(ui.step) + '"' : ""
2080
+ ].filter(Boolean).join(" ");
2081
+ if (control === "textarea") {
2082
+ return '<textarea class="form-control" name="' + name + '" rows="' + escapeHtml(ui.rows || 3) + '" placeholder="' + placeholderAttr + '"' + disabledAttr + '>' + escapeHtml(value) + '</textarea>';
2083
+ }
2084
+ if (control === "select" || control === "multiselect") {
2085
+ const selected = new Set(control === "multiselect" ? String(value || "").split(",").map((entry) => entry.trim()) : [String(value || "")]);
2086
+ const options = fieldOptions(field, selectedAppId || "").map((option) =>
2087
+ '<option value="' + escapeHtml(option.value) + '"' + (selected.has(String(option.value)) ? " selected" : "") + '>' + escapeHtml(option.label) + '</option>'
2088
+ ).join("");
2089
+ return '<select class="form-select" name="' + name + '"' + (control === "multiselect" ? " multiple" : "") + disabledAttr + '>' + options + '</select>';
2090
+ }
2091
+ if (control === "checkbox") {
2092
+ const checked = value === true || value === "true" || value === "1" || value === "on";
2093
+ return '<input class="form-check-input" type="checkbox" name="' + name + '" value="true"' + (checked ? " checked" : "") + disabledAttr + ' />';
2094
+ }
2095
+ const type = ["number", "color", "date", "time", "datetime-local", "url", "email", "password"].includes(control) ? control : "text";
2096
+ return '<input class="form-control" type="' + type + '" name="' + name + '" value="' + escapeHtml(value) + '" placeholder="' + placeholderAttr + '" ' + attrs + disabledAttr + ' />';
2097
+ };
2098
+ const readFieldValue = (form, field) => {
2099
+ const control = fieldControl(field);
2100
+ const input = form.querySelector('[name="' + CSS.escape(field.key) + '"]');
2101
+ if (!input) return undefined;
2102
+ if (control === "checkbox") return input.checked ? "true" : "false";
2103
+ if (control === "multiselect") return Array.from(input.selectedOptions || []).map((option) => option.value).join(",");
2104
+ return input.value;
2105
+ };
2106
+ const schemaLayout = (schema) => {
2107
+ const byKey = new Map();
2108
+ const groupsById = new Map();
2109
+ for (const entry of schema.configSchemas || []) {
2110
+ for (const group of entry.groups || []) {
2111
+ if (group && typeof group.id === "string" && !groupsById.has(group.id)) groupsById.set(group.id, group);
2112
+ }
2113
+ for (const field of entry.fields || []) {
2114
+ if (field && typeof field.key === "string" && !byKey.has(field.key)) byKey.set(field.key, field);
2115
+ }
2116
+ }
2117
+ const fields = Array.from(byKey.values()).sort(compareOrder);
2118
+ const ungrouped = [];
2119
+ const fieldsByGroupId = new Map();
2120
+ for (const field of fields) {
2121
+ if (!field.groupId) {
2122
+ ungrouped.push(field);
2123
+ continue;
2124
+ }
2125
+ if (!fieldsByGroupId.has(field.groupId)) fieldsByGroupId.set(field.groupId, []);
2126
+ fieldsByGroupId.get(field.groupId).push(field);
2127
+ }
2128
+ const sections = [];
2129
+ if (ungrouped.length > 0) {
2130
+ sections.push({ id: "", title: "", description: "", order: -1000, optional: false, fields: ungrouped });
2131
+ }
2132
+ for (const [groupId, groupFields] of fieldsByGroupId.entries()) {
2133
+ const group = groupsById.get(groupId) || {};
2134
+ sections.push({
2135
+ id: groupId,
2136
+ title: group.title || groupId,
2137
+ description: group.description || "",
2138
+ order: typeof group.order === "number" ? group.order : 0,
2139
+ optional: group.optional === true,
2140
+ fields: groupFields.sort(compareOrder)
2141
+ });
2142
+ }
2143
+ sections.sort(compareOrder);
2144
+ return { fields, sections };
2145
+ };
2146
+ const renderForm = async (schema, token, selectedScope, appId) => {
2147
+ const layout = schemaLayout(schema);
2148
+ const fields = layout.fields;
2149
+ const scope = selectedScope === "app" ? "app" : "tenant";
2150
+ const selectedAppId = scope === "app" ? appId : "";
2151
+ const tenantValues = await loadValues(token, "");
2152
+ const appValues = selectedAppId ? await loadValues(token, selectedAppId) : {};
2153
+ const scopeSelector = '<div class="row g-2 mb-3"><div class="col-5"><label class="form-label">Scope</label>' +
2154
+ '<select class="form-select" data-bp-config-scope><option value="tenant"' + (scope === "tenant" ? " selected" : "") + '>Tenant defaults</option><option value="app"' + (scope === "app" ? " selected" : "") + '>App override</option></select></div>' +
2155
+ '<div class="col-7"><label class="form-label">App</label><select class="form-select" data-bp-config-app ' + (scope === "app" ? "" : "disabled") + '><option value="">Select app...</option>' +
2156
+ (cfg.tenantApps || []).map((app) => '<option value="' + escapeHtml(app.id) + '"' + (app.id === selectedAppId ? " selected" : "") + '>' + escapeHtml(app.title) + '</option>').join("") +
2157
+ '</select></div></div>';
2158
+ if (scope === "app" && !selectedAppId) {
2159
+ body.innerHTML = scopeSelector + '<div class="alert alert-info">Select an app to edit overrides.</div>';
2160
+ wireScopeControls(schema, token);
2161
+ setStatus("info", "Select an app.");
2162
+ return;
2163
+ }
2164
+ const renderField = (field) => {
2165
+ const override = scope === "app" && hasOwn(appValues, field.key);
2166
+ const fallbackValue = hasOwn(field, "defaultValue") ? field.defaultValue : "";
2167
+ const rawValue = scope === "app"
2168
+ ? (override ? appValues[field.key] : tenantValues[field.key] ?? fallbackValue)
2169
+ : tenantValues[field.key] ?? fallbackValue;
2170
+ const secret = field.visibility === "secret";
2171
+ const redacted = secret && rawValue === "__redacted__";
2172
+ const disabled = scope === "app" && !override;
2173
+ const checkbox = scope === "app"
2174
+ ? '<input class="form-check-input me-2" type="checkbox" data-bp-override-key="' + escapeHtml(field.key) + '"' + (override ? " checked" : "") + ' />'
2175
+ : "";
2176
+ const resetButton = scope === "tenant"
2177
+ ? '<button type="button" class="btn btn-link btn-sm p-0 ms-auto" data-bp-reset-key="' + escapeHtml(field.key) + '"' + (hasOwn(tenantValues, field.key) ? "" : " disabled") + '>Reset</button>'
2178
+ : "";
2179
+ const inherited = scope === "app" && !override ? '<div class="form-text">Using tenant default.</div>' : "";
2180
+ return '<div class="mb-3" data-bp-field="' + escapeHtml(field.key) + '">' +
2181
+ '<label class="form-label d-flex align-items-center">' + checkbox + '<span>' + escapeHtml(field.title || field.key) + '</span>' + resetButton + '</label>' +
2182
+ renderControl(field, redacted ? "" : rawValue, redacted ? "(unchanged)" : "", disabled, selectedAppId) +
2183
+ (field.description ? '<div class="form-text">' + escapeHtml(field.description) + '</div>' : "") +
2184
+ inherited +
2185
+ '</div>';
2186
+ };
2187
+ const sectionsHtml = layout.sections.map((section) => {
2188
+ const sectionFields = section.fields || [];
2189
+ const groupOverride = scope === "app" && section.optional && sectionFields.some((field) => hasOwn(appValues, field.key));
2190
+ const groupCheckbox = scope === "app" && section.optional
2191
+ ? '<input class="form-check-input me-2" type="checkbox" data-bp-override-group="' + escapeHtml(section.id) + '"' + (groupOverride ? " checked" : "") + ' />'
2192
+ : "";
2193
+ const header = section.title
2194
+ ? '<div class="mb-3"><div class="fw-semibold d-flex align-items-center">' + groupCheckbox + '<span>' + escapeHtml(section.title) + '</span></div>' + (section.description ? '<div class="form-text">' + escapeHtml(section.description) + '</div>' : "") + '</div>'
2195
+ : "";
2196
+ const content = sectionFields.map(renderField).join("");
2197
+ return '<div class="border rounded p-3 mb-3" data-bp-config-section="' + escapeHtml(section.id) + '">' + header + content + '</div>';
2198
+ }).join("");
2199
+ body.innerHTML = scopeSelector + '<form data-bp-config-form>' + sectionsHtml + '<div data-bp-save-status></div><button type="submit" class="btn btn-primary w-100">Save Configuration</button></form>';
2200
+ wireScopeControls(schema, token);
2201
+ root.querySelectorAll("[data-bp-override-key]").forEach((checkbox) => {
2202
+ checkbox.addEventListener("change", () => {
2203
+ const field = checkbox.closest("[data-bp-field]");
2204
+ const input = field?.querySelector("[name]");
2205
+ if (input) input.disabled = !checkbox.checked;
2206
+ });
2207
+ });
2208
+ root.querySelectorAll("[data-bp-override-group]").forEach((checkbox) => {
2209
+ checkbox.addEventListener("change", () => {
2210
+ const section = checkbox.closest("[data-bp-config-section]");
2211
+ section?.querySelectorAll("[data-bp-override-key]").forEach((fieldCheckbox) => {
2212
+ fieldCheckbox.checked = checkbox.checked;
2213
+ fieldCheckbox.dispatchEvent(new Event("change"));
2214
+ });
2215
+ });
2216
+ });
2217
+ root.querySelectorAll("[data-bp-reset-key]").forEach((button) => {
2218
+ button.addEventListener("click", async () => {
2219
+ const key = button.getAttribute("data-bp-reset-key");
2220
+ if (!key) return;
2221
+ button.disabled = true;
2222
+ setStatus("info", "Resetting " + key + "...");
2223
+ const response = await fetch(serviceBase() + "/.well-known/bp/config", {
2224
+ method: "POST",
2225
+ headers: {
2226
+ "Content-Type": "application/json",
2227
+ Accept: "application/json",
2228
+ Authorization: "Bearer " + token,
2229
+ "x-bp-tenant-id": cfg.tenantId
2230
+ },
2231
+ body: JSON.stringify({ tenantId: cfg.tenantId, values: {}, clearKeys: [key] })
2232
+ });
2233
+ if (!response.ok) {
2234
+ const error = await readJson(response, "reset").catch((err) => ({ error: err.message || "HTTP " + response.status }));
2235
+ setStatus("error", error.error || "Reset failed.");
2236
+ button.disabled = false;
2237
+ return;
2238
+ }
2239
+ document.body.dispatchEvent(new CustomEvent("bp:config-saved"));
2240
+ await renderForm(schema, token, "tenant", "");
2241
+ });
2242
+ });
2243
+ root.querySelector("[data-bp-config-form]")?.addEventListener("submit", async (event) => {
2244
+ event.preventDefault();
2245
+ const submitButton = event.target.querySelector('button[type="submit"]');
2246
+ const saveStatus = root.querySelector("[data-bp-save-status]");
2247
+ if (submitButton) submitButton.disabled = true;
2248
+ setStatus("info", "Saving configuration...");
2249
+ const valuesToSave = {};
2250
+ const clearKeys = [];
2251
+ if (scope === "app") {
2252
+ for (const field of fields) {
2253
+ const checkbox = root.querySelector('[data-bp-override-key="' + CSS.escape(field.key) + '"]');
2254
+ if (!checkbox?.checked) {
2255
+ clearKeys.push(field.key);
2256
+ continue;
2257
+ }
2258
+ const value = readFieldValue(event.target, field);
2259
+ if (value !== undefined && !(field.visibility === "secret" && value === "" && appValues[field.key] === "__redacted__")) {
2260
+ valuesToSave[field.key] = value;
2261
+ }
2262
+ }
2263
+ } else {
2264
+ for (const field of fields) {
2265
+ const value = readFieldValue(event.target, field);
2266
+ if (value !== undefined && value !== "(unchanged)") valuesToSave[field.key] = value;
2267
+ }
2268
+ }
2269
+ const payload = { tenantId: cfg.tenantId, values: valuesToSave };
2270
+ if (selectedAppId) payload.appId = selectedAppId;
2271
+ if (clearKeys.length > 0) payload.clearKeys = clearKeys;
2272
+ const headers = {
2273
+ "Content-Type": "application/json",
2274
+ Accept: "application/json",
2275
+ Authorization: "Bearer " + token,
2276
+ "x-bp-tenant-id": cfg.tenantId
2277
+ };
2278
+ if (selectedAppId) headers["x-bp-app-id"] = selectedAppId;
2279
+ const response = await fetch(serviceBase() + "/.well-known/bp/config", {
2280
+ method: "POST",
2281
+ headers,
2282
+ body: JSON.stringify(payload)
2283
+ });
2284
+ if (!response.ok) {
2285
+ const error = await readJson(response, "save").catch((err) => ({ error: err.message || "HTTP " + response.status }));
2286
+ if (saveStatus) saveStatus.innerHTML = '<div class="alert alert-danger">' + escapeHtml(error.error || "Save failed") + '</div>';
2287
+ setStatus("error", error.error || "Save failed.");
2288
+ if (submitButton) submitButton.disabled = false;
2289
+ return;
2290
+ }
2291
+ document.body.dispatchEvent(new CustomEvent("bp:config-saved"));
2292
+ await renderForm(schema, token, scope, selectedAppId);
2293
+ setStatus("ok", "Configuration saved.");
2294
+ });
2295
+ setStatus("ok", "Configuration loaded.");
2296
+ };
2297
+ const wireScopeControls = (schema, token) => {
2298
+ root.querySelector("[data-bp-config-scope]")?.addEventListener("change", (event) => renderForm(schema, token, event.target.value, ""));
2299
+ root.querySelector("[data-bp-config-app]")?.addEventListener("change", (event) => renderForm(schema, token, "app", event.target.value));
2300
+ };
2301
+ (async () => {
2302
+ try {
2303
+ const token = await requestTicket();
2304
+ const schemaResponse = await fetch(serviceBase() + "/.well-known/bp/config/schema", {
2305
+ method: "GET",
2306
+ headers: { Accept: "application/json" },
2307
+ cache: "no-store"
2308
+ });
2309
+ const schema = await readJson(schemaResponse, "schema");
2310
+ if (!schemaResponse.ok) throw new Error(schema.error || schema.message || "schema HTTP " + schemaResponse.status);
2311
+ await renderForm(schema, token, "tenant", "");
2312
+ } catch (error) {
2313
+ setStatus("error", error instanceof Error ? error.message : String(error));
2314
+ }
2315
+ })();
2316
+ })();
2317
+ </script>`;
2318
+ }
2319
+ //# sourceMappingURL=adminApi.js.map