@betterportal/config-manager 10.6.25 → 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 (38) 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 +239 -191
  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/previewEnvironments.d.ts.map +1 -1
  18. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js +1 -0
  19. package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js.map +1 -1
  20. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +3 -1
  21. package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
  22. package/lib/plugins/service-betterportal-config-manager/storage/core.js +23 -9
  23. package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
  24. package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts +20 -0
  25. package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts.map +1 -0
  26. package/lib/plugins/service-betterportal-config-manager/storage/entities.js +162 -0
  27. package/lib/plugins/service-betterportal-config-manager/storage/entities.js.map +1 -0
  28. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +7 -0
  29. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
  30. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +201 -56
  31. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
  32. package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts +2 -0
  33. package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts.map +1 -1
  34. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js +13 -2
  35. package/lib/plugins/service-betterportal-config-manager/tenantManagement.js.map +1 -1
  36. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  37. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  38. 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.25
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.25
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.25",
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":"AACA,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;AAwJzD,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,CAyiDN;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,10 +1,12 @@
1
1
  import { isMenuRouteExcluded } from "./routeMounts.js";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import * as av from "anyvali";
3
4
  import { AppAuthRoleSchema, eventHeaders, htmlResponse, jsonResponse, resolveEmbeddedRequestContext, signServiceConfigTicket, uuidv7, BetterPortalMenuItemSchema, BetterPortalThemeConfigSchema } from "@betterportal/framework";
4
5
  import { generateApiKey, hashApiKey } from "./storage/index.js";
5
6
  import { getManifestCache } from "./syncApi.js";
6
7
  import { apiRoutePath, appRoutePatternKey, isApiRoute, pageRoutePath } from "./routeMounts.js";
7
8
  import { M2MConnectionError, approveM2MConnections, buildM2MConnectionModel, revokeM2MConnection } from "./m2mConnections.js";
9
+ import { ConfigRevisionConflictError } from "./storage/core.js";
8
10
  import { isPreviewApp } from "./previewEnvironments.js";
9
11
  const API_BASE = "/.well-known/bp/admin";
10
12
  const CONFIG_TICKET_TTL_SECONDS = 5 * 60;
