@betterportal/config-manager 10.6.24 → 10.6.26

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 (49) 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/bp-contracts/org.betterportal.config-manager.json +1 -1
  4. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts +1 -0
  5. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
  6. package/lib/plugins/service-betterportal-config-manager/adminApi.js +257 -196
  7. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
  8. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js +2 -2
  9. package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js.map +1 -1
  10. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.d.ts.map +1 -1
  11. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.js +20 -19
  12. package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.js.map +1 -1
  13. package/lib/plugins/service-betterportal-config-manager/index.d.ts +0 -1
  14. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
  15. package/lib/plugins/service-betterportal-config-manager/index.js +3 -5
  16. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
  17. package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts.map +1 -1
  18. package/lib/plugins/service-betterportal-config-manager/menuEditor.js +58 -6
  19. package/lib/plugins/service-betterportal-config-manager/menuEditor.js.map +1 -1
  20. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts.map +1 -1
  21. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js +5 -3
  22. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js.map +1 -1
  23. package/lib/plugins/service-betterportal-config-manager/routeMounts.d.ts +16 -0
  24. package/lib/plugins/service-betterportal-config-manager/routeMounts.d.ts.map +1 -1
  25. package/lib/plugins/service-betterportal-config-manager/routeMounts.js +8 -0
  26. package/lib/plugins/service-betterportal-config-manager/routeMounts.js.map +1 -1
  27. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +3 -1
  28. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
  29. package/lib/plugins/service-betterportal-config-manager/storage/core.js +28 -10
  30. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
  31. package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts +20 -0
  32. package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts.map +1 -0
  33. package/lib/plugins/service-betterportal-config-manager/storage/entities.js +162 -0
  34. package/lib/plugins/service-betterportal-config-manager/storage/entities.js.map +1 -0
  35. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +7 -0
  36. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
  37. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +201 -56
  38. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
  39. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +8 -1
  40. package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
  41. package/lib/plugins/service-betterportal-config-manager/syncApi.js +9 -2
  42. package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
  43. package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts +2 -0
  44. package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts.map +1 -1
  45. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js +13 -2
  46. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js.map +1 -1
  47. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  48. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  49. 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.6.24
4
+ * @version 10.6.26
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.6.24
4
+ * @version 10.6.26
5
5
  */
6
6
  import { ServiceClient, importPortableSchema, createFireAndForgetEvent, createBroadcastEvent, createEventSchemas } from "@bsb/base";
7
7
  const _webhookDeliveryAvailableSchema = importPortableSchema(JSON.parse("{\"anyvaliVersion\":\"1.0\",\"schemaVersion\":\"1.1\",\"root\":{\"kind\":\"object\",\"properties\":{},\"required\":[],\"unknownKeys\":\"strip\"},\"definitions\":{},\"extensions\":{}}"));
@@ -4,7 +4,7 @@
4
4
  "pluginId": "org.betterportal.config-manager",
5
5
  "title": "BetterPortal Config Manager",
6
6
  "description": "Admin-facing BetterPortal service for discovering and managing service config surfaces.",
7
- "version": "10.6.24",
7
+ "version": "10.6.26",
8
8
  "category": "service",
9
9
  "deploymentModes": [
10
10
  "self-hosted"
@@ -292,6 +292,7 @@ declare function automationServiceCatalog(config: BetterPortalConfig, appDef: Be
292
292
  })[];
293
293
  };
294
294
  export declare function renderAutomationLlmsApi(catalog: ReturnType<typeof automationServiceCatalog>): string;
