@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.
- package/lib/.bsb/clients/service-betterportal-config-manager.d.ts +1 -1
- package/lib/.bsb/clients/service-betterportal-config-manager.js +1 -1
- package/lib/bp-contracts/org.betterportal.config-manager.json +1 -1
- package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts +1 -0
- package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/adminApi.js +257 -196
- package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js +2 -2
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.js +20 -19
- package/lib/plugins/service-betterportal-config-manager/bp-routes/routes/_renderer.bootstrap5/GET.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/index.d.ts +0 -1
- package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/index.js +3 -5
- package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/menuEditor.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/menuEditor.js +58 -6
- package/lib/plugins/service-betterportal-config-manager/menuEditor.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js +5 -3
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/routeMounts.d.ts +16 -0
- package/lib/plugins/service-betterportal-config-manager/routeMounts.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/routeMounts.js +8 -0
- package/lib/plugins/service-betterportal-config-manager/routeMounts.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +3 -1
- package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/core.js +28 -10
- package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts +20 -0
- package/lib/plugins/service-betterportal-config-manager/storage/entities.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/entities.js +162 -0
- package/lib/plugins/service-betterportal-config-manager/storage/entities.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +7 -0
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +201 -56
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +8 -1
- package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/syncApi.js +9 -2
- package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts +2 -0
- package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/tenantManagement.js +13 -2
- package/lib/plugins/service-betterportal-config-manager/tenantManagement.js.map +1 -1
- package/lib/schemas/service-betterportal-config-manager.json +1 -1
- package/lib/schemas/service-betterportal-config-manager.plugin.json +1 -1
- 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.
|
|
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.
|
|
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.
|
|
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":"
|
|
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
|
|
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 (
|
|
1046
|
-
return validationError(event,
|
|
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
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
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
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
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
|
-
|
|
2065
|
-
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
route.
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
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
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
2392
|
+
authStatus: "auto",
|
|
2332
2393
|
children: groupChildren
|
|
2333
2394
|
});
|
|
2334
2395
|
}
|