@betterportal/config-manager 10.0.30 → 10.1.0

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 (47) hide show
  1. package/lib/.bsb/clients/service-betterportal-config-manager.d.ts +1 -1
  2. package/lib/.bsb/clients/service-betterportal-config-manager.js +1 -1
  3. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
  4. package/lib/plugins/service-betterportal-config-manager/adminApi.js +188 -32
  5. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
  6. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.d.ts.map +1 -1
  7. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js +13 -12
  8. package/lib/plugins/service-betterportal-config-manager/bootstrapEndpoint.js.map +1 -1
  9. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/GET.d.ts.map +1 -1
  10. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/GET.js +121 -6
  11. package/lib/plugins/service-betterportal-config-manager/bp-routes/auth/_theme.bootstrap1/GET.js.map +1 -1
  12. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/GET.d.ts.map +1 -1
  13. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/GET.js +36 -2
  14. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_theme.bootstrap1/GET.js.map +1 -1
  15. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.d.ts +6 -0
  16. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.d.ts.map +1 -1
  17. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.js +3 -0
  18. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/route.impl.js.map +1 -1
  19. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/GET.d.ts.map +1 -1
  20. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/GET.js +115 -32
  21. package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_theme.bootstrap1/GET.js.map +1 -1
  22. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.d.ts.map +1 -1
  23. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.js +9 -4
  24. package/lib/plugins/service-betterportal-config-manager/bp-routes/tenants/route.impl.js.map +1 -1
  25. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
  26. package/lib/plugins/service-betterportal-config-manager/index.js +42 -8
  27. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
  28. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts.map +1 -1
  29. package/lib/plugins/service-betterportal-config-manager/menuEditor.js +7 -1
  30. package/lib/plugins/service-betterportal-config-manager/menuEditor.js.map +1 -1
  31. package/lib/plugins/service-betterportal-config-manager/routeMounts.d.ts +8 -0
  32. package/lib/plugins/service-betterportal-config-manager/routeMounts.d.ts.map +1 -0
  33. package/lib/plugins/service-betterportal-config-manager/routeMounts.js +22 -0
  34. package/lib/plugins/service-betterportal-config-manager/routeMounts.js.map +1 -0
  35. package/lib/plugins/service-betterportal-config-manager/setupTokens.d.ts.map +1 -1
  36. package/lib/plugins/service-betterportal-config-manager/setupTokens.js +50 -0
  37. package/lib/plugins/service-betterportal-config-manager/setupTokens.js.map +1 -1
  38. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
  39. package/lib/plugins/service-betterportal-config-manager/storage/core.js +25 -0
  40. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
  41. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +2 -1
  42. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
  43. package/lib/plugins/service-betterportal-config-manager/syncApi.js +56 -1
  44. package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
  45. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  46. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  47. package/package.json +3 -3
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Auto-generated BSB virtual client for service-betterportal-config-manager
3
3
  * DO NOT EDIT - Regenerated on every build
4
- * @version 10.0.30
4
+ * @version 10.1.0
5
5
  */
6
6
  import { ServiceClient, BSBService } from "@bsb/base";
7
7
  import type { Observable, BSBServiceClientDefinition, EventInputType } from "@bsb/base";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Auto-generated BSB virtual client for service-betterportal-config-manager
3
3
  * DO NOT EDIT - Regenerated on every build
4
- * @version 10.0.30
4
+ * @version 10.1.0
5
5
  */
6
6
  import { ServiceClient, bsb, createBroadcastEvent, createEventSchemas } from "@bsb/base";