295
+ export declare function collectServiceDeleteBlockers(config: BetterPortalConfig, tenantId: string, serviceId: string): string[];
295
296
  export declare function purgeServiceReferences(config: BetterPortalConfig, tenantId: string, serviceId: string): {
296
297
  routesRemoved: number;
297
298
  menuItemsRemoved: number;
@@ -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,EACnB,SAAS,EACV,MAAM,yBAAyB,CAAC;AAcjC,OAAO,KAAK,EAAuE,kBAAkB,EAAE,eAAe,EAAgE,MAAM,yBAAyB,CAAC;AACtN,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAoJzD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GACvC,MAAM,GAAG,SAAS,CAepB;AAwGD,iBAAS,wBAAwB,CAAC,MAAM,EAAE,kBAAkB,EAAE,MAAM,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkEpF;AAGD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,wBAAwB,CAAC,GAAG,MAAM,CA4CpG;AA4VD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG;IACvG,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;CACzB,CAwEA;AAkPD,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,mBAAmB,EAC1B,OAAO,EAAE,gBAAgB,GACxB,IAAI,CAiiDN;AAwPD,wBAAgB,uBAAuB,CAAC,CAAC,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD,GAAG,MAAM,CAkaT"}
1
+ {"version":3,"file":"adminApi.d.ts","sourceRoot":"","sources":["../../../src/plugins/service-betterportal-config-manager/adminApi.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,iBAAiB,EAEjB,mBAAmB,EACnB,SAAS,EACV,MAAM,yBAAyB,CAAC;AAcjC,OAAO,KAAK,EAAuE,kBAAkB,EAAE,eAAe,EAAgE,MAAM,yBAAyB,CAAC;AACtN,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAoLzD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GACvC,MAAM,GAAG,SAAS,CAepB;AAwGD,iBAAS,wBAAwB,CAAC,MAAM,EAAE,kBAAkB,EAAE,MAAM,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkEpF;AAGD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,UAAU,CAAC,OAAO,wBAAwB,CAAC,GAAG,MAAM,CA4CpG;AA8RD,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAiDtH;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG;IACvG,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;CACzB,CA2FA;AAkPD,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,mBAAmB,EAC1B,OAAO,EAAE,gBAAgB,GACxB,IAAI,CA0iDN;AAwPD,wBAAgB,uBAAuB,CAAC,CAAC,EAAE;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAClD,GAAG,MAAM,CAkaT"}
@@ -1,9 +1,12 @@
1
+ import { isMenuRouteExcluded } from "./routeMounts.js";
2
+ import { setTimeout as delay } from "node:timers/promises";
1
3
  import * as av from "anyvali";
2
4
  import { AppAuthRoleSchema, eventHeaders, htmlResponse, jsonResponse, resolveEmbeddedRequestContext, signServiceConfigTicket, uuidv7, BetterPortalMenuItemSchema, BetterPortalThemeConfigSchema } from "@betterportal/framework";
3
5
  import { generateApiKey, hashApiKey } from "./storage/index.js";
4
6
  import { getManifestCache } from "./syncApi.js";
5
7
  import { apiRoutePath, appRoutePatternKey, isApiRoute, pageRoutePath } from "./routeMounts.js";
6
8
  import { M2MConnectionError, approveM2MConnections, buildM2MConnectionModel, revokeM2MConnection } from "./m2mConnections.js";
9
+ import { ConfigRevisionConflictError } from "./storage/core.js";
7
10
  import { isPreviewApp } from "./previewEnvironments.js";
8
11
  const API_BASE = "/.well-known/bp/admin";
9
12
  const CONFIG_TICKET_TTL_SECONDS = 5 * 60;
@@ -69,6 +72,30 @@ function htmxError(message, status = 400) {
69
72
  function validationError(event, message) {
70
73
  return wantsHtmx(event) ? htmxError(message, 400) : jsonResponse({ error: message }, 400);
71
74
  }
75
+ // Only wrap operations whose effects are confined to the config transaction.
76
+ // The callback must reload and revalidate, never resave a rejected snapshot.
77
+ async function withRouteConfigRetry(event, store, action) {
78
+ for (let attempt = 0;; attempt++) {
79
+ try {
80
+ return await action();
81
+ }
82
+ catch (error) {
83
+ if (!(error instanceof ConfigRevisionConflictError))
84
+ throw error;
85
+ store.invalidate();
86
+ if (attempt < 4) {
87
+ await delay(25 * 2 ** attempt * (1 + Math.random()));
88
+ continue;
89
+ }
90
+ const message = "Route configuration is busy; retry submission";
91
+ const response = wantsHtmx(event)
92
+ ? htmxError(message, 503)
93
+ : jsonResponse({ error: message }, 503);
94
+ response.headers.set("Retry-After", "1");
95
+ return response;
96
+ }
97
+ }
98
+ }
72
99
  function validateRoleId(value) {
73
100
  if (typeof value !== "string")
74
101
  return undefined;
@@ -95,7 +122,11 @@ function normalizeMenuItem(raw) {
95
122
  const item = raw && typeof raw === "object" ? raw : {};
96
123
  return BetterPortalMenuItemSchema.parse({
97
124
  id: typeof item.id === "string" ? item.id : uuidv7(),
98
- type: item.type === "group" ? "group" : "link",
125
+ type: item.type ?? "link",
126
+ authStatus: item.authStatus ?? "auto",
127
+ serviceStatus: item.serviceStatus ?? "show",
128
+ rolesAnyOf: item.rolesAnyOf,
129
+ defaultExpanded: item.defaultExpanded,
99
130
  title: typeof item.title === "string" ? item.title : undefined,
100
131
  icon: typeof item.icon === "string" ? item.icon : undefined,
101
132
  routeId: typeof item.routeId === "string" ? item.routeId : undefined,
@@ -393,18 +424,6 @@ function parseRouteCreateBody(body) {
393
424
  route.query = query.replace(/^\?+/, "");
394
425
  return { route };
395
426
  }
396
- function serviceRouteTargetKey(route) {
397
- const servicePath = route.servicePathVariant
398
- ?? route.targetPath
399
- ?? getManifestCache().get(route.serviceId)?.viewIndex[route.viewId]?.path;
400
- return servicePath
401
- ? `${route.serviceId}\u0000${route.viewId}\u0000${[...route.operations].sort().join(",")}\u0000${appRoutePatternKey(servicePath)}`
402
- : undefined;
403
- }
404
- function hasDuplicateServiceRouteTarget(appDef, route, excludeRouteId) {
405
- const key = serviceRouteTargetKey(route);
406
- return !!key && appDef.routes.some((candidate) => candidate.id !== excludeRouteId && serviceRouteTargetKey(candidate) === key);
407
- }
408
427
  function countMenuRouteReferences(items, routeId) {
409
428
  if (!Array.isArray(items))
410
429
  return 0;
@@ -618,7 +637,7 @@ function duplicateTenantService(tenant, hostname, serviceId) {
618
637
  }
619
638
  return null;
620
639
  }
621
- function collectServiceDeleteBlockers(config, tenantId, serviceId) {
640
+ export function collectServiceDeleteBlockers(config, tenantId, serviceId) {
622
641
  const blockers = [];
623
642
  const apps = config.apps.filter((app) => app.tenantId === tenantId);
624
643
  const add = (app, label, id) => {
@@ -641,6 +660,16 @@ function collectServiceDeleteBlockers(config, tenantId, serviceId) {
641
660
  add(app, "fragment", `${location}.${fragment.fragmentId}`);
642
661
  }
643
662
  }
663
+ for (const [shellServiceId, settings] of Object.entries(app.shellFragments ?? {})) {
664
+ if (shellServiceId === serviceId)
665
+ add(app, "shell fragments", shellServiceId);
666
+ for (const [fragmentId, setting] of Object.entries(settings)) {
667
+ const items = setting.mode === "override" ? [setting.item] : setting.mode === "items" ? setting.items : [];
668
+ if (items.some(item => item.source === "service" && item.serviceId === serviceId)) {
669
+ add(app, "shell fragment", `${shellServiceId}.${fragmentId}`);
670
+ }
671
+ }
672
+ }
644
673
  if (app.auth?.serviceId === serviceId)
645
674
  add(app, "auth provider", serviceId);
646
675
  for (const role of app.auth?.roles ?? []) {
@@ -699,6 +728,26 @@ export function purgeServiceReferences(config, tenantId, serviceId) {
699
728
  summary.roleGrantsRemoved += before - role.permissions.length;
700
729
  }
701
730
  }
731
+ for (const [shellServiceId, settings] of Object.entries(app.shellFragments ?? {})) {
732
+ if (shellServiceId === serviceId) {
733
+ summary.fragmentsRemoved += Object.keys(settings).length;
734
+ delete app.shellFragments[shellServiceId];
735
+ continue;
736
+ }
737
+ for (const [fragmentId, setting] of Object.entries(settings)) {
738
+ if (setting.mode === "override" && setting.item.source === "service" && setting.item.serviceId === serviceId) {
739
+ delete settings[fragmentId];
740
+ summary.fragmentsRemoved += 1;
741
+ }
742
+ else if (setting.mode === "items") {
743
+ const before = setting.items.length;
744
+ setting.items = setting.items.filter(item => item.source !== "service" || item.serviceId !== serviceId);
745
+ summary.fragmentsRemoved += before - setting.items.length;
746
+ }
747
+ }
748
+ if (Object.keys(settings).length === 0)
749
+ delete app.shellFragments[shellServiceId];
750
+ }
702
751
  const routeIds = new Set(app.routes.filter((route) => route.serviceId === serviceId).map((route) => route.id));
703
752
  const routeCountBefore = app.routes.length;
704
753
  app.routes = app.routes.filter((route) => route.serviceId !== serviceId);
@@ -1042,8 +1091,8 @@ export function registerAdminApiRoutes(app, store, cpState) {
1042
1091
  const parsed = parseRouteCreateBody(body);
1043
1092
  if (parsed.error || !parsed.route)
1044
1093
  return validationError(event, parsed.error ?? "Invalid route");
1045
- if (hasDuplicateServiceRouteTarget(appDef, parsed.route)) {
1046
- return validationError(event, "This service view and path are already mounted in this app.");
1094
+ if (appDef.routes.some((candidate) => appRoutePatternKey(candidate.path) === appRoutePatternKey(parsed.route.path))) {
1095
+ return validationError(event, `A route already exists at ${parsed.route.path}.`);
1047
1096
  }
1048
1097
  const route = { id: uuidv7(), ...parsed.route };
1049
1098
  appDef.routes.push(route);
@@ -1944,31 +1993,32 @@ export function registerAdminApiRoutes(app, store, cpState) {
1944
1993
  if (!appId)
1945
1994
  return jsonResponse({ error: "appId required" }, 400);
1946
1995
  const body = await readFormOrJsonBody(event);
1947
- const config = await store.loadConfig();
1948
- const appDef = config.apps.find((a) => a.id === appId);
1949
- if (!appDef)
1950
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1951
- const parsed = parseRouteCreateBody(body);
1952
- if (parsed.error || !parsed.route)
1953
- return validationError(event, parsed.error ?? "Invalid route.");
1954
- if (parsed.route.kind === "api")
1955
- return validationError(event, "Service/API routes are created by service manifest sync.");
1956
- const serviceError = validateRegisteredRouteService(config, appDef, parsed.route.serviceId);
1957
- if (serviceError)
1958
- return validationError(event, serviceError);
1959
- if (appDef.routes.some((candidate) => appRoutePatternKey(candidate.path) === appRoutePatternKey(parsed.route.path))) {
1960
- return validationError(event, `A route already exists at ${parsed.route.path}.`);
1961
- }
1962
- if (hasDuplicateServiceRouteTarget(appDef, parsed.route)) {
1963
- return validationError(event, "This service view and path are already mounted in this app.");
1964
- }
1965
- const route = { ...parsed.route, id: uuidv7() };
1966
- appDef.routes.push(route);
1967
- addRouteDependencies(appDef, route);
1968
- await store.saveConfig(config);
1969
- if (wantsHtmx(event))
1970
- return htmxReload(routesReloadPath(appId));
1971
- return jsonResponse({ ok: true, id: route.id }, 201);
1996
+ return withRouteConfigRetry(event, store, async () => {
1997
+ const config = await store.loadConfig();
1998
+ if (isPreviewApp(config, appId))
1999
+ return jsonResponse({ error: "Preview resources are managed through Preview Environments" }, 404);
2000
+ const appDef = config.apps.find((a) => a.id === appId);
2001
+ if (!appDef)
2002
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
2003
+ const parsed = parseRouteCreateBody(body);
2004
+ if (parsed.error || !parsed.route)
2005
+ return validationError(event, parsed.error ?? "Invalid route.");
2006
+ if (parsed.route.kind === "api")
2007
+ return validationError(event, "Service/API routes are created by service manifest sync.");
2008
+ const serviceError = validateRegisteredRouteService(config, appDef, parsed.route.serviceId);
2009
+ if (serviceError)
2010
+ return validationError(event, serviceError);
2011
+ if (appDef.routes.some((candidate) => appRoutePatternKey(candidate.path) === appRoutePatternKey(parsed.route.path))) {
2012
+ return validationError(event, `A route already exists at ${parsed.route.path}.`);
2013
+ }
2014
+ const route = { ...parsed.route, id: uuidv7() };
2015
+ appDef.routes.push(route);
2016
+ addRouteDependencies(appDef, route);
2017
+ await store.saveConfig(config);
2018
+ if (wantsHtmx(event))
2019
+ return htmxReload(routesReloadPath(appId));
2020
+ return jsonResponse({ ok: true, id: route.id }, 201);
2021
+ });
1972
2022
  });
1973
2023
  app.put(`${API_BASE}/apps/:appId/routes/:routeId`, async (event) => {
1974
2024
  const appId = getParam(event, "appId");
@@ -1976,167 +2026,170 @@ export function registerAdminApiRoutes(app, store, cpState) {
1976
2026
  if (!appId || !routeId)
1977
2027
  return jsonResponse({ error: "appId and routeId required" }, 400);
1978
2028
  const body = await readFormOrJsonBody(event);
1979
- const config = await store.loadConfig();
1980
- const appDef = config.apps.find((a) => a.id === appId);
1981
- if (!appDef)
1982
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1983
- const route = appDef.routes.find((r) => r.id === routeId);
1984
- if (!route)
1985
- return wantsHtmx(event) ? htmxError("Route not found", 404) : jsonResponse({ error: "Route not found" }, 404);
1986
- if (route.kind === "api" && Object.keys(body).some((key) => key !== "enabled")) {
1987
- return validationError(event, "Service/API route identity and metadata are managed by service manifest sync.");
1988
- }
1989
- const originalServiceTargetKey = serviceRouteTargetKey(route);
1990
- if (body.path !== undefined) {
1991
- const path = trimmedString(body, "path");
1992
- if (!path)
1993
- return validationError(event, "Mount path is required.");
1994
- const pathError = validateCanonicalRoutePath(path, "Mount path");
1995
- if (pathError)
1996
- return validationError(event, pathError);
1997
- if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(path))) {
1998
- return validationError(event, `A route already exists at ${path}.`);
2029
+ return withRouteConfigRetry(event, store, async () => {
2030
+ const config = await store.loadConfig();
2031
+ if (isPreviewApp(config, appId))
2032
+ return jsonResponse({ error: "Preview resources are managed through Preview Environments" }, 404);
2033
+ const appDef = config.apps.find((a) => a.id === appId);
2034
+ if (!appDef)
2035
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
2036
+ const route = appDef.routes.find((r) => r.id === routeId);
2037
+ if (!route)
2038
+ return wantsHtmx(event) ? htmxError("Route not found", 404) : jsonResponse({ error: "Route not found" }, 404);
2039
+ if (route.kind === "api" && Object.keys(body).some((key) => key !== "enabled")) {
2040
+ return validationError(event, "Service/API route identity and metadata are managed by service manifest sync.");
1999
2041
  }
2000
- route.path = path;
2001
- }
2002
- if (body.serviceId !== undefined) {
2003
- const serviceId = trimmedString(body, "serviceId");
2004
- if (!serviceId)
2005
- return validationError(event, "Service is required.");
2006
- const serviceError = validateRegisteredRouteService(config, appDef, serviceId);
2007
- if (serviceError)
2008
- return validationError(event, serviceError);
2009
- route.serviceId = serviceId;
2010
- }
2011
- if (body.viewId !== undefined) {
2012
- const viewId = trimmedString(body, "viewId");
2013
- if (!viewId)
2014
- return validationError(event, "View is required.");
2015
- route.viewId = viewId;
2016
- }
2017
- if (body.operationId !== undefined) {
2018
- const operationId = trimmedString(body, "operationId");
2019
- if (!operationId)
2020
- return validationError(event, "Operation is required.");
2021
- route.operations = [operationId];
2022
- }
2023
- const manifest = getManifestCache().get(route.serviceId);
2024
- const manifestView = manifest?.viewIndex[route.viewId];
2025
- if (manifestView) {
2026
- const manifestOperation = manifestView.operations.find((operation) => route.operations.includes(operation.operationId));
2027
- if (!manifestOperation)
2028
- return validationError(event, "Selected operation is not available for this view.");
2029
- const routeIdentityChanged = body.serviceId !== undefined || body.viewId !== undefined || body.operationId !== undefined;
2030
- const requestedServicePath = trimmedString(body, "servicePathVariant")
2031
- ?? (routeIdentityChanged ? undefined : route.servicePathVariant)
2032
- ?? manifestView.path;
2033
- if (![manifestView.path, ...manifestView.pathVariants].includes(requestedServicePath)) {
2034
- return validationError(event, "Selected service path is not available for this view.");
2042
+ if (body.path !== undefined) {
2043
+ const path = trimmedString(body, "path");
2044
+ if (!path)
2045
+ return validationError(event, "Mount path is required.");
2046
+ const pathError = validateCanonicalRoutePath(path, "Mount path");
2047
+ if (pathError)
2048
+ return validationError(event, pathError);
2049
+ if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(path))) {
2050
+ return validationError(event, `A route already exists at ${path}.`);
2051
+ }
2052
+ route.path = path;
2035
2053
  }
2036
- const servicePathError = validateCanonicalRoutePath(requestedServicePath, "Service path");
2037
- if (servicePathError)
2038
- return validationError(event, servicePathError);
2039
- const submittedFixed = fixedParamsFromBody(body);
2040
- const pageRenderable = manifestOperation.method === "GET" && manifestOperation.renderModes.includes("page");
2041
- const mappedAppPath = !pageRenderable
2042
- ? apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath)
2043
- : route.path;
2044
- const appParams = new Set(routeParamNames(mappedAppPath));
2045
- const fixedParams = Object.fromEntries(Object.entries(submittedFixed).filter(([name]) => routeParamNames(requestedServicePath).includes(name) && !appParams.has(name)));
2046
- const mappingError = validateRouteParamMapping(mappedAppPath, requestedServicePath, fixedParams, manifestView.paramsSchema);
2047
- if (mappingError)
2048
- return validationError(event, mappingError);
2049
- if (requestedServicePath === manifestView.path)
2050
- delete route.servicePathVariant;
2051
- else
2052
- route.servicePathVariant = requestedServicePath;
2053
- if (Object.keys(fixedParams).length > 0)
2054
- route.fixedParams = fixedParams;
2055
- else
2056
- delete route.fixedParams;
2057
- route.targetPath = requestedServicePath;
2058
- if (!pageRenderable) {
2059
- route.kind = "api";
2060
- route.path = apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath);
2061
- route.title = manifestOperation.title;
2062
- delete route.query;
2054
+ if (body.serviceId !== undefined) {
2055
+ const serviceId = trimmedString(body, "serviceId");
2056
+ if (!serviceId)
2057
+ return validationError(event, "Service is required.");
2058
+ const serviceError = validateRegisteredRouteService(config, appDef, serviceId);
2059
+ if (serviceError)
2060
+ return validationError(event, serviceError);
2061
+ route.serviceId = serviceId;
2063
2062
  }
2064
- else {
2065
- route.kind = "page";
2063
+ if (body.viewId !== undefined) {
2064
+ const viewId = trimmedString(body, "viewId");
2065
+ if (!viewId)
2066
+ return validationError(event, "View is required.");
2067
+ route.viewId = viewId;
2066
2068
  }
2067
- }
2068
- if (body.targetPath !== undefined) {
2069
- const targetPath = trimmedString(body, "targetPath");
2070
- if (targetPath)
2071
- route.targetPath = targetPath;
2072
- else
2073
- delete route.targetPath;
2074
- }
2075
- if (body.query !== undefined) {
2076
- const query = trimmedString(body, "query");
2077
- const renderable = manifestView?.operations.some((operation) => route.operations.includes(operation.operationId) && operation.method === "GET" && operation.renderModes.includes("page"));
2078
- if (isApiRoute(route, renderable))
2079
- delete route.query;
2080
- else if (query)
2081
- route.query = query.replace(/^\?+/, "");
2082
- else
2083
- delete route.query;
2084
- }
2085
- if (body.title !== undefined) {
2086
- const title = trimmedString(body, "title");
2087
- if (!title)
2088
- return validationError(event, "Display title is required.");
2089
- const renderable = manifestView?.operations.some((operation) => route.operations.includes(operation.operationId) && operation.method === "GET" && operation.renderModes.includes("page"));
2090
- if (!isApiRoute(route, renderable))
2091
- route.title = title;
2092
- }
2093
- if (body.enabled !== undefined) {
2094
- route.enabled = body.enabled === true || body.enabled === "true" || body.enabled === "on";
2095
- route.enablement = route.enabled ? "enabled" : "disabled";
2096
- }
2097
- if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(route.path))) {
2098
- return validationError(event, `A route already exists at ${route.path}.`);
2099
- }
2100
- const updatedServiceTargetKey = serviceRouteTargetKey(route);
2101
- if (updatedServiceTargetKey !== originalServiceTargetKey && hasDuplicateServiceRouteTarget(appDef, route, route.id)) {
2102
- return validationError(event, "This service view and path are already mounted in this app.");
2103
- }
2104
- await store.saveConfig(config);
2105
- if (wantsHtmx(event))
2106
- return htmxReload(routesReloadPath(appId, route.kind === "api" ? route.serviceId : undefined));
2107
- return jsonResponse({ ok: true });
2069
+ if (body.operationId !== undefined) {
2070
+ const operationId = trimmedString(body, "operationId");
2071
+ if (!operationId)
2072
+ return validationError(event, "Operation is required.");
2073
+ route.operations = [operationId];
2074
+ }
2075
+ const manifest = getManifestCache().get(route.serviceId);
2076
+ const manifestView = manifest?.viewIndex[route.viewId];
2077
+ if (manifestView) {
2078
+ const manifestOperation = manifestView.operations.find((operation) => route.operations.includes(operation.operationId));
2079
+ if (!manifestOperation)
2080
+ return validationError(event, "Selected operation is not available for this view.");
2081
+ const routeIdentityChanged = body.serviceId !== undefined || body.viewId !== undefined || body.operationId !== undefined;
2082
+ const requestedServicePath = trimmedString(body, "servicePathVariant")
2083
+ ?? (routeIdentityChanged ? undefined : route.servicePathVariant)
2084
+ ?? manifestView.path;
2085
+ if (![manifestView.path, ...manifestView.pathVariants].includes(requestedServicePath)) {
2086
+ return validationError(event, "Selected service path is not available for this view.");
2087
+ }
2088
+ const servicePathError = validateCanonicalRoutePath(requestedServicePath, "Service path");
2089
+ if (servicePathError)
2090
+ return validationError(event, servicePathError);
2091
+ const submittedFixed = fixedParamsFromBody(body);
2092
+ const pageRenderable = manifestOperation.method === "GET" && manifestOperation.renderModes.includes("page");
2093
+ const mappedAppPath = !pageRenderable
2094
+ ? apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath)
2095
+ : route.path;
2096
+ const appParams = new Set(routeParamNames(mappedAppPath));
2097
+ const fixedParams = Object.fromEntries(Object.entries(submittedFixed).filter(([name]) => routeParamNames(requestedServicePath).includes(name) && !appParams.has(name)));
2098
+ const mappingError = validateRouteParamMapping(mappedAppPath, requestedServicePath, fixedParams, manifestView.paramsSchema);
2099
+ if (mappingError)
2100
+ return validationError(event, mappingError);
2101
+ if (requestedServicePath === manifestView.path)
2102
+ delete route.servicePathVariant;
2103
+ else
2104
+ route.servicePathVariant = requestedServicePath;
2105
+ if (Object.keys(fixedParams).length > 0)
2106
+ route.fixedParams = fixedParams;
2107
+ else
2108
+ delete route.fixedParams;
2109
+ route.targetPath = requestedServicePath;
2110
+ if (!pageRenderable) {
2111
+ route.kind = "api";
2112
+ route.path = apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath);
2113
+ route.title = manifestOperation.title;
2114
+ delete route.query;
2115
+ }
2116
+ else {
2117
+ route.kind = "page";
2118
+ }
2119
+ }
2120
+ if (body.targetPath !== undefined) {
2121
+ const targetPath = trimmedString(body, "targetPath");
2122
+ if (targetPath)
2123
+ route.targetPath = targetPath;
2124
+ else
2125
+ delete route.targetPath;
2126
+ }
2127
+ if (body.query !== undefined) {
2128
+ const query = trimmedString(body, "query");
2129
+ const renderable = manifestView?.operations.some((operation) => route.operations.includes(operation.operationId) && operation.method === "GET" && operation.renderModes.includes("page"));
2130
+ if (isApiRoute(route, renderable))
2131
+ delete route.query;
2132
+ else if (query)
2133
+ route.query = query.replace(/^\?+/, "");
2134
+ else
2135
+ delete route.query;
2136
+ }
2137
+ if (body.title !== undefined) {
2138
+ const title = trimmedString(body, "title");
2139
+ if (!title)
2140
+ return validationError(event, "Display title is required.");
2141
+ const renderable = manifestView?.operations.some((operation) => route.operations.includes(operation.operationId) && operation.method === "GET" && operation.renderModes.includes("page"));
2142
+ if (!isApiRoute(route, renderable))
2143
+ route.title = title;
2144
+ }
2145
+ if (body.enabled !== undefined) {
2146
+ route.enabled = body.enabled === true || body.enabled === "true" || body.enabled === "on";
2147
+ route.enablement = route.enabled ? "enabled" : "disabled";
2148
+ }
2149
+ if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(route.path))) {
2150
+ return validationError(event, `A route already exists at ${route.path}.`);
2151
+ }
2152
+ await store.saveConfig(config);
2153
+ if (wantsHtmx(event))
2154
+ return htmxReload(routesReloadPath(appId, route.kind === "api" ? route.serviceId : undefined));
2155
+ return jsonResponse({ ok: true });
2156
+ });
2108
2157
  });
2109
2158
  app.delete(`${API_BASE}/apps/:appId/routes/:routeId`, async (event) => {
2110
2159
  const appId = getParam(event, "appId");
2111
2160
  const routeId = getParam(event, "routeId");
2112
2161
  if (!appId || !routeId)
2113
2162
  return jsonResponse({ error: "appId and routeId required" }, 400);
2114
- const config = await store.loadConfig();
2115
- const appDef = config.apps.find((a) => a.id === appId);
2116
- if (!appDef)
2117
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
2118
- const route = appDef.routes.find((r) => r.id === routeId);
2119
- if (!route)
2120
- return wantsHtmx(event) ? htmxAlert("Route not found.") : jsonResponse({ error: "Route not found" }, 404);
2121
- if (route.kind === "api") {
2122
- const message = "Service/API routes are managed by service manifest sync and cannot be deleted manually.";
2123
- return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2124
- }
2125
- const menuReferenceCount = countMenuRouteReferences(appDef.menu, routeId);
2126
- if (menuReferenceCount > 0) {
2127
- const message = `Cannot delete route "${route.title ?? route.path}" because ${menuReferenceCount} menu item${menuReferenceCount === 1 ? "" : "s"} reference it. Remove the menu reference first.`;
2128
- return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2129
- }
2130
- const redirectReference = Object.entries(appDef.auth?.redirects ?? {}).find(([, target]) => target?.serviceId === route.serviceId && target.viewId === route.viewId);
2131
- if (redirectReference) {
2132
- const message = `Cannot delete route "${route.title ?? route.path}" because the app uses it for auth ${redirectReference[0]} navigation.`;
2133
- return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2134
- }
2135
- appDef.routes = appDef.routes.filter((r) => r.id !== routeId);
2136
- await store.saveConfig(config);
2137
- if (wantsHtmx(event))
2138
- return htmxReload(`/routes?appId=${encodeURIComponent(appId)}`);
2139
- return jsonResponse({ ok: true });
2163
+ return withRouteConfigRetry(event, store, async () => {
2164
+ const config = await store.loadConfig();
2165
+ if (isPreviewApp(config, appId))
2166
+ return jsonResponse({ error: "Preview resources are managed through Preview Environments" }, 404);
2167
+ const appDef = config.apps.find((a) => a.id === appId);
2168
+ if (!appDef)
2169
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
2170
+ const route = appDef.routes.find((r) => r.id === routeId);
2171
+ if (!route)
2172
+ return wantsHtmx(event) ? htmxAlert("Route not found.") : jsonResponse({ error: "Route not found" }, 404);
2173
+ if (route.kind === "api") {
2174
+ const message = "Service/API routes are managed by service manifest sync and cannot be deleted manually.";
2175
+ return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2176
+ }
2177
+ const menuReferenceCount = countMenuRouteReferences(appDef.menu, routeId);
2178
+ if (menuReferenceCount > 0) {
2179
+ const message = `Cannot delete route "${route.title ?? route.path}" because ${menuReferenceCount} menu item${menuReferenceCount === 1 ? "" : "s"} reference it. Remove the menu reference first.`;
2180
+ return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2181
+ }
2182
+ const redirectReference = Object.entries(appDef.auth?.redirects ?? {}).find(([, target]) => target?.serviceId === route.serviceId && target.viewId === route.viewId);
2183
+ if (redirectReference) {
2184
+ const message = `Cannot delete route "${route.title ?? route.path}" because the app uses it for auth ${redirectReference[0]} navigation.`;
2185
+ return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2186
+ }
2187
+ appDef.routes = appDef.routes.filter((r) => r.id !== routeId);
2188
+ await store.saveConfig(config);
2189
+ if (wantsHtmx(event))
2190
+ return htmxReload(`/routes?appId=${encodeURIComponent(appId)}`);
2191
+ return jsonResponse({ ok: true });
2192
+ });
2140
2193
  });
2141
2194
  // Menu (per app)
2142
2195
  app.get(`${API_BASE}/apps/:appId/menu`, async (event) => {
@@ -2159,7 +2212,14 @@ export function registerAdminApiRoutes(app, store, cpState) {
2159
2212
  const appDef = config.apps.find((a) => a.id === appId);
2160
2213
  if (!appDef)
2161
2214
  return jsonResponse({ error: "App not found" }, 404);
2162
- appDef.menu = items.map(normalizeMenuItem);
2215
+ const menu = items.map(normalizeMenuItem);
2216
+ const invalid = (entries) => entries.some(item => {
2217
+ const route = item.routeId ? appDef.routes.find(route => route.id === item.routeId) : undefined;
2218
+ return (Boolean(item.routeId) && (!route || isMenuRouteExcluded(route, getManifestCache()))) || invalid(item.children);
2219
+ });
2220
+ if (invalid(menu))
2221
+ return jsonResponse({ error: "Menu contains a missing or excluded route" }, 400);
2222
+ appDef.menu = menu;
2163
2223
  await store.saveConfig(config);
2164
2224
  return jsonResponse({ ok: true });
2165
2225
  });
@@ -2254,6 +2314,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
2254
2314
  operationId: operation.operationId,
2255
2315
  title: typeof operation.title === "string" ? operation.title : view.title,
2256
2316
  renderable: operation.renderable === true,
2317
+ menu: operation.menu,
2257
2318
  pageRenderable: operation.method === "GET" && hasPageRenderer({ ...view, operations: [operation] })
2258
2319
  }]
2259
2320
  : []));
@@ -2308,7 +2369,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
2308
2369
  });
2309
2370
  if (r.pageRenderable)
2310
2371
  existingPaths.add(mountPath);
2311
- if (r.pageRenderable && !isApiRoute({ kind: "page", path: mountPath }, true)) {
2372
+ if (r.pageRenderable && r.menu !== false && !isApiRoute({ kind: "page", path: mountPath }, true)) {
2312
2373
  groupChildren.push({
2313
2374
  id: uuidv7(),
2314
2375
  type: "link",
@@ -2316,7 +2377,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
2316
2377
  routeId,
2317
2378
  enabled: true,
2318
2379
  serviceStatus: "show",
2319
- authStatus: "show",
2380
+ authStatus: "auto",
2320
2381
  children: []
2321
2382
  });
2322
2383
  }
@@ -2328,7 +2389,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
2328
2389
  title,
2329
2390
  enabled: true,
2330
2391
  serviceStatus: "show",
2331
- authStatus: "show",
2392
+ authStatus: "auto",
2332
2393
  children: groupChildren
2333
2394
  });
2334
2395
  }