@betterportal/config-manager 10.4.0 → 10.6.0
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 +2169 -2
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts +1426 -0
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js +35 -0
- package/lib/plugins/service-betterportal-config-manager/.bp-generated/registry.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/adminApi.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/adminApi.js +15 -2
- package/lib/plugins/service-betterportal-config-manager/adminApi.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/DELETE.d.ts +8 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/DELETE.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/DELETE.js +10 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/DELETE.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/GET.d.ts +18 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/GET.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/GET.js +15 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/GET.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/POST.d.ts +8 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/POST.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/POST.js +10 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/POST.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/PUT.d.ts +8 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/PUT.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/PUT.js +10 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/PUT.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/DELETE.d.ts +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/DELETE.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/DELETE.js +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/DELETE.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js +172 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/GET.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/POST.d.ts +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/POST.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/POST.js +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/POST.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/PUT.d.ts +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/PUT.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/PUT.js +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/_renderer.bootstrap5/PUT.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/index.d.ts +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/index.js +2 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/preview-environments/index.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/GET.d.ts +4 -0
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/GET.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/GET.js +3 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/GET.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_renderer.bootstrap5/GET.js +1 -1
- package/lib/plugins/service-betterportal-config-manager/bp-routes/services/_renderer.bootstrap5/GET.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js +12 -15
- package/lib/plugins/service-betterportal-config-manager/fragmentsEditor.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/index.d.ts +3 -0
- package/lib/plugins/service-betterportal-config-manager/index.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/index.js +67 -18
- package/lib/plugins/service-betterportal-config-manager/index.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/previewApi.d.ts +7 -0
- package/lib/plugins/service-betterportal-config-manager/previewApi.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/previewApi.js +112 -0
- package/lib/plugins/service-betterportal-config-manager/previewApi.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.d.ts +419 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.js +398 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironmentManagement.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts +62 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js +763 -0
- package/lib/plugins/service-betterportal-config-manager/previewEnvironments.js.map +1 -0
- package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts +6 -2
- package/lib/plugins/service-betterportal-config-manager/storage/core.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/core.js +12 -2
- package/lib/plugins/service-betterportal-config-manager/storage/core.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/file.d.ts +3 -1
- package/lib/plugins/service-betterportal-config-manager/storage/file.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/file.js +3 -2
- package/lib/plugins/service-betterportal-config-manager/storage/file.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts +3 -1
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.js +3 -2
- package/lib/plugins/service-betterportal-config-manager/storage/postgres.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts +4 -1
- package/lib/plugins/service-betterportal-config-manager/syncApi.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/syncApi.js +58 -4
- package/lib/plugins/service-betterportal-config-manager/syncApi.js.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/tenantManagement.d.ts.map +1 -1
- package/lib/plugins/service-betterportal-config-manager/tenantManagement.js +12 -1
- 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 +6 -6
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { PreviewEnvironmentGroupSchema, uuidv7, verifyExternalOidcToken } from "@betterportal/framework";
|
|
3
|
+
import { generateApiKey, hashApiKey } from "./storage/core.js";
|
|
4
|
+
import { apiRoutePath, pageRoutePath } from "./routeMounts.js";
|
|
5
|
+
export const PREVIEW_DEPLOYMENT_API_BASE = "/api/preview-groups";
|
|
6
|
+
export class PreviewEnvironmentError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, status = 400) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.name = "PreviewEnvironmentError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function previewTenantIds(config) {
|
|
15
|
+
return new Set(config.previewEnvironmentDeployments.map((deployment) => deployment.tenantId));
|
|
16
|
+
}
|
|
17
|
+
export function previewAppIds(config) {
|
|
18
|
+
return new Set(config.previewEnvironmentDeployments.map((deployment) => deployment.appId));
|
|
19
|
+
}
|
|
20
|
+
export function isPreviewTenant(config, tenantId) {
|
|
21
|
+
return config.previewEnvironmentDeployments.some((deployment) => deployment.tenantId === tenantId);
|
|
22
|
+
}
|
|
23
|
+
export function isPreviewApp(config, appId) {
|
|
24
|
+
return config.previewEnvironmentDeployments.some((deployment) => deployment.appId === appId);
|
|
25
|
+
}
|
|
26
|
+
export function isPreviewService(config, instanceId) {
|
|
27
|
+
return config.previewEnvironmentDeployments.some((deployment) => deployment.services.some((service) => service.instanceId === instanceId));
|
|
28
|
+
}
|
|
29
|
+
export function visibleAdminConfig(config) {
|
|
30
|
+
const tenantIds = previewTenantIds(config);
|
|
31
|
+
const appIds = previewAppIds(config);
|
|
32
|
+
return {
|
|
33
|
+
...config,
|
|
34
|
+
tenants: config.tenants.filter((tenant) => !tenantIds.has(tenant.id)),
|
|
35
|
+
apps: config.apps.filter((app) => !appIds.has(app.id)),
|
|
36
|
+
sharedServiceActivations: config.sharedServiceActivations.filter((activation) => !tenantIds.has(activation.tenantId)),
|
|
37
|
+
m2m: {
|
|
38
|
+
bindings: config.m2m.bindings.filter((binding) => !tenantIds.has(binding.tenantId)),
|
|
39
|
+
grants: config.m2m.grants.filter((grant) => !tenantIds.has(grant.tenantId))
|
|
40
|
+
},
|
|
41
|
+
webhooks: {
|
|
42
|
+
targets: config.webhooks.targets.filter((target) => !tenantIds.has(target.tenantId))
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function createPreviewGroup(config, input, now = new Date()) {
|
|
47
|
+
const sourceTenant = config.tenants.find((tenant) => tenant.id === input.sourceTenantId);
|
|
48
|
+
const sourceApp = config.apps.find((app) => app.id === input.sourceAppId && app.tenantId === input.sourceTenantId);
|
|
49
|
+
if (!sourceTenant || !sourceApp || isPreviewTenant(config, sourceTenant.id) || isPreviewApp(config, sourceApp.id)) {
|
|
50
|
+
throw new PreviewEnvironmentError("Source tenant/app was not found", 404);
|
|
51
|
+
}
|
|
52
|
+
const serviceIds = normalizeServiceIds(input.serviceIds);
|
|
53
|
+
requireManagedSourceServices(config, sourceApp, serviceIds);
|
|
54
|
+
const apiKey = `bp_pg_${randomBytes(32).toString("base64url")}`;
|
|
55
|
+
const timestamp = now.toISOString();
|
|
56
|
+
const group = PreviewEnvironmentGroupSchema.parse({
|
|
57
|
+
id: uuidv7(),
|
|
58
|
+
name: requiredText(input.name, "Group name", 120),
|
|
59
|
+
sourceTenantId: sourceTenant.id,
|
|
60
|
+
sourceAppId: sourceApp.id,
|
|
61
|
+
expiresInDays: normalizeExpiry(input.expiresInDays),
|
|
62
|
+
apiKeyHash: hashApiKey(apiKey),
|
|
63
|
+
...(input.oidc ? { oidc: normalizeOidc(input.oidc) } : {}),
|
|
64
|
+
services: serviceIds.map((serviceId) => ({
|
|
65
|
+
serviceId,
|
|
66
|
+
title: sourceServiceForPreview(sourceTenant, sourceApp, serviceId)?.title ?? serviceId,
|
|
67
|
+
config: { tenant: {}, app: {} }
|
|
68
|
+
})),
|
|
69
|
+
createdAt: timestamp,
|
|
70
|
+
updatedAt: timestamp
|
|
71
|
+
});
|
|
72
|
+
config.previewEnvironmentGroups.push(group);
|
|
73
|
+
return { group, apiKey };
|
|
74
|
+
}
|
|
75
|
+
export function updatePreviewGroup(config, groupId, input, now = new Date()) {
|
|
76
|
+
const group = requireGroup(config, groupId);
|
|
77
|
+
group.name = requiredText(input.name, "Group name", 120);
|
|
78
|
+
group.expiresInDays = normalizeExpiry(input.expiresInDays);
|
|
79
|
+
if (input.oidc)
|
|
80
|
+
group.oidc = normalizeOidc(input.oidc);
|
|
81
|
+
else
|
|
82
|
+
delete group.oidc;
|
|
83
|
+
group.updatedAt = now.toISOString();
|
|
84
|
+
return group;
|
|
85
|
+
}
|
|
86
|
+
export function rotatePreviewGroupApiKey(config, groupId, now = new Date()) {
|
|
87
|
+
const group = requireGroup(config, groupId);
|
|
88
|
+
const apiKey = `bp_pg_${randomBytes(32).toString("base64url")}`;
|
|
89
|
+
group.apiKeyHash = hashApiKey(apiKey);
|
|
90
|
+
group.updatedAt = now.toISOString();
|
|
91
|
+
return apiKey;
|
|
92
|
+
}
|
|
93
|
+
export async function authenticatePreviewGroup(group, authorization) {
|
|
94
|
+
const token = bearerToken(authorization);
|
|
95
|
+
if (!token)
|
|
96
|
+
throw new PreviewEnvironmentError("A bearer credential is required", 401);
|
|
97
|
+
if (token.startsWith("bp_pg_")) {
|
|
98
|
+
if (!safeHashEqual(group.apiKeyHash, hashApiKey(token))) {
|
|
99
|
+
throw new PreviewEnvironmentError("Invalid preview group API key", 401);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const oidc = group.oidc;
|
|
104
|
+
if (!oidc)
|
|
105
|
+
throw new PreviewEnvironmentError("OIDC is not configured for this preview group", 401);
|
|
106
|
+
let claims;
|
|
107
|
+
try {
|
|
108
|
+
claims = await verifyExternalOidcToken(token, oidc);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new PreviewEnvironmentError("Invalid OIDC token", 401);
|
|
112
|
+
}
|
|
113
|
+
if (oidc.subjectPrefix && (typeof claims.sub !== "string" || !claims.sub.startsWith(oidc.subjectPrefix))) {
|
|
114
|
+
throw new PreviewEnvironmentError("OIDC token subject is not allowed", 401);
|
|
115
|
+
}
|
|
116
|
+
for (const [name, expected] of Object.entries(oidc.requiredClaims)) {
|
|
117
|
+
if (claims[name] !== expected)
|
|
118
|
+
throw new PreviewEnvironmentError(`OIDC claim ${name} is not allowed`, 401);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function provisionPreviewDeployment(config, groupId, input, controlPlaneUrl, now = new Date()) {
|
|
122
|
+
deleteExpiredPreviewDeployments(config, now);
|
|
123
|
+
const group = requireGroup(config, groupId);
|
|
124
|
+
const key = requiredText(input.key, "Preview key", 255);
|
|
125
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$/.test(key)) {
|
|
126
|
+
throw new PreviewEnvironmentError("Preview key must start with a letter or number and contain only letters, numbers, dot, underscore, colon or hyphen");
|
|
127
|
+
}
|
|
128
|
+
const requestedServices = normalizeRequestedServices(input.services);
|
|
129
|
+
requireExactServiceSet(group, requestedServices);
|
|
130
|
+
const existing = config.previewEnvironmentDeployments.find((deployment) => deployment.groupId === group.id && deployment.key === key);
|
|
131
|
+
if (existing) {
|
|
132
|
+
return updateDeployment(config, existing, requestedServices, controlPlaneUrl, now);
|
|
133
|
+
}
|
|
134
|
+
const sourceTenant = config.tenants.find((tenant) => tenant.id === group.sourceTenantId);
|
|
135
|
+
const sourceApp = config.apps.find((app) => app.id === group.sourceAppId && app.tenantId === group.sourceTenantId);
|
|
136
|
+
if (!sourceTenant || !sourceApp || isPreviewTenant(config, sourceTenant.id) || isPreviewApp(config, sourceApp.id)) {
|
|
137
|
+
throw new PreviewEnvironmentError("The preview group's source tenant/app is unavailable", 409);
|
|
138
|
+
}
|
|
139
|
+
const hostname = normalizeAppHostname(input.hostname);
|
|
140
|
+
if (config.apps.some((app) => app.hostnames.some((candidate) => sameHostname(candidate, hostname)))) {
|
|
141
|
+
throw new PreviewEnvironmentError("Preview hostname is already assigned to another app", 409);
|
|
142
|
+
}
|
|
143
|
+
const timestamp = now.toISOString();
|
|
144
|
+
const tenantId = uuidv7();
|
|
145
|
+
const appId = uuidv7();
|
|
146
|
+
const serviceMap = new Map();
|
|
147
|
+
const credentials = [];
|
|
148
|
+
const services = group.services.map((configured) => {
|
|
149
|
+
const requested = requestedServices.get(configured.serviceId);
|
|
150
|
+
const source = sourceServiceForPreview(sourceTenant, sourceApp, configured.serviceId);
|
|
151
|
+
const instanceId = uuidv7();
|
|
152
|
+
const apiKey = generateApiKey();
|
|
153
|
+
if (source)
|
|
154
|
+
serviceMap.set(source.id, instanceId);
|
|
155
|
+
credentials.push(issuedCredential(configured.serviceId, instanceId, requested, apiKey, controlPlaneUrl));
|
|
156
|
+
return {
|
|
157
|
+
id: instanceId,
|
|
158
|
+
hostname: requested,
|
|
159
|
+
apiKeyHash: hashApiKey(apiKey),
|
|
160
|
+
serviceId: configured.serviceId,
|
|
161
|
+
capabilities: source?.capabilities ?? [],
|
|
162
|
+
title: configured.title ?? source?.title ?? configured.serviceId,
|
|
163
|
+
...(source?.description ? { description: source.description } : {}),
|
|
164
|
+
deploymentMode: "self-hosted",
|
|
165
|
+
createdAt: timestamp,
|
|
166
|
+
enabled: true
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
const referenced = collectAppServiceReferences(sourceApp);
|
|
170
|
+
const activatedPlatformServices = sourceTenant.activatedPlatformServices.filter((serviceId) => referenced.has(serviceId));
|
|
171
|
+
for (const serviceId of activatedPlatformServices)
|
|
172
|
+
serviceMap.set(serviceId, serviceId);
|
|
173
|
+
const clonedActivations = config.sharedServiceActivations
|
|
174
|
+
.filter((activation) => activation.enabled
|
|
175
|
+
&& activation.tenantId === sourceTenant.id
|
|
176
|
+
&& referenced.has(activation.id)
|
|
177
|
+
&& (!activation.appId || activation.appId === sourceApp.id))
|
|
178
|
+
.map((activation) => {
|
|
179
|
+
const id = uuidv7();
|
|
180
|
+
serviceMap.set(activation.id, id);
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
tenantId,
|
|
184
|
+
appId,
|
|
185
|
+
sharedServiceId: activation.sharedServiceId,
|
|
186
|
+
activatedAt: timestamp,
|
|
187
|
+
enabled: true
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
const tenant = {
|
|
191
|
+
id: tenantId,
|
|
192
|
+
slug: `preview-${tenantId}`,
|
|
193
|
+
title: `${group.name}: ${input.name?.trim() || key}`,
|
|
194
|
+
active: true,
|
|
195
|
+
branding: structuredClone(sourceTenant.branding),
|
|
196
|
+
services,
|
|
197
|
+
activatedPlatformServices
|
|
198
|
+
};
|
|
199
|
+
const app = clonePreviewApp(sourceApp, appId, tenantId, input.name?.trim() || key, hostname, serviceMap);
|
|
200
|
+
const expiresInDays = effectiveExpiry(group.expiresInDays, input.expiresInDays);
|
|
201
|
+
const deployment = {
|
|
202
|
+
id: uuidv7(),
|
|
203
|
+
groupId: group.id,
|
|
204
|
+
key,
|
|
205
|
+
name: input.name?.trim() || key,
|
|
206
|
+
hostname,
|
|
207
|
+
tenantId,
|
|
208
|
+
appId,
|
|
209
|
+
expiresInDays,
|
|
210
|
+
...(expiryDate(now, expiresInDays) ? { expiresAt: expiryDate(now, expiresInDays) } : {}),
|
|
211
|
+
services: services.map((service) => ({
|
|
212
|
+
serviceId: service.serviceId,
|
|
213
|
+
instanceId: service.id,
|
|
214
|
+
url: service.hostname
|
|
215
|
+
})),
|
|
216
|
+
createdAt: timestamp,
|
|
217
|
+
updatedAt: timestamp
|
|
218
|
+
};
|
|
219
|
+
config.tenants.push(tenant);
|
|
220
|
+
config.apps.push(app);
|
|
221
|
+
config.sharedServiceActivations.push(...clonedActivations);
|
|
222
|
+
config.previewEnvironmentDeployments.push(deployment);
|
|
223
|
+
reconcilePreviewDeploymentFromCache(config, deployment);
|
|
224
|
+
return { deployment, credentials, created: true };
|
|
225
|
+
}
|
|
226
|
+
export function updatePreviewDeploymentExpiry(config, deploymentId, expiresInDays, now = new Date()) {
|
|
227
|
+
const deployment = config.previewEnvironmentDeployments.find((candidate) => candidate.id === deploymentId);
|
|
228
|
+
if (!deployment)
|
|
229
|
+
throw new PreviewEnvironmentError("Preview deployment was not found", 404);
|
|
230
|
+
deployment.expiresInDays = normalizeExpiry(expiresInDays);
|
|
231
|
+
const expiresAt = expiryDate(now, deployment.expiresInDays);
|
|
232
|
+
if (expiresAt)
|
|
233
|
+
deployment.expiresAt = expiresAt;
|
|
234
|
+
else
|
|
235
|
+
delete deployment.expiresAt;
|
|
236
|
+
deployment.updatedAt = now.toISOString();
|
|
237
|
+
return deployment;
|
|
238
|
+
}
|
|
239
|
+
export function deleteExpiredPreviewDeployments(config, now = new Date()) {
|
|
240
|
+
const expired = config.previewEnvironmentDeployments
|
|
241
|
+
.filter((deployment) => deployment.expiresAt && Date.parse(deployment.expiresAt) <= now.getTime())
|
|
242
|
+
.map((deployment) => deployment.id);
|
|
243
|
+
for (const deploymentId of expired)
|
|
244
|
+
deletePreviewDeployment(config, deploymentId);
|
|
245
|
+
return expired;
|
|
246
|
+
}
|
|
247
|
+
export function deletePreviewDeployment(config, deploymentId) {
|
|
248
|
+
const deployment = config.previewEnvironmentDeployments.find((candidate) => candidate.id === deploymentId);
|
|
249
|
+
if (!deployment)
|
|
250
|
+
return false;
|
|
251
|
+
const serviceIds = new Set(deployment.services.map((service) => service.instanceId));
|
|
252
|
+
const activationIds = new Set(config.sharedServiceActivations
|
|
253
|
+
.filter((activation) => activation.tenantId === deployment.tenantId)
|
|
254
|
+
.map((activation) => activation.id));
|
|
255
|
+
const bindingIds = new Set(config.m2m.bindings
|
|
256
|
+
.filter((binding) => binding.tenantId === deployment.tenantId)
|
|
257
|
+
.map((binding) => binding.id));
|
|
258
|
+
config.m2m.grants = config.m2m.grants.filter((grant) => !bindingIds.has(grant.bindingId) && grant.tenantId !== deployment.tenantId);
|
|
259
|
+
config.m2m.bindings = config.m2m.bindings.filter((binding) => binding.tenantId !== deployment.tenantId);
|
|
260
|
+
config.webhooks.targets = config.webhooks.targets.filter((target) => target.tenantId !== deployment.tenantId);
|
|
261
|
+
config.manifestCache = config.manifestCache.filter((entry) => !serviceIds.has(entry.serviceId) && !activationIds.has(entry.serviceId));
|
|
262
|
+
config.sharedServiceActivations = config.sharedServiceActivations.filter((activation) => activation.tenantId !== deployment.tenantId);
|
|
263
|
+
config.apps = config.apps.filter((app) => app.id !== deployment.appId && app.tenantId !== deployment.tenantId);
|
|
264
|
+
config.tenants = config.tenants.filter((tenant) => tenant.id !== deployment.tenantId);
|
|
265
|
+
config.previewEnvironmentDeployments = config.previewEnvironmentDeployments.filter((candidate) => candidate.id !== deployment.id);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
export function deletePreviewGroup(config, groupId) {
|
|
269
|
+
const group = config.previewEnvironmentGroups.find((candidate) => candidate.id === groupId);
|
|
270
|
+
if (!group)
|
|
271
|
+
return false;
|
|
272
|
+
for (const deployment of [...config.previewEnvironmentDeployments.filter((candidate) => candidate.groupId === groupId)]) {
|
|
273
|
+
deletePreviewDeployment(config, deployment.id);
|
|
274
|
+
}
|
|
275
|
+
config.previewEnvironmentGroups = config.previewEnvironmentGroups.filter((candidate) => candidate.id !== groupId);
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
export function reconcilePreviewService(config, serviceInstanceId, manifest) {
|
|
279
|
+
const deployments = config.previewEnvironmentDeployments.filter((deployment) => deployment.services.some((service) => service.instanceId === serviceInstanceId)
|
|
280
|
+
|| previewAppUsesService(config, deployment.appId, serviceInstanceId));
|
|
281
|
+
let changed = false;
|
|
282
|
+
for (const deployment of deployments) {
|
|
283
|
+
const app = config.apps.find((candidate) => candidate.id === deployment.appId);
|
|
284
|
+
if (!app)
|
|
285
|
+
continue;
|
|
286
|
+
const before = JSON.stringify({ routes: app.routes, menu: app.menu });
|
|
287
|
+
reconcileRoutesForService(app, serviceInstanceId, manifest);
|
|
288
|
+
rebuildPreviewMenu(config, app);
|
|
289
|
+
if (before !== JSON.stringify({ routes: app.routes, menu: app.menu }))
|
|
290
|
+
changed = true;
|
|
291
|
+
}
|
|
292
|
+
return changed;
|
|
293
|
+
}
|
|
294
|
+
function updateDeployment(config, deployment, requestedServices, controlPlaneUrl, now) {
|
|
295
|
+
const tenant = config.tenants.find((candidate) => candidate.id === deployment.tenantId);
|
|
296
|
+
if (!tenant)
|
|
297
|
+
throw new PreviewEnvironmentError("Preview deployment tenant is missing", 409);
|
|
298
|
+
const credentials = [];
|
|
299
|
+
for (const binding of deployment.services) {
|
|
300
|
+
const nextUrl = requestedServices.get(binding.serviceId);
|
|
301
|
+
if (sameOrigin(binding.url, nextUrl))
|
|
302
|
+
continue;
|
|
303
|
+
const service = tenant.services.find((candidate) => candidate.id === binding.instanceId);
|
|
304
|
+
if (!service)
|
|
305
|
+
throw new PreviewEnvironmentError(`Preview service ${binding.serviceId} is missing`, 409);
|
|
306
|
+
const apiKey = generateApiKey();
|
|
307
|
+
binding.url = nextUrl;
|
|
308
|
+
service.hostname = nextUrl;
|
|
309
|
+
service.apiKeyHash = hashApiKey(apiKey);
|
|
310
|
+
delete service.publicKeyPem;
|
|
311
|
+
delete service.keyId;
|
|
312
|
+
delete service.lastSeenAt;
|
|
313
|
+
delete service.lastSyncAt;
|
|
314
|
+
config.manifestCache = config.manifestCache.filter((entry) => entry.serviceId !== service.id);
|
|
315
|
+
const app = config.apps.find((candidate) => candidate.id === deployment.appId);
|
|
316
|
+
if (app) {
|
|
317
|
+
for (const route of app.routes.filter((candidate) => candidate.serviceId === service.id))
|
|
318
|
+
route.enabled = false;
|
|
319
|
+
rebuildPreviewMenu(config, app);
|
|
320
|
+
}
|
|
321
|
+
credentials.push(issuedCredential(binding.serviceId, binding.instanceId, nextUrl, apiKey, controlPlaneUrl));
|
|
322
|
+
}
|
|
323
|
+
const expiresAt = expiryDate(now, deployment.expiresInDays);
|
|
324
|
+
if (expiresAt)
|
|
325
|
+
deployment.expiresAt = expiresAt;
|
|
326
|
+
else
|
|
327
|
+
delete deployment.expiresAt;
|
|
328
|
+
deployment.updatedAt = now.toISOString();
|
|
329
|
+
return { deployment, credentials, created: false };
|
|
330
|
+
}
|
|
331
|
+
function clonePreviewApp(source, id, tenantId, name, hostname, serviceMap) {
|
|
332
|
+
const mapId = (value) => serviceMap.get(value);
|
|
333
|
+
const routeIds = new Map();
|
|
334
|
+
const routes = source.routes.flatMap((route) => {
|
|
335
|
+
const serviceId = mapId(route.serviceId);
|
|
336
|
+
if (!serviceId)
|
|
337
|
+
return [];
|
|
338
|
+
const routeId = uuidv7();
|
|
339
|
+
routeIds.set(route.id, routeId);
|
|
340
|
+
return [{ ...structuredClone(route), id: routeId, serviceId, enabled: false }];
|
|
341
|
+
});
|
|
342
|
+
const slots = source.slots.flatMap((slot) => {
|
|
343
|
+
const serviceId = mapId(slot.serviceId);
|
|
344
|
+
return serviceId ? [{ ...structuredClone(slot), serviceId }] : [];
|
|
345
|
+
});
|
|
346
|
+
const fragments = Object.fromEntries(Object.entries(source.fragments).flatMap(([location, values]) => {
|
|
347
|
+
const mapped = values.flatMap((fragment) => {
|
|
348
|
+
const serviceId = mapId(fragment.serviceId);
|
|
349
|
+
return serviceId ? [{ ...structuredClone(fragment), serviceId }] : [];
|
|
350
|
+
});
|
|
351
|
+
return mapped.length ? [[location, mapped]] : [];
|
|
352
|
+
}));
|
|
353
|
+
const shellFragments = Object.fromEntries(Object.entries(source.shellFragments).flatMap(([shellServiceId, settings]) => {
|
|
354
|
+
const mappedShell = mapId(shellServiceId);
|
|
355
|
+
if (!mappedShell)
|
|
356
|
+
return [];
|
|
357
|
+
const mappedSettings = Object.fromEntries(Object.entries(settings).flatMap(([fragmentId, setting]) => {
|
|
358
|
+
const mapped = mapShellFragmentSetting(setting, serviceMap);
|
|
359
|
+
return mapped ? [[fragmentId, mapped]] : [];
|
|
360
|
+
}));
|
|
361
|
+
return [[mappedShell, mappedSettings]];
|
|
362
|
+
}));
|
|
363
|
+
const shellServiceId = source.shell ? mapId(source.shell.serviceId) : undefined;
|
|
364
|
+
const authServiceId = source.auth ? mapId(source.auth.serviceId) : undefined;
|
|
365
|
+
const auth = source.auth && authServiceId ? structuredClone(source.auth) : undefined;
|
|
366
|
+
if (auth) {
|
|
367
|
+
auth.serviceId = authServiceId;
|
|
368
|
+
auth.redirects = auth.redirects ? Object.fromEntries(Object.entries(auth.redirects).flatMap(([key, target]) => {
|
|
369
|
+
if (!target)
|
|
370
|
+
return [];
|
|
371
|
+
const serviceId = mapId(target.serviceId);
|
|
372
|
+
return serviceId ? [[key, { ...target, serviceId }]] : [];
|
|
373
|
+
})) : undefined;
|
|
374
|
+
auth.roles = auth.roles.map((role) => ({
|
|
375
|
+
...role,
|
|
376
|
+
permissions: role.permissions.flatMap((permission) => {
|
|
377
|
+
const serviceId = mapId(permission.serviceId);
|
|
378
|
+
return serviceId ? [{ ...permission, serviceId }] : [];
|
|
379
|
+
})
|
|
380
|
+
}));
|
|
381
|
+
delete auth.publicKeys;
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
...structuredClone(source),
|
|
385
|
+
id,
|
|
386
|
+
tenantId,
|
|
387
|
+
slug: `preview-${id}`,
|
|
388
|
+
title: name,
|
|
389
|
+
hostnames: [hostname],
|
|
390
|
+
originOverrides: [],
|
|
391
|
+
refererOverrides: [],
|
|
392
|
+
...(shellServiceId ? { shell: { serviceId: shellServiceId } } : { shell: undefined }),
|
|
393
|
+
seo: {
|
|
394
|
+
visibility: "private",
|
|
395
|
+
serviceFailure: source.seo?.serviceFailure ?? "omit-service",
|
|
396
|
+
serviceCache: source.seo?.serviceCache ?? "24h"
|
|
397
|
+
},
|
|
398
|
+
routes,
|
|
399
|
+
menu: [],
|
|
400
|
+
slots,
|
|
401
|
+
fragments,
|
|
402
|
+
shellFragments,
|
|
403
|
+
...(auth ? { auth } : { auth: undefined })
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function mapShellFragmentSetting(setting, serviceMap) {
|
|
407
|
+
if (setting.mode === "none")
|
|
408
|
+
return setting;
|
|
409
|
+
const mapItem = (item) => {
|
|
410
|
+
if (item.source === "shell")
|
|
411
|
+
return item;
|
|
412
|
+
const serviceId = serviceMap.get(item.serviceId);
|
|
413
|
+
return serviceId ? { ...item, serviceId } : undefined;
|
|
414
|
+
};
|
|
415
|
+
if (setting.mode === "override") {
|
|
416
|
+
const item = mapItem(setting.item);
|
|
417
|
+
return item ? { mode: "override", item } : undefined;
|
|
418
|
+
}
|
|
419
|
+
return { mode: "items", items: setting.items.flatMap((item) => mapItem(item) ?? []) };
|
|
420
|
+
}
|
|
421
|
+
function reconcilePreviewDeploymentFromCache(config, deployment) {
|
|
422
|
+
const app = config.apps.find((candidate) => candidate.id === deployment.appId);
|
|
423
|
+
if (!app)
|
|
424
|
+
return;
|
|
425
|
+
for (const serviceId of collectAppServiceReferences(app)) {
|
|
426
|
+
if (deployment.services.some((service) => service.instanceId === serviceId))
|
|
427
|
+
continue;
|
|
428
|
+
const activation = config.sharedServiceActivations.find((candidate) => candidate.id === serviceId);
|
|
429
|
+
const manifestId = activation?.sharedServiceId ?? serviceId;
|
|
430
|
+
const manifest = config.manifestCache.find((entry) => entry.serviceId === manifestId);
|
|
431
|
+
if (manifest)
|
|
432
|
+
reconcileRoutesForService(app, serviceId, { ...manifest, serviceId: servicePluginId(config, serviceId) ?? manifest.serviceId });
|
|
433
|
+
}
|
|
434
|
+
rebuildPreviewMenu(config, app);
|
|
435
|
+
}
|
|
436
|
+
function reconcileRoutesForService(app, serviceInstanceId, manifest) {
|
|
437
|
+
const existing = app.routes.filter((route) => route.serviceId === serviceInstanceId);
|
|
438
|
+
const desired = [];
|
|
439
|
+
for (const view of Object.values(manifest.viewIndex)) {
|
|
440
|
+
const pageOperations = view.operations.filter((operation) => operation.method === "GET" && operation.renderable && operation.renderModes.includes("page"));
|
|
441
|
+
const pageIds = new Set(pageOperations.map((operation) => operation.operationId));
|
|
442
|
+
const apiOperations = view.operations.filter((operation) => !pageIds.has(operation.operationId));
|
|
443
|
+
for (const [kind, operations] of [["page", pageOperations], ["api", apiOperations]]) {
|
|
444
|
+
if (operations.length === 0)
|
|
445
|
+
continue;
|
|
446
|
+
const previous = existing.find((route) => (route.kind ?? "page") === kind
|
|
447
|
+
&& route.viewId === view.viewId
|
|
448
|
+
&& route.operations.some((operationId) => operations.some((operation) => operation.operationId === operationId)));
|
|
449
|
+
const operation = operations[0];
|
|
450
|
+
desired.push({
|
|
451
|
+
...(previous ? structuredClone(previous) : {
|
|
452
|
+
id: uuidv7(),
|
|
453
|
+
serviceId: serviceInstanceId,
|
|
454
|
+
viewId: view.viewId,
|
|
455
|
+
operations: []
|
|
456
|
+
}),
|
|
457
|
+
kind,
|
|
458
|
+
path: previous?.path ?? (kind === "page"
|
|
459
|
+
? pageRoutePath(manifest.serviceId, view.path)
|
|
460
|
+
: apiRoutePath(manifest.serviceId, view.path)),
|
|
461
|
+
serviceId: serviceInstanceId,
|
|
462
|
+
viewId: view.viewId,
|
|
463
|
+
targetPath: view.path,
|
|
464
|
+
title: operation.title,
|
|
465
|
+
authRequired: operation.authRequired,
|
|
466
|
+
...(operation.sitemap ? { sitemap: operation.sitemap } : {}),
|
|
467
|
+
robots: [...operation.robots],
|
|
468
|
+
...(operation.chrome ? { chrome: operation.chrome } : {}),
|
|
469
|
+
enabled: true,
|
|
470
|
+
enablement: "enabled",
|
|
471
|
+
operations: operations.map((candidate) => candidate.operationId)
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
app.routes = [...app.routes.filter((route) => route.serviceId !== serviceInstanceId), ...desired];
|
|
476
|
+
}
|
|
477
|
+
function rebuildPreviewMenu(config, app) {
|
|
478
|
+
const previousGroups = new Map();
|
|
479
|
+
const previousItems = new Map();
|
|
480
|
+
for (const group of app.menu) {
|
|
481
|
+
const route = group.children.map((item) => app.routes.find((candidate) => candidate.id === item.routeId)).find(Boolean);
|
|
482
|
+
if (route)
|
|
483
|
+
previousGroups.set(route.serviceId, group);
|
|
484
|
+
for (const item of group.children)
|
|
485
|
+
if (item.routeId)
|
|
486
|
+
previousItems.set(item.routeId, item);
|
|
487
|
+
}
|
|
488
|
+
const routes = app.routes
|
|
489
|
+
.filter((route) => route.enabled && (route.kind ?? "page") === "page")
|
|
490
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
491
|
+
const byService = new Map();
|
|
492
|
+
for (const route of routes)
|
|
493
|
+
byService.set(route.serviceId, [...(byService.get(route.serviceId) ?? []), route]);
|
|
494
|
+
app.menu = [...byService.entries()]
|
|
495
|
+
.sort(([left], [right]) => serviceTitle(config, app.tenantId, left).localeCompare(serviceTitle(config, app.tenantId, right)))
|
|
496
|
+
.map(([serviceId, serviceRoutes]) => ({
|
|
497
|
+
id: previousGroups.get(serviceId)?.id ?? uuidv7(),
|
|
498
|
+
type: "group",
|
|
499
|
+
title: serviceTitle(config, app.tenantId, serviceId),
|
|
500
|
+
enabled: true,
|
|
501
|
+
serviceStatus: "show",
|
|
502
|
+
authStatus: "show",
|
|
503
|
+
defaultExpanded: true,
|
|
504
|
+
children: serviceRoutes.map((route) => ({
|
|
505
|
+
id: previousItems.get(route.id)?.id ?? uuidv7(),
|
|
506
|
+
type: "link",
|
|
507
|
+
title: route.title ?? route.viewId,
|
|
508
|
+
routeId: route.id,
|
|
509
|
+
enabled: true,
|
|
510
|
+
serviceStatus: "show",
|
|
511
|
+
authStatus: "show",
|
|
512
|
+
children: []
|
|
513
|
+
}))
|
|
514
|
+
}));
|
|
515
|
+
}
|
|
516
|
+
function collectAppServiceReferences(app) {
|
|
517
|
+
const ids = new Set();
|
|
518
|
+
if (app.shell)
|
|
519
|
+
ids.add(app.shell.serviceId);
|
|
520
|
+
for (const route of app.routes)
|
|
521
|
+
ids.add(route.serviceId);
|
|
522
|
+
for (const slot of app.slots)
|
|
523
|
+
ids.add(slot.serviceId);
|
|
524
|
+
for (const fragments of Object.values(app.fragments))
|
|
525
|
+
for (const fragment of fragments)
|
|
526
|
+
ids.add(fragment.serviceId);
|
|
527
|
+
for (const [shellServiceId, settings] of Object.entries(app.shellFragments)) {
|
|
528
|
+
ids.add(shellServiceId);
|
|
529
|
+
for (const setting of Object.values(settings)) {
|
|
530
|
+
if (setting.mode === "override" && setting.item.source === "service")
|
|
531
|
+
ids.add(setting.item.serviceId);
|
|
532
|
+
if (setting.mode === "items")
|
|
533
|
+
for (const item of setting.items)
|
|
534
|
+
if (item.source === "service")
|
|
535
|
+
ids.add(item.serviceId);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (app.auth) {
|
|
539
|
+
ids.add(app.auth.serviceId);
|
|
540
|
+
for (const target of Object.values(app.auth.redirects ?? {}))
|
|
541
|
+
if (target)
|
|
542
|
+
ids.add(target.serviceId);
|
|
543
|
+
for (const role of app.auth.roles)
|
|
544
|
+
for (const permission of role.permissions)
|
|
545
|
+
ids.add(permission.serviceId);
|
|
546
|
+
}
|
|
547
|
+
return ids;
|
|
548
|
+
}
|
|
549
|
+
function requireManagedSourceServices(config, app, serviceIds) {
|
|
550
|
+
const tenant = config.tenants.find((candidate) => candidate.id === app.tenantId);
|
|
551
|
+
const referencedServices = tenant?.services.filter((service) => collectAppServiceReferences(app).has(service.id)) ?? [];
|
|
552
|
+
const seen = new Set();
|
|
553
|
+
const duplicate = referencedServices.find((service) => {
|
|
554
|
+
if (!service.serviceId)
|
|
555
|
+
return false;
|
|
556
|
+
if (seen.has(service.serviceId))
|
|
557
|
+
return true;
|
|
558
|
+
seen.add(service.serviceId);
|
|
559
|
+
return false;
|
|
560
|
+
});
|
|
561
|
+
if (duplicate) {
|
|
562
|
+
throw new PreviewEnvironmentError(`Source app uses multiple instances of ${duplicate.serviceId}; preview groups require one instance per service ID`);
|
|
563
|
+
}
|
|
564
|
+
const required = managedServiceIdsForPreviewApp(config, app);
|
|
565
|
+
const missing = required.filter((serviceId) => !serviceIds.includes(serviceId));
|
|
566
|
+
if (missing.length)
|
|
567
|
+
throw new PreviewEnvironmentError(`Preview group is missing source app services: ${[...new Set(missing)].join(", ")}`);
|
|
568
|
+
}
|
|
569
|
+
function sourceServiceForPreview(tenant, app, serviceId) {
|
|
570
|
+
const referenced = collectAppServiceReferences(app);
|
|
571
|
+
return tenant.services.find((service) => service.serviceId === serviceId && referenced.has(service.id))
|
|
572
|
+
?? tenant.services.find((service) => service.serviceId === serviceId);
|
|
573
|
+
}
|
|
574
|
+
export function managedServiceIdsForPreviewApp(config, app) {
|
|
575
|
+
const tenant = config.tenants.find((candidate) => candidate.id === app.tenantId);
|
|
576
|
+
if (!tenant)
|
|
577
|
+
return [];
|
|
578
|
+
return [...new Set([...collectAppServiceReferences(app)].flatMap((instanceId) => {
|
|
579
|
+
const service = tenant.services.find((candidate) => candidate.id === instanceId);
|
|
580
|
+
return service?.serviceId ? [service.serviceId] : [];
|
|
581
|
+
}))];
|
|
582
|
+
}
|
|
583
|
+
function previewAppUsesService(config, appId, serviceId) {
|
|
584
|
+
const app = config.apps.find((candidate) => candidate.id === appId);
|
|
585
|
+
return app ? collectAppServiceReferences(app).has(serviceId) : false;
|
|
586
|
+
}
|
|
587
|
+
function servicePluginId(config, serviceId) {
|
|
588
|
+
for (const tenant of config.tenants) {
|
|
589
|
+
const service = tenant.services.find((candidate) => candidate.id === serviceId);
|
|
590
|
+
if (service)
|
|
591
|
+
return service.serviceId;
|
|
592
|
+
}
|
|
593
|
+
const platform = config.platformServices.find((candidate) => candidate.id === serviceId);
|
|
594
|
+
if (platform)
|
|
595
|
+
return platform.serviceId;
|
|
596
|
+
const activation = config.sharedServiceActivations.find((candidate) => candidate.id === serviceId);
|
|
597
|
+
return activation
|
|
598
|
+
? config.sharedServiceCatalog.find((candidate) => candidate.id === activation.sharedServiceId)?.serviceId
|
|
599
|
+
: undefined;
|
|
600
|
+
}
|
|
601
|
+
function serviceTitle(config, tenantId, serviceId) {
|
|
602
|
+
const tenantService = config.tenants.find((tenant) => tenant.id === tenantId)?.services.find((service) => service.id === serviceId);
|
|
603
|
+
if (tenantService)
|
|
604
|
+
return tenantService.title ?? tenantService.serviceId ?? serviceId;
|
|
605
|
+
const platform = config.platformServices.find((service) => service.id === serviceId);
|
|
606
|
+
if (platform)
|
|
607
|
+
return platform.title;
|
|
608
|
+
const activation = config.sharedServiceActivations.find((candidate) => candidate.id === serviceId);
|
|
609
|
+
return config.sharedServiceCatalog.find((service) => service.id === activation?.sharedServiceId)?.title ?? serviceId;
|
|
610
|
+
}
|
|
611
|
+
function requireGroup(config, groupId) {
|
|
612
|
+
const group = config.previewEnvironmentGroups.find((candidate) => candidate.id === groupId);
|
|
613
|
+
if (!group)
|
|
614
|
+
throw new PreviewEnvironmentError("Preview group was not found", 404);
|
|
615
|
+
return group;
|
|
616
|
+
}
|
|
617
|
+
function normalizeServiceIds(values) {
|
|
618
|
+
const normalized = values.map((value) => value.trim()).filter(Boolean);
|
|
619
|
+
const unique = [...new Set(normalized)];
|
|
620
|
+
if (unique.length === 0)
|
|
621
|
+
throw new PreviewEnvironmentError("At least one service plugin ID is required");
|
|
622
|
+
if (unique.length !== normalized.length)
|
|
623
|
+
throw new PreviewEnvironmentError("Service plugin IDs must be unique");
|
|
624
|
+
return unique;
|
|
625
|
+
}
|
|
626
|
+
function normalizeRequestedServices(values) {
|
|
627
|
+
const result = new Map();
|
|
628
|
+
for (const value of values) {
|
|
629
|
+
const serviceId = value.serviceId.trim();
|
|
630
|
+
if (!serviceId || result.has(serviceId))
|
|
631
|
+
throw new PreviewEnvironmentError("Service plugin IDs must be present and unique");
|
|
632
|
+
result.set(serviceId, normalizeServiceOrigin(value.url));
|
|
633
|
+
}
|
|
634
|
+
return result;
|
|
635
|
+
}
|
|
636
|
+
function requireExactServiceSet(group, requested) {
|
|
637
|
+
const expected = group.services.map((service) => service.serviceId).sort();
|
|
638
|
+
const received = [...requested.keys()].sort();
|
|
639
|
+
if (expected.length !== received.length || expected.some((serviceId, index) => serviceId !== received[index])) {
|
|
640
|
+
throw new PreviewEnvironmentError(`Services must exactly match the preview group: ${expected.join(", ")}`, 409);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function normalizeOidc(oidc) {
|
|
644
|
+
return {
|
|
645
|
+
issuer: requiredText(oidc.issuer, "OIDC issuer", 2048).replace(/\/+$/, ""),
|
|
646
|
+
audience: requiredText(oidc.audience, "OIDC audience", 255),
|
|
647
|
+
jwksUri: normalizeServiceOriginOrUrl(oidc.jwksUri, "OIDC JWKS URL"),
|
|
648
|
+
...(oidc.subjectPrefix?.trim() ? { subjectPrefix: oidc.subjectPrefix.trim() } : {}),
|
|
649
|
+
requiredClaims: Object.fromEntries(Object.entries(oidc.requiredClaims ?? {}).map(([key, value]) => [
|
|
650
|
+
requiredText(key, "OIDC claim name", 128),
|
|
651
|
+
requiredText(value, `OIDC claim ${key}`, 512)
|
|
652
|
+
]))
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
function normalizeExpiry(value) {
|
|
656
|
+
if (value === null)
|
|
657
|
+
return null;
|
|
658
|
+
if (!Number.isInteger(value) || value < 1 || value > 3650) {
|
|
659
|
+
throw new PreviewEnvironmentError("Expiry must be between 1 and 3650 days, or never");
|
|
660
|
+
}
|
|
661
|
+
return value;
|
|
662
|
+
}
|
|
663
|
+
function effectiveExpiry(limit, requested) {
|
|
664
|
+
if (requested !== undefined)
|
|
665
|
+
normalizeExpiry(requested);
|
|
666
|
+
if (limit === null)
|
|
667
|
+
return requested === undefined ? null : requested;
|
|
668
|
+
if (requested === undefined || requested === null)
|
|
669
|
+
return limit;
|
|
670
|
+
return Math.min(limit, requested);
|
|
671
|
+
}
|
|
672
|
+
function expiryDate(now, days) {
|
|
673
|
+
return days === null ? undefined : new Date(now.getTime() + days * 86_400_000).toISOString();
|
|
674
|
+
}
|
|
675
|
+
function normalizeServiceOrigin(value) {
|
|
676
|
+
const raw = requiredText(value, "Service URL", 2048);
|
|
677
|
+
let parsed;
|
|
678
|
+
try {
|
|
679
|
+
parsed = new URL(raw);
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
throw new PreviewEnvironmentError(`Invalid service URL: ${raw}`);
|
|
683
|
+
}
|
|
684
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== "/") {
|
|
685
|
+
throw new PreviewEnvironmentError(`Service URL must be an HTTP(S) origin without credentials, path, query or fragment: ${raw}`);
|
|
686
|
+
}
|
|
687
|
+
return parsed.origin;
|
|
688
|
+
}
|
|
689
|
+
function normalizeServiceOriginOrUrl(value, label) {
|
|
690
|
+
const raw = requiredText(value, label, 2048);
|
|
691
|
+
let parsed;
|
|
692
|
+
try {
|
|
693
|
+
parsed = new URL(raw);
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
throw new PreviewEnvironmentError(`${label} is invalid`);
|
|
697
|
+
}
|
|
698
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) {
|
|
699
|
+
throw new PreviewEnvironmentError(`${label} must be an HTTPS URL without credentials or a fragment`);
|
|
700
|
+
}
|
|
701
|
+
return parsed.toString();
|
|
702
|
+
}
|
|
703
|
+
function normalizeAppHostname(value) {
|
|
704
|
+
const raw = requiredText(value, "Preview hostname", 2048).replace(/\/+$/, "");
|
|
705
|
+
try {
|
|
706
|
+
const parsed = new URL(raw);
|
|
707
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== "/") {
|
|
708
|
+
throw new Error();
|
|
709
|
+
}
|
|
710
|
+
return parsed.origin;
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
if (!/^[A-Za-z0-9.-]+(?::\d+)?$/.test(raw) || raw.includes("..")) {
|
|
714
|
+
throw new PreviewEnvironmentError("Preview hostname must be a hostname or HTTP(S) origin without a path");
|
|
715
|
+
}
|
|
716
|
+
return raw.toLowerCase();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function sameHostname(left, right) {
|
|
720
|
+
const host = (value) => {
|
|
721
|
+
try {
|
|
722
|
+
return new URL(value).host.toLowerCase();
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
return value.toLowerCase().replace(/^https?:\/\//, "");
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
return host(left) === host(right);
|
|
729
|
+
}
|
|
730
|
+
function sameOrigin(left, right) {
|
|
731
|
+
return normalizeServiceOrigin(left) === normalizeServiceOrigin(right);
|
|
732
|
+
}
|
|
733
|
+
function requiredText(value, label, maxLength) {
|
|
734
|
+
const normalized = value.trim();
|
|
735
|
+
if (!normalized)
|
|
736
|
+
throw new PreviewEnvironmentError(`${label} is required`);
|
|
737
|
+
if (normalized.length > maxLength)
|
|
738
|
+
throw new PreviewEnvironmentError(`${label} must be at most ${maxLength} characters`);
|
|
739
|
+
return normalized;
|
|
740
|
+
}
|
|
741
|
+
function bearerToken(value) {
|
|
742
|
+
if (!value?.startsWith("Bearer "))
|
|
743
|
+
return undefined;
|
|
744
|
+
const token = value.slice("Bearer ".length).trim();
|
|
745
|
+
return token && token.length <= 16_384 ? token : undefined;
|
|
746
|
+
}
|
|
747
|
+
function safeHashEqual(expected, actual) {
|
|
748
|
+
const left = Buffer.from(expected);
|
|
749
|
+
const right = Buffer.from(actual);
|
|
750
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
751
|
+
}
|
|
752
|
+
function issuedCredential(serviceId, instanceId, url, apiKey, controlPlaneUrl) {
|
|
753
|
+
return {
|
|
754
|
+
serviceId,
|
|
755
|
+
instanceId,
|
|
756
|
+
url,
|
|
757
|
+
environment: {
|
|
758
|
+
BP_CONTROL_PLANE_URL: controlPlaneUrl.replace(/\/+$/, ""),
|
|
759
|
+
BP_SERVICE_API_KEY: apiKey
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
//# sourceMappingURL=previewEnvironments.js.map
|