@betterportal/config-manager 10.6.28 → 10.6.30

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 (22) 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 +2 -2
  5. package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
  6. package/lib/plugins/service-betterportal-config-manager/adminApi.js +152 -148
  7. package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
  8. package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
  9. package/lib/plugins/service-betterportal-config-manager/index.js +1 -0
  10. package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
  11. package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.d.ts +1 -1
  12. package/lib/plugins/service-betterportal-config-manager/storage/appData.d.ts +230 -0
  13. package/lib/plugins/service-betterportal-config-manager/storage/appData.d.ts.map +1 -0
  14. package/lib/plugins/service-betterportal-config-manager/storage/appData.js +193 -0
  15. package/lib/plugins/service-betterportal-config-manager/storage/appData.js.map +1 -0
  16. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +5 -6
  17. package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
  18. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +185 -73
  19. package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
  20. package/lib/schemas/service-betterportal-config-manager.json +1 -1
  21. package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
  22. 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.28
4
+ * @version 10.6.30
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.28
4
+ * @version 10.6.30
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.28",
7
+ "version": "10.6.30",
8
8
  "category": "service",
9
9
  "deploymentModes": [
10
10
  "self-hosted"
@@ -31,7 +31,7 @@ declare function automationServiceCatalog(config: BetterPortalConfig, appDef: Be
31
31
  scope: "tenant" | "app";
32
32
  visibility: "public" | "protected" | "secret";
33
33
  ownership: "bp" | "plugin" | "mixed";
34
- sourceOfTruth: "bp" | "plugin" | "external";
34
+ sourceOfTruth: "external" | "bp" | "plugin";
35
35
  required: boolean;
36
36
  groupId?: string | undefined;
37
37
  order?: number | undefined;
@@ -168,7 +168,7 @@ declare function automationServiceCatalog(config: BetterPortalConfig, appDef: Be
168
168
  scope: "tenant" | "app";
169
169
  visibility: "public" | "protected" | "secret";
170
170
  ownership: "bp" | "plugin" | "mixed";
171
- sourceOfTruth: "bp" | "plugin" | "external";
171
+ sourceOfTruth: "external" | "bp" | "plugin";
172
172
  required: boolean;
173
173
  groupId?: string | undefined;
174
174
  order?: number | undefined;
@@ -1 +1 @@
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,CAwaT"}
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;AAgLzD,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,CAwiDN;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,CAwaT"}
@@ -1,12 +1,10 @@
1
1
  import { isMenuRouteExcluded } from "./routeMounts.js";
2
- import { setTimeout as delay } from "node:timers/promises";
3
2
  import * as av from "anyvali";
4
3
  import { AppAuthRoleSchema, eventHeaders, htmlResponse, jsonResponse, resolveEmbeddedRequestContext, signServiceConfigTicket, uuidv7, BetterPortalMenuItemSchema, BetterPortalThemeConfigSchema } from "@betterportal/framework";
5
4
  import { generateApiKey, hashApiKey } from "./storage/index.js";
6
5
  import { getManifestCache } from "./syncApi.js";
7
6
  import { apiRoutePath, appRoutePatternKey, isApiRoute, pageRoutePath } from "./routeMounts.js";
8
7
  import { M2MConnectionError, approveM2MConnections, buildM2MConnectionModel, revokeM2MConnection } from "./m2mConnections.js";
9
- import { ConfigRevisionConflictError } from "./storage/core.js";
10
8
  import { isPreviewApp } from "./previewEnvironments.js";
11
9
  const API_BASE = "/.well-known/bp/admin";
12
10
  const CONFIG_TICKET_TTL_SECONDS = 5 * 60;
@@ -72,28 +70,27 @@ function htmxError(message, status = 400) {
72
70
  function validationError(event, message) {
73
71
  return wantsHtmx(event) ? htmxError(message, 400) : jsonResponse({ error: message }, 400);
74
72
  }
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
- }
73
+ // PostgreSQL edits read current rows under an app-scoped transaction.
74
+ // File stores retain their existing single-process save semantics.
75
+ async function editApp(store, appId, action) {
76
+ const apply = async (config) => {
77
+ const response = await action(config);
78
+ if (!response.ok)
79
+ throw response;
80
+ return response;
81
+ };
82
+ try {
83
+ if (store.mutateApp)
84
+ return await store.mutateApp(appId, apply);
85
+ const config = await store.loadConfig();
86
+ const response = await apply(config);
87
+ await store.saveConfig(config);
88
+ return response;
89
+ }
90
+ catch (error) {
91
+ if (error instanceof Response)
92
+ return error;
93
+ throw error;
97
94
  }
98
95
  }
99
96
  function validateRoleId(value) {
@@ -1672,10 +1669,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
1672
1669
  && service.id === mountedServiceId
1673
1670
  && service.serviceId === identity.serviceId));
1674
1671
  };
1675
- const requireServiceIdentityForAuthoritativeService = async (event, config, appDef, type) => {
1676
- const identity = await readServiceIdentity(event);
1677
- if (identity instanceof Response)
1678
- return identity;
1672
+ const requireServiceIdentityForAuthoritativeService = (identity, config, appDef, type) => {
1679
1673
  const mountedServiceId = appMountedServiceId(appDef, type);
1680
1674
  if (!serviceIdentityOwnsMountedService(config, appDef, identity, mountedServiceId)) {
1681
1675
  return jsonResponse({
@@ -1687,7 +1681,6 @@ export function registerAdminApiRoutes(app, store, cpState) {
1687
1681
  }
1688
1682
  return undefined;
1689
1683
  };
1690
- const requireServiceIdentityForAppAuth = async (event, config, appDef) => requireServiceIdentityForAuthoritativeService(event, config, appDef, "auth");
1691
1684
  const parseSyncedRoles = (appDef, roles) => {
1692
1685
  const parsedRoles = [];
1693
1686
  for (const rawRole of roles) {
@@ -1766,34 +1759,52 @@ export function registerAdminApiRoutes(app, store, cpState) {
1766
1759
  const roles = Array.isArray(body.roles) ? body.roles : undefined;
1767
1760
  if (!roles)
1768
1761
  return jsonResponse({ error: "roles array required" }, 400);
1769
- const { config, appDef } = await getAppOr404(appId);
1770
- if (!appDef)
1771
- return jsonResponse({ error: "App not found" }, 404);
1772
- const serviceError = await requireServiceIdentityForAppAuth(event, config, appDef);
1773
- if (serviceError)
1774
- return serviceError;
1775
- const authResult = requireAuthBlock(event, appDef);
1776
- if (authResult.response)
1777
- return authResult.response;
1778
- const parsedRoles = parseSyncedRoles(appDef, roles);
1779
- if (parsedRoles instanceof Response)
1780
- return parsedRoles;
1781
- authResult.auth.roles = parsedRoles;
1782
- await store.saveConfig(config);
1783
- return jsonResponse({ ok: true, roles: parsedRoles.length });
1762
+ const identity = await readServiceIdentity(event);
1763
+ if (identity instanceof Response)
1764
+ return identity;
1765
+ return editApp(store, appId, async (config) => {
1766
+ const appDef = config.apps.find(app => app.id === appId);
1767
+ if (!appDef)
1768
+ return jsonResponse({ error: "App not found" }, 404);
1769
+ const serviceError = requireServiceIdentityForAuthoritativeService(identity, config, appDef, "auth");
1770
+ if (serviceError)
1771
+ return serviceError;
1772
+ const authResult = requireAuthBlock(event, appDef);
1773
+ if (authResult.response)
1774
+ return authResult.response;
1775
+ const parsedRoles = parseSyncedRoles(appDef, roles);
1776
+ if (parsedRoles instanceof Response)
1777
+ return parsedRoles;
1778
+ authResult.auth.roles = parsedRoles;
1779
+ return jsonResponse({ ok: true, roles: parsedRoles.length });
1780
+ });
1784
1781
  });
1785
1782
  app.put(`${API_BASE}/services/self-mutation`, async (event) => {
1786
1783
  const parsed = SelfMutationRequestSchema.safeParse(await readJsonBody(event));
1787
1784
  if (!parsed.success)
1788
1785
  return jsonResponse({ error: "Invalid self-mutation request", issues: parsed.issues }, 400);
1789
1786
  const { tenantId, appId, type, mutation } = parsed.data;
1790
- const { config, appDef } = await getAppOr404(appId);
1791
- if (!appDef || appDef.tenantId !== tenantId)
1792
- return jsonResponse({ error: "App not found" }, 404);
1793
- const serviceError = await requireServiceIdentityForAuthoritativeService(event, config, appDef, type);
1794
- if (serviceError)
1795
- return serviceError;
1796
- if (type === "auth") {
1787
+ const identity = await readServiceIdentity(event);
1788
+ if (identity instanceof Response)
1789
+ return identity;
1790
+ if (type === "theme") {
1791
+ const { config, appDef } = await getAppOr404(appId);
1792
+ if (!appDef || appDef.tenantId !== tenantId)
1793
+ return jsonResponse({ error: "App not found" }, 404);
1794
+ const serviceError = requireServiceIdentityForAuthoritativeService(identity, config, appDef, type);
1795
+ if (serviceError)
1796
+ return serviceError;
1797
+ appDef.themeConfig = mutation.themeConfig;
1798
+ await store.saveConfig(config);
1799
+ return jsonResponse({ ok: true, type });
1800
+ }
1801
+ return editApp(store, appId, async (config) => {
1802
+ const appDef = config.apps.find(app => app.id === appId);
1803
+ if (!appDef || appDef.tenantId !== tenantId)
1804
+ return jsonResponse({ error: "App not found" }, 404);
1805
+ const serviceError = requireServiceIdentityForAuthoritativeService(identity, config, appDef, type);
1806
+ if (serviceError)
1807
+ return serviceError;
1797
1808
  const authResult = requireAuthBlock(event, appDef);
1798
1809
  if (authResult.response)
1799
1810
  return authResult.response;
@@ -1801,12 +1812,8 @@ export function registerAdminApiRoutes(app, store, cpState) {
1801
1812
  if (parsedRoles instanceof Response)
1802
1813
  return parsedRoles;
1803
1814
  authResult.auth.roles = parsedRoles;
1804
- await store.saveConfig(config);
1805
1815
  return jsonResponse({ ok: true, type, roles: parsedRoles.length });
1806
- }
1807
- appDef.themeConfig = mutation.themeConfig;
1808
- await store.saveConfig(config);
1809
- return jsonResponse({ ok: true, type });
1816
+ });
1810
1817
  });
1811
1818
  app.post(`${API_BASE}/apps/:appId/auth/roles`, async (event) => {
1812
1819
  const appId = getParam(event, "appId");
@@ -1821,27 +1828,28 @@ export function registerAdminApiRoutes(app, store, cpState) {
1821
1828
  const message = "role id must start with a letter or number, may contain letters, numbers, dot, underscore, colon, or dash, and cannot be reserved";
1822
1829
  return wantsHtmx(event) ? htmxError(message) : jsonResponse({ error: message }, 400);
1823
1830
  }
1824
- const { config, appDef } = await getAppOr404(appId);
1825
- if (!appDef)
1826
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1827
- const authResult = requireAuthBlock(event, appDef);
1828
- if (authResult.response)
1829
- return authResult.response;
1830
- const auth = authResult.auth;
1831
- const role = {
1832
- id: roleId,
1833
- title,
1834
- description: body.description,
1835
- permissions: Array.isArray(body.permissions) ? body.permissions : []
1836
- };
1837
- if (auth.roles.some((r) => r.id === role.id)) {
1838
- return wantsHtmx(event) ? htmxError("role id already exists", 409) : jsonResponse({ error: "role id already exists" }, 409);
1839
- }
1840
- auth.roles.push(role);
1841
- await store.saveConfig(config);
1842
- if (wantsHtmx(event))
1843
- return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1844
- return jsonResponse(role, 201);
1831
+ return editApp(store, appId, async (config) => {
1832
+ const appDef = config.apps.find(app => app.id === appId);
1833
+ if (!appDef)
1834
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1835
+ const authResult = requireAuthBlock(event, appDef);
1836
+ if (authResult.response)
1837
+ return authResult.response;
1838
+ const auth = authResult.auth;
1839
+ const role = {
1840
+ id: roleId,
1841
+ title,
1842
+ description: body.description,
1843
+ permissions: Array.isArray(body.permissions) ? body.permissions : []
1844
+ };
1845
+ if (auth.roles.some((r) => r.id === role.id)) {
1846
+ return wantsHtmx(event) ? htmxError("role id already exists", 409) : jsonResponse({ error: "role id already exists" }, 409);
1847
+ }
1848
+ auth.roles.push(role);
1849
+ if (wantsHtmx(event))
1850
+ return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1851
+ return jsonResponse(role, 201);
1852
+ });
1845
1853
  });
1846
1854
  app.put(`${API_BASE}/apps/:appId/auth/roles/:roleId`, async (event) => {
1847
1855
  const appId = getParam(event, "appId");
@@ -1849,62 +1857,64 @@ export function registerAdminApiRoutes(app, store, cpState) {
1849
1857
  if (!appId || !roleId)
1850
1858
  return jsonResponse({ error: "appId + roleId required" }, 400);
1851
1859
  const body = await readFormOrJsonBody(event);
1852
- const { config, appDef } = await getAppOr404(appId);
1853
- if (!appDef)
1854
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1855
- const authResult = requireAuthBlock(event, appDef);
1856
- if (authResult.response)
1857
- return authResult.response;
1858
- const auth = authResult.auth;
1859
- const role = auth.roles.find((r) => r.id === roleId);
1860
- if (!role)
1861
- return wantsHtmx(event) ? htmxError("Role not found", 404) : jsonResponse({ error: "Role not found" }, 404);
1862
- if (typeof body.title === "string")
1863
- role.title = body.title;
1864
- if (typeof body.description === "string" || body.description === null)
1865
- role.description = body.description ?? undefined;
1866
- if (Array.isArray(body.permissions)) {
1867
- role.permissions = body.permissions;
1868
- }
1869
- else if (typeof body.grant === "string" || Array.isArray(body.grant)) {
1870
- const grants = Array.isArray(body.grant) ? body.grant : [body.grant];
1871
- const byView = new Map();
1872
- for (const grant of grants) {
1873
- const [serviceId, viewId, action] = String(grant).split("|");
1874
- if (!serviceId || !viewId || !["read", "create", "update", "delete"].includes(action))
1875
- continue;
1876
- const key = `${serviceId}::${viewId}`;
1877
- if (!byView.has(key))
1878
- byView.set(key, { serviceId, viewId, permissions: [] });
1879
- byView.get(key).permissions.push(action);
1860
+ return editApp(store, appId, async (config) => {
1861
+ const appDef = config.apps.find(app => app.id === appId);
1862
+ if (!appDef)
1863
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1864
+ const authResult = requireAuthBlock(event, appDef);
1865
+ if (authResult.response)
1866
+ return authResult.response;
1867
+ const auth = authResult.auth;
1868
+ const role = auth.roles.find((r) => r.id === roleId);
1869
+ if (!role)
1870
+ return wantsHtmx(event) ? htmxError("Role not found", 404) : jsonResponse({ error: "Role not found" }, 404);
1871
+ if (typeof body.title === "string")
1872
+ role.title = body.title;
1873
+ if (typeof body.description === "string" || body.description === null)
1874
+ role.description = body.description ?? undefined;
1875
+ if (Array.isArray(body.permissions)) {
1876
+ role.permissions = body.permissions;
1880
1877
  }
1881
- role.permissions = Array.from(byView.values());
1882
- }
1883
- await store.saveConfig(config);
1884
- if (wantsHtmx(event))
1885
- return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1886
- return jsonResponse(role);
1878
+ else if (typeof body.grant === "string" || Array.isArray(body.grant)) {
1879
+ const grants = Array.isArray(body.grant) ? body.grant : [body.grant];
1880
+ const byView = new Map();
1881
+ for (const grant of grants) {
1882
+ const [serviceId, viewId, action] = String(grant).split("|");
1883
+ if (!serviceId || !viewId || !["read", "create", "update", "delete"].includes(action))
1884
+ continue;
1885
+ const key = `${serviceId}::${viewId}`;
1886
+ if (!byView.has(key))
1887
+ byView.set(key, { serviceId, viewId, permissions: [] });
1888
+ byView.get(key).permissions.push(action);
1889
+ }
1890
+ role.permissions = Array.from(byView.values());
1891
+ }
1892
+ if (wantsHtmx(event))
1893
+ return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1894
+ return jsonResponse(role);
1895
+ });
1887
1896
  });
1888
1897
  app.delete(`${API_BASE}/apps/:appId/auth/roles/:roleId`, async (event) => {
1889
1898
  const appId = getParam(event, "appId");
1890
1899
  const roleId = getParam(event, "roleId");
1891
1900
  if (!appId || !roleId)
1892
1901
  return jsonResponse({ error: "appId + roleId required" }, 400);
1893
- const { config, appDef } = await getAppOr404(appId);
1894
- if (!appDef)
1895
- return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1896
- const authResult = requireAuthBlock(event, appDef);
1897
- if (authResult.response)
1898
- return authResult.response;
1899
- const auth = authResult.auth;
1900
- const before = auth.roles.length;
1901
- auth.roles = auth.roles.filter((r) => r.id !== roleId);
1902
- if (auth.roles.length === before)
1903
- return wantsHtmx(event) ? htmxError("Role not found", 404) : jsonResponse({ error: "Role not found" }, 404);
1904
- await store.saveConfig(config);
1905
- if (wantsHtmx(event))
1906
- return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1907
- return jsonResponse({ ok: true });
1902
+ return editApp(store, appId, async (config) => {
1903
+ const appDef = config.apps.find(app => app.id === appId);
1904
+ if (!appDef)
1905
+ return wantsHtmx(event) ? htmxError("App not found", 404) : jsonResponse({ error: "App not found" }, 404);
1906
+ const authResult = requireAuthBlock(event, appDef);
1907
+ if (authResult.response)
1908
+ return authResult.response;
1909
+ const auth = authResult.auth;
1910
+ const before = auth.roles.length;
1911
+ auth.roles = auth.roles.filter((r) => r.id !== roleId);
1912
+ if (auth.roles.length === before)
1913
+ return wantsHtmx(event) ? htmxError("Role not found", 404) : jsonResponse({ error: "Role not found" }, 404);
1914
+ if (wantsHtmx(event))
1915
+ return htmxReload(`/auth?appId=${encodeURIComponent(appId)}`);
1916
+ return jsonResponse({ ok: true });
1917
+ });
1908
1918
  });
1909
1919
  // Service connections (per app)
1910
1920
  app.get(`${API_BASE}/apps/:appId/m2m/connections`, async (event) => {
@@ -1993,8 +2003,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
1993
2003
  if (!appId)
1994
2004
  return jsonResponse({ error: "appId required" }, 400);
1995
2005
  const body = await readFormOrJsonBody(event);
1996
- return withRouteConfigRetry(event, store, async () => {
1997
- const config = await store.loadConfig();
2006
+ return editApp(store, appId, async (config) => {
1998
2007
  if (isPreviewApp(config, appId))
1999
2008
  return jsonResponse({ error: "Preview resources are managed through Preview Environments" }, 404);
2000
2009
  const appDef = config.apps.find((a) => a.id === appId);
@@ -2014,7 +2023,6 @@ export function registerAdminApiRoutes(app, store, cpState) {
2014
2023
  const route = { ...parsed.route, id: uuidv7() };
2015
2024
  appDef.routes.push(route);
2016
2025
  addRouteDependencies(appDef, route);
2017
- await store.saveConfig(config);
2018
2026
  if (wantsHtmx(event))
2019
2027
  return htmxReload(routesReloadPath(appId));
2020
2028
  return jsonResponse({ ok: true, id: route.id }, 201);
@@ -2026,8 +2034,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
2026
2034
  if (!appId || !routeId)
2027
2035
  return jsonResponse({ error: "appId and routeId required" }, 400);
2028
2036
  const body = await readFormOrJsonBody(event);
2029
- return withRouteConfigRetry(event, store, async () => {
2030
- const config = await store.loadConfig();
2037
+ return editApp(store, appId, async (config) => {
2031
2038
  if (isPreviewApp(config, appId))
2032
2039
  return jsonResponse({ error: "Preview resources are managed through Preview Environments" }, 404);
2033
2040
  const appDef = config.apps.find((a) => a.id === appId);
@@ -2149,7 +2156,6 @@ export function registerAdminApiRoutes(app, store, cpState) {
2149
2156
  if (appDef.routes.some((candidate) => candidate.id !== route.id && appRoutePatternKey(candidate.path) === appRoutePatternKey(route.path))) {
2150
2157
  return validationError(event, `A route already exists at ${route.path}.`);
2151
2158
  }
2152
- await store.saveConfig(config);
2153
2159
  if (wantsHtmx(event))
2154
2160
  return htmxReload(routesReloadPath(appId, route.kind === "api" ? route.serviceId : undefined));
2155
2161
  return jsonResponse({ ok: true });
@@ -2160,8 +2166,7 @@ export function registerAdminApiRoutes(app, store, cpState) {
2160
2166
  const routeId = getParam(event, "routeId");
2161
2167
  if (!appId || !routeId)
2162
2168
  return jsonResponse({ error: "appId and routeId required" }, 400);
2163
- return withRouteConfigRetry(event, store, async () => {
2164
- const config = await store.loadConfig();
2169
+ return editApp(store, appId, async (config) => {
2165
2170
  if (isPreviewApp(config, appId))
2166
2171
  return jsonResponse({ error: "Preview resources are managed through Preview Environments" }, 404);
2167
2172
  const appDef = config.apps.find((a) => a.id === appId);
@@ -2185,7 +2190,6 @@ export function registerAdminApiRoutes(app, store, cpState) {
2185
2190
  return wantsHtmx(event) ? htmxAlert(message, "warning") : jsonResponse({ error: message }, 409);
2186
2191
  }
2187
2192
  appDef.routes = appDef.routes.filter((r) => r.id !== routeId);
2188
- await store.saveConfig(config);
2189
2193
  if (wantsHtmx(event))
2190
2194
  return htmxReload(`/routes?appId=${encodeURIComponent(appId)}`);
2191
2195
  return jsonResponse({ ok: true });
@@ -2208,20 +2212,20 @@ export function registerAdminApiRoutes(app, store, cpState) {
2208
2212
  return jsonResponse({ error: "appId required" }, 400);
2209
2213
  const body = await readJsonBody(event);
2210
2214
  const items = Array.isArray(body.items) ? body.items : [];
2211
- const config = await store.loadConfig();
2212
- const appDef = config.apps.find((a) => a.id === appId);
2213
- if (!appDef)
2214
- return jsonResponse({ error: "App not found" }, 404);
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);
2215
+ return editApp(store, appId, async (config) => {
2216
+ const appDef = config.apps.find((a) => a.id === appId);
2217
+ if (!appDef)
2218
+ return jsonResponse({ error: "App not found" }, 404);
2219
+ const menu = items.map(normalizeMenuItem);
2220
+ const invalid = (entries) => entries.some(item => {
2221
+ const route = item.routeId ? appDef.routes.find(route => route.id === item.routeId) : undefined;
2222
+ return (Boolean(item.routeId) && (!route || isMenuRouteExcluded(route, getManifestCache()))) || invalid(item.children);
2223
+ });
2224
+ if (invalid(menu))
2225
+ return jsonResponse({ error: "Menu contains a missing or excluded route" }, 400);
2226
+ appDef.menu = menu;
2227
+ return jsonResponse({ ok: true });
2219
2228
  });
2220
- if (invalid(menu))
2221
- return jsonResponse({ error: "Menu contains a missing or excluded route" }, 400);
2222
- appDef.menu = menu;
2223
- await store.saveConfig(config);
2224
- return jsonResponse({ ok: true });
2225
2229
  });
2226
2230
  // Full config (read-only)
2227
2231
  app.get(`${API_BASE}/config`, async () => {