@@ -70,6 +72,30 @@ function htmxError(message, status = 400) {
70
72
  function validationError(event, message) {
71
73
  return wantsHtmx(event) ? htmxError(message, 400) : jsonResponse({ error: message }, 400);
72
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
+ }
73
99
  function validateRoleId(value) {
74
100
  if (typeof value !== "string")
75
101
  return undefined;
@@ -398,18 +424,6 @@ function parseRouteCreateBody(body) {
398
424
  route.query = query.replace(/^\?+/, "");
399
425
  return { route };
400
426
  }
401
- function serviceRouteTargetKey(route) {
402
- const servicePath = route.servicePathVariant
403
- ?? route.targetPath
404
- ?? getManifestCache().get(route.serviceId)?.viewIndex[route.viewId]?.path;
405
- return servicePath
406
- ? `${route.serviceId}\u0000${route.viewId}\u0000${[...route.operations].sort().join(",")}\u0000${appRoutePatternKey(servicePath)}`
407
- : undefined;
408
- }
409
- function hasDuplicateServiceRouteTarget(appDef, route, excludeRouteId) {
410
- const key = serviceRouteTargetKey(route);
411
- return !!key && appDef.routes.some((candidate) => candidate.id !== excludeRouteId && serviceRouteTargetKey(candidate) === key);
412
- }
413
427
  function countMenuRouteReferences(items, routeId) {
414
428
  if (!Array.isArray(items))
415
429
  return 0;
@@ -623,7 +637,7 @@ function duplicateTenantService(tenant, hostname, serviceId) {
623
637
  }
624
638
  return null;
625
639
  }
626
- function collectServiceDeleteBlockers(config, tenantId, serviceId) {
640
+ export function collectServiceDeleteBlockers(config, tenantId, serviceId) {
627
641
  const blockers = [];
628
642
  const apps = config.apps.filter((app) => app.tenantId === tenantId);
629
643
  const add = (app, label, id) => {
@@ -646,6 +660,16 @@ function collectServiceDeleteBlockers(config, tenantId, serviceId) {
646
660
  add(app, "fragment", `${location}.${fragment.fragmentId}`);
647
661
  }
648
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
+ }
649
673
  if (app.auth?.serviceId === serviceId)
650
674
  add(app, "auth provider", serviceId);
651
675
  for (const role of app.auth?.roles ?? []) {
@@ -704,6 +728,26 @@ export function purgeServiceReferences(config, tenantId, serviceId) {
704
728
  summary.roleGrantsRemoved += before - role.permissions.length;
705
729
  }
706
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
+ }
707
751
  const routeIds = new Set(app.routes.filter((route) => route.serviceId === serviceId).map((route) => route.id));
708
752
  const routeCountBefore = app.routes.length;
709
753
  app.routes = app.routes.filter((route) => route.serviceId !== serviceId);
@@ -1047,8 +1091,8 @@ export function registerAdminApiRoutes(app, store, cpState) {
1047
1091
  const parsed = parseRouteCreateBody(body);
1048
1092
  if (parsed.error || !parsed.route)
1049
1093
  return validationError(event, parsed.error ?? "Invalid route");
1050
- if (hasDuplicateServiceRouteTarget(appDef, parsed.route)) {
1051
- 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}.`);
1052
1096
  }
1053
1097
  const route = { id: uuidv7(), ...parsed.route };
1054
1098
  appDef.routes.push(route);
@@ -1949,31 +1993,32 @@ export function registerAdminApiRoutes(app, store, cpState) {
1949
1993
  if (!appId)
1950
1994
  return jsonResponse({ error: "appId required" }, 400);
1951
1995
  const body = await readFormOrJsonBody(event);
1952
- const config = await store.loadConfig();
1953
- const appDef = config.apps.find((a) => a.id === appId);
1954
- if (!appDef)
1955
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1956
- const parsed = parseRouteCreateBody(body);
1957
- if (parsed.error || !parsed.route)
1958
- return validationError(event, parsed.error ?? "Invalid route.");
1959
- if (parsed.route.kind === "api")
1960
- return validationError(event, "Service/API routes are created by service manifest sync.");
1961
- const serviceError = validateRegisteredRouteService(config, appDef, parsed.route.serviceId);
1962
- if (serviceError)
1963
- return validationError(event, serviceError);
1964
- if (appDef.routes.some((candidate) => appRoutePatternKey(candidate.path) === appRoutePatternKey(parsed.route.path))) {
1965
- return validationError(event, `A route already exists at ${parsed.route.path}.`);
1966
- }
1967
- if (hasDuplicateServiceRouteTarget(appDef, parsed.route)) {
1968
- return validationError(event, "This service view and path are already mounted in this app.");
1969
- }
1970
- const route = { ...parsed.route, id: uuidv7() };
1971
- appDef.routes.push(route);
1972
- addRouteDependencies(appDef, route);
1973
- await store.saveConfig(config);
1974
- if (wantsHtmx(event))
1975
- return htmxReload(routesReloadPath(appId));
1976
- 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
+ });
1977
2022
  });
1978
2023
  app.put(`${API_BASE}/apps/:appId/routes/:routeId`, async (event) => {
1979
2024
  const appId = getParam(event, "appId");
@@ -1981,167 +2026,170 @@ export function registerAdminApiRoutes(app, store, cpState) {
1981
2026
  if (!appId || !routeId)
1982
2027
  return jsonResponse({ error: "appId and routeId required" }, 400);
1983
2028
  const body = await readFormOrJsonBody(event);
1984
- const config = await store.loadConfig();
1985
- const appDef = config.apps.find((a) => a.id === appId);
1986
- if (!appDef)
1987
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1988
- const route = appDef.routes.find((r) => r.id === routeId);
1989
- if (!route)
1990
- return wantsHtmx(event) ? htmxError("Route not found", 404) : jsonResponse({ error: "Route not found" }, 404);
1991
- if (route.kind === "api" && Object.keys(body).some((key) => key !== "enabled")) {
1992
- return validationError(event, "Service/API route identity and metadata are managed by service manifest sync.");
1993
- }
1994
- const originalServiceTargetKey = serviceRouteTargetKey(route);
1995
- if (body.path !== undefined) {
1996
- const path = trimmedString(body, "path");
1997
- if (!path)
1998
- return validationError(event, "Mount path is required.");
1999
- const pathError = validateCanonicalRoutePath(path, "Mount path");
2000
- if (pathError)
2001
- return validationError(event, pathError);
2002
- if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(path))) {
2003
- 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.");
2004
2041
  }
2005
- route.path = path;
2006
- }
2007
- if (body.serviceId !== undefined) {
2008
- const serviceId = trimmedString(body, "serviceId");
2009
- if (!serviceId)
2010
- return validationError(event, "Service is required.");
2011
- const serviceError = validateRegisteredRouteService(config, appDef, serviceId);
2012
- if (serviceError)
2013
- return validationError(event, serviceError);
2014
- route.serviceId = serviceId;
2015
- }
2016
- if (body.viewId !== undefined) {
2017
- const viewId = trimmedString(body, "viewId");
2018
- if (!viewId)
2019
- return validationError(event, "View is required.");
2020
- route.viewId = viewId;
2021
- }
2022
- if (body.operationId !== undefined) {
2023
- const operationId = trimmedString(body, "operationId");
2024
- if (!operationId)
2025
- return validationError(event, "Operation is required.");
2026
- route.operations = [operationId];
2027
- }
2028
- const manifest = getManifestCache().get(route.serviceId);
2029
- const manifestView = manifest?.viewIndex[route.viewId];
2030
- if (manifestView) {
2031
- const manifestOperation = manifestView.operations.find((operation) => route.operations.includes(operation.operationId));
2032
- if (!manifestOperation)
2033
- return validationError(event, "Selected operation is not available for this view.");
2034
- const routeIdentityChanged = body.serviceId !== undefined || body.viewId !== undefined || body.operationId !== undefined;
2035
- const requestedServicePath = trimmedString(body, "servicePathVariant")
2036
- ?? (routeIdentityChanged ? undefined : route.servicePathVariant)
2037
- ?? manifestView.path;
2038
- if (![manifestView.path, ...manifestView.pathVariants].includes(requestedServicePath)) {
2039
- 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;
2040
2053
  }
2041
- const servicePathError = validateCanonicalRoutePath(requestedServicePath, "Service path");
2042
- if (servicePathError)
2043
- return validationError(event, servicePathError);
2044
- const submittedFixed = fixedParamsFromBody(body);
2045
- const pageRenderable = manifestOperation.method === "GET" && manifestOperation.renderModes.includes("page");
2046
- const mappedAppPath = !pageRenderable
2047
- ? apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath)
2048
- : route.path;
2049
- const appParams = new Set(routeParamNames(mappedAppPath));
2050
- const fixedParams = Object.fromEntries(Object.entries(submittedFixed).filter(([name]) => routeParamNames(requestedServicePath).includes(name) && !appParams.has(name)));
2051
- const mappingError = validateRouteParamMapping(mappedAppPath, requestedServicePath, fixedParams, manifestView.paramsSchema);
2052
- if (mappingError)
2053
- return validationError(event, mappingError);
2054
- if (requestedServicePath === manifestView.path)
2055
- delete route.servicePathVariant;
2056
- else
2057
- route.servicePathVariant = requestedServicePath;
2058
- if (Object.keys(fixedParams).length > 0)
2059
- route.fixedParams = fixedParams;
2060
- else
2061
- delete route.fixedParams;
2062
- route.targetPath = requestedServicePath;
2063
- if (!pageRenderable) {
2064
- route.kind = "api";
2065
- route.path = apiRoutePath(manifest?.serviceId ?? route.serviceId, requestedServicePath);
2066
- route.title = manifestOperation.title;
2067
- 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;
2068
2062
  }
2069
- else {
2070
- 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;
2071
2068
  }
2072
- }
2073
- if (body.targetPath !== undefined) {
2074
- const targetPath = trimmedString(body, "targetPath");
2075
- if (targetPath)
2076
- route.targetPath = targetPath;
2077
- else
2078
- delete route.targetPath;
2079
- }
2080
- if (body.query !== undefined) {
2081
- const query = trimmedString(body, "query");
2082
- const renderable = manifestView?.operations.some((operation) => route.operations.includes(operation.operationId) && operation.method === "GET" && operation.renderModes.includes("page"));
2083
- if (isApiRoute(route, renderable))
2084
- delete route.query;
2085
- else if (query)
2086
- route.query = query.replace(/^\?+/, "");
2087
- else
2088
- delete route.query;
2089
- }
2090
- if (body.title !== undefined) {
2091
- const title = trimmedString(body, "title");
2092
- if (!title)
2093
- return validationError(event, "Display title is required.");
2094
- const renderable = manifestView?.operations.some((operation) => route.operations.includes(operation.operationId) && operation.method === "GET" && operation.renderModes.includes("page"));
2095
- if (!isApiRoute(route, renderable))
2096
- route.title = title;
2097
- }
2098
- if (body.enabled !== undefined) {
2099
- route.enabled = body.enabled === true || body.enabled === "true" || body.enabled === "on";
2100
- route.enablement = route.enabled ? "enabled" : "disabled";
2101
- }
2102
- if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(route.path))) {
2103
- return validationError(event, `A route already exists at ${route.path}.`);
2104
- }
2105
- const updatedServiceTargetKey = serviceRouteTargetKey(route);
2106
- if (updatedServiceTargetKey !== originalServiceTargetKey && hasDuplicateServiceRouteTarget(appDef, route, route.id)) {
2107
- return validationError(event, "This service view and path are already mounted in this app.");
2108
- }
2109
- await store.saveConfig(config);
2110
- if (wantsHtmx(event))
2111
- return htmxReload(routesReloadPath(appId, route.kind === "api" ? route.serviceId : undefined));
2112
- 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
+ });
2113
2157
  });
2114
2158
  app.delete(`${API_BASE}/apps/:appId/routes/:routeId`, async (event) => {
2115
2159
  const appId = getParam(event, "appId");
2116
2160
  const routeId = getParam(event, "routeId");
2117
2161
  if (!appId || !routeId)
2118
2162
  return jsonResponse({ error: "appId and routeId required" }, 400);
2119
- const config = await store.loadConfig();
2120
- const appDef = config.apps.find((a) => a.id === appId);
2121
- if (!appDef)
2122
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
2123
- const route = appDef.routes.find((r) => r.id === routeId);
2124
- if (!route)
2125
- return wantsHtmx(event) ? htmxAlert("Route not found.") : jsonResponse({ error: "Route not found" }, 404);
2126
- if (route.kind === "api") {
2127
- const message = "Service/API routes are managed by service manifest sync and cannot be deleted manually.";
2128
- return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2129
- }
2130
- const menuReferenceCount = countMenuRouteReferences(appDef.menu, routeId);
2131
- if (menuReferenceCount > 0) {
2132
- const message = `Cannot delete route "${route.title ?? route.path}" because ${menuReferenceCount} menu item${menuReferenceCount === 1 ? "" : "s"} reference it. Remove the menu reference first.`;
2133
- return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2134
- }
2135
- const redirectReference = Object.entries(appDef.auth?.redirects ?? {}).find(([, target]) => target?.serviceId === route.serviceId && target.viewId === route.viewId);
2136
- if (redirectReference) {
2137
- const message = `Cannot delete route "${route.title ?? route.path}" because the app uses it for auth ${redirectReference[0]} navigation.`;
2138
- return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2139
- }
2140
- appDef.routes = appDef.routes.filter((r) => r.id !== routeId);
2141
- await store.saveConfig(config);
2142
- if (wantsHtmx(event))
2143
- return htmxReload(`/routes?appId=${encodeURIComponent(appId)}`);
2144
- 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
+ });
2145
2193
  });
2146
2194
  // Menu (per app)
2147
2195
  app.get(`${API_BASE}/apps/:appId/menu`, async (event) => {