@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.
- 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 +239 -191
- 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/previewEnvironments.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js +1 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.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 +23 -9
- 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/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,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 (
|
|
1051
|
-
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}.`);
|
|
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
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
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
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
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
|
-
|
|
2070
|
-
|
|
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
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
route.
|
|
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
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
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
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
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) => {
|