7
7
  const _platformConfigChangedSchema = bsb.object({
@@ -1 +1 @@
1
- {"version":3,"file":"adminApi.d.ts","sourceRoot":"","sources":["../../../src/plugins/service-betterportal-config-manager/adminApi.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EAEjB,mBAAmB,EAEpB,MAAM,yBAAyB,CAAC;AAUjC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AA+qBzD,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,mBAAmB,EAC1B,OAAO,EAAE,gBAAgB,GACxB,IAAI,CA6iCN"}
1
+ {"version":3,"file":"adminApi.d.ts","sourceRoot":"","sources":["../../../src/plugins/service-betterportal-config-manager/adminApi.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EAEjB,mBAAmB,EAEpB,MAAM,yBAAyB,CAAC;AAUjC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAmyBzD,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,mBAAmB,EAC1B,OAAO,EAAE,gBAAgB,GACxB,IAAI,CAgnCN"}
@@ -1,6 +1,7 @@
1
1
  import { eventHeaders, htmlResponse, jsonResponse, resolveEmbeddedRequestContext, signServiceConfigTicket, uuidv7 } from "@betterportal/framework";
2
2
  import { generateApiKey, hashApiKey } from "./storage/index.js";
3
3
  import { getManifestCache } from "./syncApi.js";
4
+ import { apiRoutePath, isApiRoute } from "./routeMounts.js";
4
5
  const API_BASE = "/.well-known/bp/admin";
5
6
  const CONFIG_TICKET_TTL_SECONDS = 5 * 60;
6
7
  // Parse-only base for relative request URLs. Never emit this origin.
@@ -14,6 +15,24 @@ async function readFormBody(event) {
14
15
  out[k] = v; });
15
16
  return out;
16
17
  }
18
+ async function readFormBodyWithArrays(event) {
19
+ const fd = await event.req.formData().catch(() => null);
20
+ if (!fd)
21
+ return {};
22
+ const out = {};
23
+ fd.forEach((v, k) => {
24
+ if (typeof v !== "string")
25
+ return;
26
+ const existing = out[k];
27
+ if (Array.isArray(existing))
28
+ existing.push(v);
29
+ else if (typeof existing === "string")
30
+ out[k] = [existing, v];
31
+ else
32
+ out[k] = v;
33
+ });
34
+ return out;
35
+ }
17
36
  function escapeHtml(s) {
18
37
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
19
38
  }
@@ -26,7 +45,7 @@ async function readJsonBody(event) {
26
45
  async function readFormOrJsonBody(event) {
27
46
  const contentType = event.req.headers.get("content-type") ?? "";
28
47
  if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
29
- return readFormBody(event);
48
+ return readFormBodyWithArrays(event);
30
49
  }
31
50
  return readJsonBody(event);
32
51
  }
@@ -59,6 +78,12 @@ function routeMethodsFromManifest(methods) {
59
78
  const normalized = (methods ?? []).filter((method) => method === "GET" || method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE" || method === "OPTIONS");
60
79
  return normalized.length ? normalized : ["GET"];
61
80
  }
81
+ function hasPageRenderer(view) {
82
+ const renderers = view.html?.themeRenderers;
83
+ if (!renderers)
84
+ return view.renderable !== false;
85
+ return Object.values(renderers).some((renderer) => Array.isArray(renderer.renderModes) && renderer.renderModes.includes("page"));
86
+ }
62
87
  function appMatchesTenantUrl(app, tenantUrl) {
63
88
  let host = "";
64
89
  try {
@@ -187,7 +212,9 @@ function parseRouteCreateBody(body) {
187
212
  return { error: viewId.error };
188
213
  const manifestView = getManifestCache().get(serviceId.value)?.viewIndex[viewId.value];
189
214
  const renderable = manifestView?.renderable !== false;
190
- const path = renderable ? requiredRouteString(body, "path", "Mount path") : { value: manifestView?.path ?? `/${viewId.value}` };
215
+ const manifest = getManifestCache().get(serviceId.value);
216
+ const serviceRoutePath = manifestView?.path ?? `/${viewId.value}`;
217
+ const path = renderable ? requiredRouteString(body, "path", "Mount path") : { value: apiRoutePath(manifest?.serviceId ?? serviceId.value, serviceRoutePath) };
191
218
  if (path.error)
192
219
  return { error: path.error };
193
220
  if (!path.value.startsWith("/"))
@@ -196,6 +223,7 @@ function parseRouteCreateBody(body) {
196
223
  if (title.error)
197
224
  return { error: title.error };
198
225
  const route = {
226
+ kind: renderable ? "page" : "api",
199
227
  path: path.value,
200
228
  serviceId: serviceId.value,
201
229
  viewId: viewId.value,
@@ -281,7 +309,8 @@ function addRouteDependencies(appDef, route) {
281
309
  continue;
282
310
  appDef.routes.push({
283
311
  id: uuidv7(),
284
- path: dependency.path,
312
+ kind: "api",
313
+ path: apiRoutePath(manifest.serviceId, dependency.path),
285
314
  serviceId: route.serviceId,
286
315
  viewId: dependencyViewId,
287
316
  targetPath: dependency.path,
@@ -445,6 +474,53 @@ function collectServiceDeleteBlockers(config, tenantId, serviceId) {
445
474
  }
446
475
  return blockers;
447
476
  }
477
+ function purgeTenantServiceReferences(config, tenantId, serviceId) {
478
+ const summary = {
479
+ routesRemoved: 0,
480
+ menuItemsRemoved: 0,
481
+ slotsRemoved: 0,
482
+ fragmentsRemoved: 0,
483
+ roleGrantsRemoved: 0,
484
+ shellCleared: 0,
485
+ authCleared: 0
486
+ };
487
+ for (const app of config.apps.filter((candidate) => candidate.tenantId === tenantId)) {
488
+ if (app.shell?.serviceId === serviceId) {
489
+ delete app.shell;
490
+ summary.shellCleared += 1;
491
+ }
492
+ if (app.auth?.serviceId === serviceId) {
493
+ delete app.auth;
494
+ summary.authCleared += 1;
495
+ }
496
+ else if (app.auth?.roles) {
497
+ for (const role of app.auth.roles) {
498
+ const before = role.permissions.length;
499
+ role.permissions = role.permissions.filter((grant) => grant.serviceId !== serviceId);
500
+ summary.roleGrantsRemoved += before - role.permissions.length;
501
+ }
502
+ }
503
+ const routeIds = new Set(app.routes.filter((route) => route.serviceId === serviceId).map((route) => route.id));
504
+ const routeCountBefore = app.routes.length;
505
+ app.routes = app.routes.filter((route) => route.serviceId !== serviceId);
506
+ summary.routesRemoved += routeCountBefore - app.routes.length;
507
+ const menuBefore = JSON.stringify(app.menu ?? []);
508
+ app.menu = removeMenuRoutes(app.menu, routeIds);
509
+ if (menuBefore !== JSON.stringify(app.menu ?? []))
510
+ summary.menuItemsRemoved += routeIds.size;
511
+ const slotsBefore = app.slots.length;
512
+ app.slots = app.slots.filter((slot) => slot.serviceId !== serviceId);
513
+ summary.slotsRemoved += slotsBefore - app.slots.length;
514
+ for (const [location, fragments] of Object.entries(app.fragments)) {
515
+ const before = fragments.length;
516
+ app.fragments[location] = fragments.filter((fragment) => fragment.serviceId !== serviceId);
517
+ summary.fragmentsRemoved += before - app.fragments[location].length;
518
+ if (app.fragments[location].length === 0)
519
+ delete app.fragments[location];
520
+ }
521
+ }
522
+ return summary;
523
+ }
448
524
  function sharedServiceIdFor(service, requested) {
449
525
  return (requested && requested.trim()) || service.serviceId || service.id;
450
526
  }
@@ -616,6 +692,23 @@ function migrateTenantServiceToShared(config, tenantId, serviceId, options = {})
616
692
  function linkedServiceError(blockers) {
617
693
  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` : ""}`;
618
694
  }
695
+ function linkedServiceHtmxError(tenantId, serviceId, blockers) {
696
+ const items = blockers.slice(0, 8).map((blocker) => `<li>${escapeHtml(blocker)}</li>`).join("");
697
+ const more = blockers.length > 8 ? `<li>${blockers.length - 8} more references</li>` : "";
698
+ return htmlResponse(`
699
+ <div class="alert alert-warning">
700
+ <div class="fw-semibold mb-2">Service is still linked and cannot be deleted.</div>
701
+ <ul class="mb-3">${items}${more}</ul>
702
+ <button
703
+ class="btn btn-sm btn-danger"
704
+ hx-post="${API_BASE}/tenants/${encodeURIComponent(tenantId)}/services/${encodeURIComponent(serviceId)}/purge"
705
+ hx-target="#bp-services-alerts"
706
+ hx-swap="innerHTML"
707
+ hx-confirm="Purge all routes, menu entries, fragments, slots, auth links and role grants for this service, then delete it?"
708
+ >Purge references and delete service</button>
709
+ </div>
710
+ `, 409, "text/html; mode=fragment");
711
+ }
619
712
  function parseWizardManifest(raw) {
620
713
  const parsed = JSON.parse(raw);
621
714
  return parsed && typeof parsed === "object" && !Array.isArray(parsed)
@@ -1002,7 +1095,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
1002
1095
  const blockers = collectServiceDeleteBlockers(config, tenantId, serviceId);
1003
1096
  if (blockers.length > 0) {
1004
1097
  const message = linkedServiceError(blockers);
1005
- return wantsHtmx(event) ? htmxError(message, 409) : jsonResponse({ error: message, blockers }, 409);
1098
+ return wantsHtmx(event) ? linkedServiceHtmxError(tenantId, serviceId, blockers) : jsonResponse({ error: message, blockers }, 409);
1006
1099
  }
1007
1100
  tenant.services = tenant.services.filter((s) => s.id !== serviceId);
1008
1101
  await store.saveConfig(config);
@@ -1014,6 +1107,31 @@ export function registerAdminApiRoutes(app, store, cpState) {
1014
1107
  }
1015
1108
  return jsonResponse({ ok: true });
1016
1109
  });
1110
+ app.post(`${API_BASE}/tenants/:tenantId/services/:serviceId/purge`, async (event) => {
1111
+ const tenantId = getParam(event, "tenantId");
1112
+ const serviceId = getParam(event, "serviceId");
1113
+ if (!tenantId || !serviceId)
1114
+ return jsonResponse({ error: "tenantId and serviceId required" }, 400);
1115
+ const config = await store.loadConfig();
1116
+ const tenant = config.tenants.find((t) => t.id === tenantId);
1117
+ if (!tenant)
1118
+ return wantsHtmx(event) ? htmxError("Tenant not found", 404) : jsonResponse({ error: "Tenant not found" }, 404);
1119
+ const service = tenant.services.find((candidate) => candidate.id === serviceId);
1120
+ if (!service)
1121
+ return wantsHtmx(event) ? htmxError("Service not found", 404) : jsonResponse({ error: "Service not found" }, 404);
1122
+ const summary = purgeTenantServiceReferences(config, tenantId, serviceId);
1123
+ tenant.services = tenant.services.filter((candidate) => candidate.id !== serviceId);
1124
+ await store.saveConfig(config);
1125
+ if (wantsHtmx(event)) {
1126
+ return htmlResponse(`
1127
+ <div class="alert alert-success">
1128
+ Service deleted. Purged ${summary.routesRemoved} route(s), ${summary.slotsRemoved} slot(s), ${summary.fragmentsRemoved} fragment(s), ${summary.roleGrantsRemoved} role grant(s), ${summary.shellCleared} shell binding(s), and ${summary.authCleared} auth binding(s).
1129
+ </div>
1130
+ <div hx-get="/services?tenantId=${encodeURIComponent(tenantId)}" hx-trigger="load" hx-target="#bp-main" hx-swap="innerHTML"></div>
1131
+ `, 200, "text/html; mode=fragment");
1132
+ }
1133
+ return jsonResponse({ ok: true, summary });
1134
+ });
1017
1135
  // Activate/deactivate platform services for tenant
1018
1136
  app.get(`${API_BASE}/tenants/:tenantId/services/:serviceId/migrate-to-shared/preview`, async (event) => {
1019
1137
  const tenantId = getParam(event, "tenantId");
@@ -1335,15 +1453,20 @@ export function registerAdminApiRoutes(app, store, cpState) {
1335
1453
  return validationError(event, "View is required.");
1336
1454
  route.viewId = viewId;
1337
1455
  }
1338
- const manifestView = getManifestCache().get(route.serviceId)?.viewIndex[route.viewId];
1456
+ const manifest = getManifestCache().get(route.serviceId);
1457
+ const manifestView = manifest?.viewIndex[route.viewId];
1339
1458
  if (manifestView) {
1340
1459
  route.methods = routeMethodsFromManifest(manifestView.methods);
1341
1460
  route.targetPath = manifestView.path;
1342
1461
  if (manifestView.renderable === false) {
1343
- route.path = manifestView.path;
1462
+ route.kind = "api";
1463
+ route.path = apiRoutePath(manifest?.serviceId ?? route.serviceId, manifestView.path);
1344
1464
  route.title = manifestView.viewId;
1345
1465
  delete route.query;
1346
1466
  }
1467
+ else {
1468
+ route.kind = "page";
1469
+ }
1347
1470
  }
1348
1471
  if (body.targetPath !== undefined) {
1349
1472
  const targetPath = trimmedString(body, "targetPath");
@@ -1354,7 +1477,9 @@ export function registerAdminApiRoutes(app, store, cpState) {
1354
1477
  }
1355
1478
  if (body.query !== undefined) {
1356
1479
  const query = trimmedString(body, "query");
1357
- if (query)
1480
+ if (isApiRoute(route, manifestView?.renderable))
1481
+ delete route.query;
1482
+ else if (query)
1358
1483
  route.query = query.replace(/^\?+/, "");
1359
1484
  else
1360
1485
  delete route.query;
@@ -1363,7 +1488,8 @@ export function registerAdminApiRoutes(app, store, cpState) {
1363
1488
  const title = trimmedString(body, "title");
1364
1489
  if (!title)
1365
1490
  return validationError(event, "Display title is required.");
1366
- route.title = title;
1491
+ if (!isApiRoute(route, manifestView?.renderable))
1492
+ route.title = title;
1367
1493
  }
1368
1494
  if (body.enabled !== undefined)
1369
1495
  route.enabled = body.enabled === true || body.enabled === "true" || body.enabled === "on";
@@ -1441,7 +1567,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
1441
1567
  // Browser-mediated: the admin UI fetches the manifest from the service
1442
1568
  // (CM cannot reach services) and POSTs the parsed payload here.
1443
1569
  app.post(`${API_BASE}/wizard/verify`, async (event) => {
1444
- const adminApiBase = adminApiBaseFromControlPlane(cpState);
1570
+ const adminApiBase = API_BASE;
1445
1571
  const form = await readFormBody(event);
1446
1572
  const tenantId = form.tenantId ?? "";
1447
1573
  const hostname = normalizeHostname(form.hostname);
@@ -1479,17 +1605,22 @@ export function registerAdminApiRoutes(app, store, cpState) {
1479
1605
  });
1480
1606
  // HTMX wizard: register service
1481
1607
  app.post(`${API_BASE}/wizard/register`, async (event) => {
1482
- const form = await readFormBody(event);
1483
- const tenantId = form.tenantId ?? "";
1484
- const hostname = (form.hostname ?? "").replace(/\/+$/, "");
1485
- const title = form.title || hostname;
1608
+ const body = await readFormOrJsonBody(event);
1609
+ const jsonMode = (event.req.headers.get("accept") ?? "").includes("application/json")
1610
+ || (event.req.headers.get("content-type") ?? "").includes("application/json");
1611
+ const registerError = (message, status = 400) => jsonMode
1612
+ ? jsonResponse({ error: message }, status)
1613
+ : htmlResponse(`<div class="alert alert-danger">${escapeHtml(message)}</div>`, 200, "text/html; mode=fragment");
1614
+ const tenantId = typeof body.tenantId === "string" ? body.tenantId : "";
1615
+ const hostname = (typeof body.hostname === "string" ? body.hostname : "").replace(/\/+$/, "");
1616
+ const title = (typeof body.title === "string" && body.title.trim().length > 0 ? body.title.trim() : hostname);
1486
1617
  if (!tenantId || !hostname) {
1487
- return htmlResponse(`<div class="alert alert-danger">Missing tenantId or hostname</div>`, 200, "text/html; mode=fragment");
1618
+ return registerError("Missing tenantId or hostname");
1488
1619
  }
1489
1620
  const config = await store.loadConfig();
1490
1621
  const tenant = config.tenants.find((t) => t.id === tenantId);
1491
1622
  if (!tenant) {
1492
- return htmlResponse(`<div class="alert alert-danger">Tenant not found</div>`, 200, "text/html; mode=fragment");
1623
+ return registerError("Tenant not found", 404);
1493
1624
  }
1494
1625
  // Schema is fetched by the browser from the service (CM cannot reach services)
1495
1626
  // and posted to us as a JSON string in form.schema.
@@ -1497,19 +1628,27 @@ export function registerAdminApiRoutes(app, store, cpState) {
1497
1628
  let capabilities = [];
1498
1629
  let serviceRoutes = [];
1499
1630
  let viewMeta = new Map();
1500
- if (form.schema) {
1631
+ const schemaText = typeof body.schema === "string" ? body.schema : "";
1632
+ if (schemaText) {
1501
1633
  try {
1502
- const schema = JSON.parse(form.schema);
1634
+ const schema = JSON.parse(schemaText);
1503
1635
  serviceId = schema.manifest?.pluginId;
1504
1636
  capabilities = Array.isArray(schema.manifest?.capabilities) ? schema.manifest.capabilities.filter((value) => typeof value === "string") : [];
1505
1637
  serviceRoutes = schema.routes ?? [];
1506
- viewMeta = new Map((schema.manifest?.views ?? []).map((v) => [v.viewId, v.title]));
1638
+ viewMeta = new Map((schema.manifest?.views ?? []).map((v) => [v.viewId, {
1639
+ title: v.title,
1640
+ renderable: v.renderable !== false,
1641
+ pageRenderable: hasPageRenderer(v),
1642
+ methods: Array.isArray(v.methods)
1643
+ ? v.methods.filter((value) => typeof value === "string")
1644
+ : []
1645
+ }]));
1507
1646
  }
1508
1647
  catch { /* malformed schema - register without routes */ }
1509
1648
  }
1510
1649
  const duplicate = duplicateTenantService(tenant, hostname, serviceId);
1511
1650
  if (duplicate) {
1512
- return htmlResponse(`<div class="alert alert-warning">${escapeHtml(duplicate)}</div>`, 200, "text/html; mode=fragment");
1651
+ return registerError(duplicate, 409);
1513
1652
  }
1514
1653
  const newServiceId = uuidv7();
1515
1654
  const service = {
@@ -1532,30 +1671,37 @@ export function registerAdminApiRoutes(app, store, cpState) {
1532
1671
  const groupChildren = [];
1533
1672
  const existingPaths = new Set(appDef.routes.map((r) => r.path));
1534
1673
  for (const r of serviceRoutes) {
1674
+ const meta = viewMeta.get(r.viewId);
1675
+ const renderable = meta?.renderable ?? r.renderable !== false;
1535
1676
  // Default mounting path is the service's own path; skip collisions
1536
- const mountPath = existingPaths.has(r.path) ? `/${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}${r.path}` : r.path;
1677
+ const mountPath = renderable
1678
+ ? (existingPaths.has(r.path) ? `/${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}${r.path}` : r.path)
1679
+ : apiRoutePath(serviceId ?? newServiceId, r.path);
1537
1680
  if (existingPaths.has(mountPath))
1538
1681
  continue;
1539
1682
  const routeId = uuidv7();
1540
- const routeTitle = viewMeta.get(r.viewId) ?? r.viewId;
1683
+ const routeTitle = meta?.title ?? r.viewId;
1541
1684
  appDef.routes.push({
1542
1685
  id: routeId,
1686
+ kind: renderable ? "page" : "api",
1543
1687
  path: mountPath,
1544
1688
  serviceId: newServiceId,
1545
1689
  viewId: r.viewId,
1546
1690
  targetPath: r.path,
1547
1691
  title: routeTitle,
1548
1692
  enabled: true,
1549
- methods: ["GET"]
1693
+ methods: routeMethodsFromManifest(r.methods)
1550
1694
  });
1551
1695
  existingPaths.add(mountPath);
1552
- groupChildren.push({
1553
- id: uuidv7(),
1554
- type: "link",
1555
- title: routeTitle,
1556
- routeId,
1557
- enabled: true
1558
- });
1696
+ if (meta?.pageRenderable === true && !isApiRoute({ kind: renderable ? "page" : "api", path: mountPath }, renderable)) {
1697
+ groupChildren.push({
1698
+ id: uuidv7(),
1699
+ type: "link",
1700
+ title: routeTitle,
1701
+ routeId,
1702
+ enabled: true
1703
+ });
1704
+ }
1559
1705
  }
1560
1706
  if (groupChildren.length > 0) {
1561
1707
  const menu = (appDef.menu ?? []);
@@ -1571,7 +1717,17 @@ export function registerAdminApiRoutes(app, store, cpState) {
1571
1717
  }
1572
1718
  }
1573
1719
  await store.saveConfig(config);
1574
- const adminApiBase = adminApiBaseFromControlPlane(cpState);
1720
+ if (jsonMode) {
1721
+ return jsonResponse({
1722
+ ok: true,
1723
+ tenantId,
1724
+ serviceInstanceId: newServiceId,
1725
+ serviceUrl: hostname,
1726
+ title,
1727
+ deploymentMode: service.deploymentMode
1728
+ });
1729
+ }
1730
+ const adminApiBase = API_BASE;
1575
1731
  return htmlResponse(renderWizardStep3({
1576
1732
  apiKey: "",
1577
1733
  deploymentMode: service.deploymentMode,
@@ -1602,7 +1758,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
1602
1758
  const tenantId = url.searchParams.get("tenantId") ?? undefined;
1603
1759
  const hostname = url.searchParams.get("hostname") ?? undefined;
1604
1760
  const config = await store.loadConfig();
1605
- return htmlResponse(renderWizardStep1(config, undefined, { tenantId, hostname }, adminApiBaseFromControlPlane(cpState)), 200, "text/html; mode=fragment");
1761
+ return htmlResponse(renderWizardStep1(config, undefined, { tenantId, hostname }, API_BASE), 200, "text/html; mode=fragment");
1606
1762
  });
1607
1763
  // HTMX configure: load form
1608
1764
  app.get(`${API_BASE}/configure`, async (event) => {
@@ -1771,7 +1927,7 @@ function renderWizardStep2(d) {
1771
1927
  <div class="form-text">Auto-filled from manifest. Edit if needed.</div>
1772
1928
  </div>
1773
1929
  <div class="d-flex gap-2">
1774
- <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>
1930
+ <button type="button" class="btn btn-outline-secondary" hx-get="${escapeHtml(d.adminApiBase)}/wizard/step1" hx-target="#bp-wizard-step" hx-swap="outerHTML"><- Back</button>
1775
1931
  <button type="submit" class="btn btn-primary flex-grow-1">Register Service</button>
1776
1932
  </div>
1777
1933
  </form>