@wtfalch/foundry 0.46.0 → 0.48.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/dist/build-json.d.ts +28 -0
- package/dist/build-json.js +95 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/migrations/0059_org_builtin_blue_green.sql +9 -0
- package/dist/org.d.ts +47 -5
- package/dist/org.js +176 -128
- package/dist/plan/archon-blue-green.js +15 -1
- package/dist/plan/blue-green-apply.d.ts +35 -0
- package/dist/plan/blue-green-apply.js +167 -16
- package/dist/plan/blue-green.d.ts +97 -2
- package/dist/plan/blue-green.js +214 -13
- package/dist/plan/database-dump.d.ts +37 -0
- package/dist/plan/database-dump.js +101 -0
- package/dist/plan/diff.js +9 -5
- package/dist/plan/executor.d.ts +48 -0
- package/dist/plan/executor.js +87 -28
- package/dist/plan/health-targets.js +1 -2
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +6 -6
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/locked-hostnames.d.ts +1 -0
- package/dist/plan/locked-hostnames.js +1 -0
- package/dist/plan/plan-apply.d.ts +30 -1
- package/dist/plan/plan-apply.js +86 -8
- package/dist/plan/platform-diff.js +2 -1
- package/dist/plan/platform-providers.js +5 -0
- package/dist/plan/providers-readers.js +15 -4
- package/dist/plan/proxy-labels.d.ts +13 -1
- package/dist/plan/proxy-labels.js +26 -3
- package/dist/plan/release-host.d.ts +1 -1
- package/dist/plan/release-host.js +36 -14
- package/dist/plan/stage-env.d.ts +38 -0
- package/dist/plan/stage-env.js +52 -0
- package/dist/plan/stage-guards.d.ts +104 -0
- package/dist/plan/stage-guards.js +149 -0
- package/dist/plan/stage-isolation.d.ts +57 -0
- package/dist/plan/stage-isolation.js +248 -0
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +12 -0
- package/dist/release/decide.d.ts +5 -0
- package/dist/release/decide.js +10 -6
- package/dist/release/health.d.ts +6 -1
- package/dist/release/health.js +10 -2
- package/dist/release/run.d.ts +2 -0
- package/dist/release/run.js +2 -1
- package/dist/requested-changes.d.ts +50 -4
- package/dist/requested-changes.js +9 -1
- package/dist/store/postgres.d.ts +5 -1
- package/dist/store/postgres.js +86 -8
- package/dist/store/schema.d.ts +111 -1
- package/dist/store/schema.js +7 -0
- package/dist/store.d.ts +24 -1
- package/dist/worker-app-config.d.ts +8 -2
- package/dist/worker-app-config.js +20 -6
- package/package.json +1 -1
package/dist/org.js
CHANGED
|
@@ -10,6 +10,9 @@ export const ACCESS_RULE_KINDS = new Set(['bypass', 'service-token']);
|
|
|
10
10
|
export const DEFAULT_GATE_LIMIT = 3;
|
|
11
11
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
12
12
|
export const APP_ENTRY_KEY = 'app';
|
|
13
|
+
function refusal(code, path, message) {
|
|
14
|
+
return { code, path, message };
|
|
15
|
+
}
|
|
13
16
|
/** The most volumes one entry may declare. */
|
|
14
17
|
export const MAX_ENTRY_VOLUMES = 8;
|
|
15
18
|
/** `uses.auth.mcp.onboardingPath`: absolute, no query, no fragment, no dot segments. */
|
|
@@ -44,9 +47,14 @@ export const FILES_ENTRY_ENV_NAMES = [
|
|
|
44
47
|
export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
45
48
|
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
46
49
|
export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
|
|
47
|
-
/**
|
|
48
|
-
export function
|
|
49
|
-
return
|
|
50
|
+
/** True for the entry marked `main`, and for an entry keyed {@link APP_ENTRY_KEY} that leaves `main` unset. */
|
|
51
|
+
export function isMainEntry(entry) {
|
|
52
|
+
return entry.main ?? entry.key === APP_ENTRY_KEY;
|
|
53
|
+
}
|
|
54
|
+
/** The org's main app: the entry marked `main`, else the entry keyed {@link APP_ENTRY_KEY}. */
|
|
55
|
+
export function mainEntry(hosting) {
|
|
56
|
+
return (hosting.entries.find((entry) => entry.main === true) ??
|
|
57
|
+
hosting.entries.find((entry) => entry.main === undefined && entry.key === APP_ENTRY_KEY));
|
|
50
58
|
}
|
|
51
59
|
/**
|
|
52
60
|
* `sharedSecret` ({@link EnvSharedSecret}) and `heldSecret` ({@link EnvHeldSecret}) are the secret sources. The
|
|
@@ -107,26 +115,30 @@ const HOSTING_BUILD_PACKS = new Set([
|
|
|
107
115
|
* - `tunnel` requires `enabled` -- it means nothing without the apps running.
|
|
108
116
|
* - Every `issuerApps[]` entry has a non-empty `name`, unique within the
|
|
109
117
|
* list, and a `kind` that is one of `IssuerAppKind`'s three values.
|
|
110
|
-
* - `enabled` requires
|
|
118
|
+
* - `enabled` requires at least one entry, and at most one entry is the main app ({@link isMainEntry}).
|
|
111
119
|
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
112
120
|
* unique, and at most one entry has a `database`, because the one
|
|
113
121
|
* `<slug>_rt` runtime role cannot serve two apps.
|
|
114
122
|
*/
|
|
115
123
|
export function validateHosting(hosting, names) {
|
|
124
|
+
return refuseHosting(hosting, names)?.message ?? null;
|
|
125
|
+
}
|
|
126
|
+
/** {@link validateHosting} with the code and path of the first refusal. */
|
|
127
|
+
export function refuseHosting(hosting, names) {
|
|
116
128
|
if (hosting.tunnel && !hosting.enabled) {
|
|
117
|
-
return 'hosting.tunnel requires hosting.enabled';
|
|
129
|
+
return refusal('hosting_tunnel_needs_hosting', 'hosting.tunnel', 'hosting.tunnel requires hosting.enabled');
|
|
118
130
|
}
|
|
119
131
|
const seenNames = new Set();
|
|
120
132
|
for (const app of hosting.issuerApps) {
|
|
121
133
|
if (!app.name) {
|
|
122
|
-
return 'every issuerApps entry needs a non-empty name';
|
|
134
|
+
return refusal('issuer_app_name_missing', 'hosting.issuerApps', 'every issuerApps entry needs a non-empty name');
|
|
123
135
|
}
|
|
124
136
|
if (seenNames.has(app.name)) {
|
|
125
|
-
return `issuerApps name '${app.name}' is not unique
|
|
137
|
+
return refusal('issuer_app_name_not_unique', `hosting.issuerApps.${app.name}.name`, `issuerApps name '${app.name}' is not unique`);
|
|
126
138
|
}
|
|
127
139
|
seenNames.add(app.name);
|
|
128
140
|
if (!ISSUER_APP_KINDS.has(app.kind)) {
|
|
129
|
-
return `issuerApps '${app.name}' has an unknown kind '${app.kind}'
|
|
141
|
+
return refusal('issuer_app_kind_unknown', `hosting.issuerApps.${app.name}.kind`, `issuerApps '${app.name}' has an unknown kind '${app.kind}'`);
|
|
130
142
|
}
|
|
131
143
|
// The fixed 'agora' ZITADEL client (#164, `plan/diff.ts`'s
|
|
132
144
|
// `planAgoraClient`) already owns the `zitadel:app:agora` key, for
|
|
@@ -134,23 +146,24 @@ export function validateHosting(hosting, names) {
|
|
|
134
146
|
// reservation (`validateSite` below), which only applies once
|
|
135
147
|
// `site.enabled` is true.
|
|
136
148
|
if (app.name === 'agora') {
|
|
137
|
-
return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets
|
|
149
|
+
return refusal('issuer_app_name_reserved_agora', `hosting.issuerApps.${app.name}.name`, `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`);
|
|
138
150
|
}
|
|
139
151
|
// #161: the same for the always-on Boule client.
|
|
140
152
|
if (app.name === 'boule') {
|
|
141
|
-
return `issuerApps '${app.name}' is reserved for the always-on Boule client every org gets
|
|
153
|
+
return refusal('issuer_app_name_reserved_boule', `hosting.issuerApps.${app.name}.name`, `issuerApps '${app.name}' is reserved for the always-on Boule client every org gets`);
|
|
142
154
|
}
|
|
143
155
|
}
|
|
144
|
-
if (hosting.enabled &&
|
|
145
|
-
return
|
|
156
|
+
if (hosting.enabled && hosting.entries.length === 0) {
|
|
157
|
+
return refusal('hosting_needs_entry', 'hosting.entries', 'hosting.enabled requires at least one entry');
|
|
146
158
|
}
|
|
159
|
+
const reserveKeys = names?.builtinBlueGreen === true || hosting.entries.some((e) => e.blueGreen !== undefined);
|
|
147
160
|
const seenKeys = new Set();
|
|
148
161
|
for (const entry of hosting.entries) {
|
|
149
162
|
if (seenKeys.has(entry.key)) {
|
|
150
|
-
return `hosting.entries key '${entry.key}' is not unique
|
|
163
|
+
return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
|
|
151
164
|
}
|
|
152
165
|
seenKeys.add(entry.key);
|
|
153
|
-
const invalid = validateHostingEntry(entry, hosting.enabled);
|
|
166
|
+
const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys);
|
|
154
167
|
if (invalid)
|
|
155
168
|
return invalid;
|
|
156
169
|
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
@@ -166,14 +179,19 @@ export function validateHosting(hosting, names) {
|
|
|
166
179
|
if (invalidWorker)
|
|
167
180
|
return invalidWorker;
|
|
168
181
|
const invalidWorkerApp = validateEntryWorkerApp(entry, hosting, names);
|
|
169
|
-
if (invalidWorkerApp)
|
|
170
|
-
return invalidWorkerApp;
|
|
182
|
+
if (invalidWorkerApp) {
|
|
183
|
+
return refusal('worker_app_invalid', `hosting.entries.${entry.key}.workerApp`, invalidWorkerApp);
|
|
184
|
+
}
|
|
171
185
|
const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
|
|
172
186
|
if (invalidEdgeUse)
|
|
173
187
|
return invalidEdgeUse;
|
|
174
188
|
}
|
|
189
|
+
const mains = hosting.entries.filter(isMainEntry);
|
|
190
|
+
if (mains.length > 1) {
|
|
191
|
+
return refusal('hosting_main_count', 'hosting.entries', `at most one hosting entry may be the main app: ${mains.map((entry) => `'${entry.key}'`).join(' and ')} are`);
|
|
192
|
+
}
|
|
175
193
|
if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
|
|
176
|
-
return 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation';
|
|
194
|
+
return refusal('ai_webhook_path_not_unique', 'hosting.entries', 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation');
|
|
177
195
|
}
|
|
178
196
|
// #318 re-review: two entries declaring the same worker route both throw at
|
|
179
197
|
// apply (`cloudflareWorker` refuses a route already bound to a Worker
|
|
@@ -185,7 +203,7 @@ export function validateHosting(hosting, names) {
|
|
|
185
203
|
if (!entry.worker)
|
|
186
204
|
continue;
|
|
187
205
|
if (seenWorkerRoutes.has(entry.worker.route)) {
|
|
188
|
-
return `hosting.entries '${entry.key}' worker route '${entry.worker.route}' is already declared by another entry
|
|
206
|
+
return refusal('worker_route_not_unique', `hosting.entries.${entry.key}.worker.route`, `hosting.entries '${entry.key}' worker route '${entry.worker.route}' is already declared by another entry`);
|
|
189
207
|
}
|
|
190
208
|
seenWorkerRoutes.add(entry.worker.route);
|
|
191
209
|
}
|
|
@@ -193,7 +211,7 @@ export function validateHosting(hosting, names) {
|
|
|
193
211
|
if (invalidNames)
|
|
194
212
|
return invalidNames;
|
|
195
213
|
if (hosting.entries.filter(entryHasDatabase).length > 1) {
|
|
196
|
-
return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
|
|
214
|
+
return refusal('database_not_unique', 'hosting.entries', 'at most one hosting entry may set database: the one runtime database role cannot serve two apps');
|
|
197
215
|
}
|
|
198
216
|
const invalidGates = validateGates(hosting, names?.domain, names?.locked);
|
|
199
217
|
if (invalidGates)
|
|
@@ -204,7 +222,7 @@ export function validateHosting(hosting, names) {
|
|
|
204
222
|
if (invalidRule)
|
|
205
223
|
return invalidRule;
|
|
206
224
|
if (seenAccessHostnames.has(rule.hostname)) {
|
|
207
|
-
return `hosting.access hostname '${rule.hostname}' is listed twice
|
|
225
|
+
return refusal('access_hostname_not_unique', 'hosting.access', `hosting.access hostname '${rule.hostname}' is listed twice`);
|
|
208
226
|
}
|
|
209
227
|
seenAccessHostnames.add(rule.hostname);
|
|
210
228
|
}
|
|
@@ -220,20 +238,20 @@ const ACCESS_RULE_HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-
|
|
|
220
238
|
function validateAccessRule(rule, hosting, names) {
|
|
221
239
|
const at = `hosting.access '${rule.hostname}'`;
|
|
222
240
|
if (!rule.hostname || !ACCESS_RULE_HOSTNAME.test(rule.hostname)) {
|
|
223
|
-
return `hosting.access entries need a full hostname ('${rule.hostname}' is not one)
|
|
241
|
+
return refusal('access_hostname_invalid', 'hosting.access', `hosting.access entries need a full hostname ('${rule.hostname}' is not one)`);
|
|
224
242
|
}
|
|
225
243
|
if (!hosting.enabled) {
|
|
226
|
-
return `${at} requires hosting.enabled
|
|
244
|
+
return refusal('access_needs_hosting', 'hosting.access', `${at} requires hosting.enabled`);
|
|
227
245
|
}
|
|
228
246
|
if (!ACCESS_RULE_KINDS.has(rule.kind)) {
|
|
229
|
-
return `${at} has an unknown kind '${rule.kind}'
|
|
247
|
+
return refusal('access_kind_unknown', 'hosting.access', `${at} has an unknown kind '${rule.kind}'`);
|
|
230
248
|
}
|
|
231
249
|
if (rule.kind === 'service-token' && !rule.serviceTokenName) {
|
|
232
|
-
return `${at} kind 'service-token' needs a non-empty serviceTokenName
|
|
250
|
+
return refusal('access_service_token_name_missing', 'hosting.access', `${at} kind 'service-token' needs a non-empty serviceTokenName`);
|
|
233
251
|
}
|
|
234
252
|
if (names) {
|
|
235
253
|
if (names.locked.has(rule.hostname)) {
|
|
236
|
-
return `${at} is a name foundry owns
|
|
254
|
+
return refusal('access_hostname_locked', 'hosting.access', `${at} is a name foundry owns`);
|
|
237
255
|
}
|
|
238
256
|
const { domain } = names;
|
|
239
257
|
// Any name under the domain: `share.<domain>` and `storage.<domain>` are
|
|
@@ -242,7 +260,7 @@ function validateAccessRule(rule, hosting, names) {
|
|
|
242
260
|
domain !== null &&
|
|
243
261
|
(rule.hostname === domain || rule.hostname.endsWith(`.${domain}`));
|
|
244
262
|
if (!served) {
|
|
245
|
-
return `${at} is not a name the org serves under its own domain
|
|
263
|
+
return refusal('access_hostname_not_served', 'hosting.access', `${at} is not a name the org serves under its own domain`);
|
|
246
264
|
}
|
|
247
265
|
}
|
|
248
266
|
return null;
|
|
@@ -256,7 +274,7 @@ function validateAccessRule(rule, hosting, names) {
|
|
|
256
274
|
*/
|
|
257
275
|
function validateGates(hosting, domain, locked) {
|
|
258
276
|
if (!Number.isInteger(hosting.gateLimit) || hosting.gateLimit < 1) {
|
|
259
|
-
return 'hosting.gateLimit must be a positive whole number';
|
|
277
|
+
return refusal('gate_limit_invalid', 'hosting.gateLimit', 'hosting.gateLimit must be a positive whole number');
|
|
260
278
|
}
|
|
261
279
|
const seenKeys = new Set();
|
|
262
280
|
for (const entry of hosting.entries) {
|
|
@@ -264,33 +282,33 @@ function validateGates(hosting, domain, locked) {
|
|
|
264
282
|
for (const gate of entry.gates) {
|
|
265
283
|
const at = `hosting.entries '${entry.key}' gate '${gate.key}'`;
|
|
266
284
|
if (!hosting.enabled || !hosting.usesDomain || domain === null) {
|
|
267
|
-
return `${at} needs hosting.enabled, hosting.usesDomain and an org domain
|
|
285
|
+
return refusal('gate_needs_domain', `hosting.entries.${entry.key}.gates.${gate.key}`, `${at} needs hosting.enabled, hosting.usesDomain and an org domain`);
|
|
268
286
|
}
|
|
269
287
|
if (!ENTRY_KEY.test(gate.key)) {
|
|
270
|
-
return `${at} key must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)
|
|
288
|
+
return refusal('gate_key_invalid', `hosting.entries.${entry.key}.gates.${gate.key}.key`, `${at} key must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`);
|
|
271
289
|
}
|
|
272
290
|
if (seenKeys.has(gate.key))
|
|
273
|
-
return `${at} key is used by two gates
|
|
291
|
+
return refusal('gate_key_not_unique', `hosting.entries.${entry.key}.gates.${gate.key}.key`, `${at} key is used by two gates`);
|
|
274
292
|
seenKeys.add(gate.key);
|
|
275
293
|
if (gate.hostnames.length === 0)
|
|
276
|
-
return `${at} needs at least one hostname
|
|
294
|
+
return refusal('gate_needs_hostname', `hosting.entries.${entry.key}.gates.${gate.key}.hostnames`, `${at} needs at least one hostname`);
|
|
277
295
|
for (const name of gate.hostnames) {
|
|
278
296
|
if (!entry.hostnames.includes(name)) {
|
|
279
|
-
return `${at} hostname '${name}' is not one of the entry's hostnames
|
|
297
|
+
return refusal('gate_hostname_not_entry_hostname', `hosting.entries.${entry.key}.gates.${gate.key}.hostnames`, `${at} hostname '${name}' is not one of the entry's hostnames`);
|
|
280
298
|
}
|
|
281
299
|
if (taken.has(name))
|
|
282
|
-
return `${at} hostname '${name}' is held by two gates or listed twice
|
|
300
|
+
return refusal('gate_hostname_held_twice', `hosting.entries.${entry.key}.gates.${gate.key}.hostnames`, `${at} hostname '${name}' is held by two gates or listed twice`);
|
|
283
301
|
taken.add(name);
|
|
284
302
|
if (domain && name !== '*') {
|
|
285
303
|
const full = name === '@' ? domain : `${name}.${domain}`;
|
|
286
304
|
if (locked?.has(full))
|
|
287
|
-
return `${at} hostname '${name}' is a name foundry owns: ${full}
|
|
305
|
+
return refusal('gate_hostname_locked', `hosting.entries.${entry.key}.gates.${gate.key}.hostnames`, `${at} hostname '${name}' is a name foundry owns: ${full}`);
|
|
288
306
|
}
|
|
289
307
|
}
|
|
290
308
|
}
|
|
291
309
|
}
|
|
292
310
|
if (seenKeys.size > hosting.gateLimit) {
|
|
293
|
-
return `hosting declares ${seenKeys.size} gates, over its limit of ${hosting.gateLimit}
|
|
311
|
+
return refusal('gate_limit_exceeded', 'hosting.gateLimit', `hosting declares ${seenKeys.size} gates, over its limit of ${hosting.gateLimit}`);
|
|
294
312
|
}
|
|
295
313
|
return null;
|
|
296
314
|
}
|
|
@@ -307,40 +325,42 @@ function validateEntryHostnames(hosting, names) {
|
|
|
307
325
|
const at = `hosting.entries '${entry.key}'`;
|
|
308
326
|
if (entry.hostnames.includes('*')) {
|
|
309
327
|
if (!hosting.usesDomain || (names && !names.domain)) {
|
|
310
|
-
return `${at} hostname '*' needs hosting.usesDomain and an org domain: it serves any name under the domain
|
|
328
|
+
return refusal('wildcard_needs_domain', `hosting.entries.${entry.key}.hostnames`, `${at} hostname '*' needs hosting.usesDomain and an org domain: it serves any name under the domain`);
|
|
311
329
|
}
|
|
312
330
|
if (hosting.entries.filter((e) => e.hostnames.includes('*')).length > 1) {
|
|
313
|
-
return `hostname '*' is held by two entries: an org has one wildcard
|
|
331
|
+
return refusal('wildcard_held_twice', `hosting.entries.${entry.key}.hostnames`, `hostname '*' is held by two entries: an org has one wildcard`);
|
|
314
332
|
}
|
|
315
333
|
}
|
|
316
|
-
if (!hosting.usesDomain && entry
|
|
317
|
-
return `${at} hostnames need hosting.usesDomain: without it only the
|
|
334
|
+
if (!hosting.usesDomain && !isMainEntry(entry) && entry.hostnames.length > 0) {
|
|
335
|
+
return refusal('hostnames_need_domain', `hosting.entries.${entry.key}.hostnames`, `${at} hostnames need hosting.usesDomain: without it only the main app is served, at its own address`);
|
|
318
336
|
}
|
|
319
337
|
const reads = hosting.usesDomain && names?.domain;
|
|
320
338
|
if (reads &&
|
|
321
|
-
entry
|
|
339
|
+
isMainEntry(entry) &&
|
|
322
340
|
!entry.workerApp &&
|
|
323
341
|
!entry.hostnames.some((n) => n !== '*')) {
|
|
324
|
-
return `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain
|
|
342
|
+
return refusal('hostname_needed', `hosting.entries.${entry.key}.hostnames`, `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain`);
|
|
325
343
|
}
|
|
326
344
|
for (const name of entry.hostnames) {
|
|
327
345
|
if (name !== '@' && name !== '*' && !ENTRY_KEY.test(name)) {
|
|
328
|
-
return `${at} hostname '${name}' must be '@', '*' or a DNS label
|
|
346
|
+
return refusal('hostname_invalid', `hosting.entries.${entry.key}.hostnames`, `${at} hostname '${name}' must be '@', '*' or a DNS label`);
|
|
329
347
|
}
|
|
330
348
|
if (taken.has(name))
|
|
331
|
-
return `hostname '${name}' is held by two entries or listed twice
|
|
349
|
+
return refusal('hostname_not_unique', `hosting.entries.${entry.key}.hostnames`, `hostname '${name}' is held by two entries or listed twice`);
|
|
332
350
|
taken.add(name);
|
|
333
351
|
if (names?.domain &&
|
|
334
352
|
name !== '@' &&
|
|
335
353
|
name !== '*' &&
|
|
336
354
|
names.locked.has(`${name}.${names.domain}`)) {
|
|
337
|
-
return `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}
|
|
355
|
+
return refusal('hostname_locked', `hosting.entries.${entry.key}.hostnames`, `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}`);
|
|
338
356
|
}
|
|
339
357
|
}
|
|
340
358
|
}
|
|
341
359
|
return null;
|
|
342
360
|
}
|
|
343
361
|
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
362
|
+
/** Names of the parts blue/green adds to an app (`<app>-stage`, `-blue`, `-green`, `-lb`): an entry of that name would adopt one. */
|
|
363
|
+
const RESERVED_ENTRY_KEY = /^(stage|blue|green|lb)$|-(stage|blue|green|lb)$/;
|
|
344
364
|
const VOLUME_MOUNT_PATH = /^(\/(?!\.\.?(\/|$))[A-Za-z0-9._-]+)+$/;
|
|
345
365
|
/** The log volume's mount path; `executor.ts`'s `LOG_DIR` is the same value. */
|
|
346
366
|
const LOG_DIR_PATH = '/app/logs';
|
|
@@ -364,112 +384,134 @@ const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
|
364
384
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
365
385
|
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
366
386
|
// Set only on the colors of a blueGreen entry; any other entry may declare them.
|
|
367
|
-
const BLUE_GREEN_ENV_NAME = /^REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)$/;
|
|
387
|
+
const BLUE_GREEN_ENV_NAME = /^(REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)|NEXT_SERVER_ACTIONS_ENCRYPTION_KEY)$/;
|
|
368
388
|
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
369
389
|
const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
|
|
370
390
|
/** The rules for one {@link HostingEntry}; `null` when it is valid. */
|
|
371
|
-
function validateHostingEntry(entry, enabled) {
|
|
391
|
+
function validateHostingEntry(entry, enabled, reserveKeys) {
|
|
372
392
|
const at = `hosting.entries '${entry.key}'`;
|
|
373
393
|
if (!ENTRY_KEY.test(entry.key)) {
|
|
374
|
-
return `hosting.entries key '${entry.key}' must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)
|
|
394
|
+
return refusal('entry_key_invalid', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`);
|
|
395
|
+
}
|
|
396
|
+
if (reserveKeys && RESERVED_ENTRY_KEY.test(entry.key)) {
|
|
397
|
+
return refusal('entry_key_reserved', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is reserved: stage, blue, green and lb, and any key ending in -stage, -blue, -green or -lb, name the parts blue/green adds to an app`);
|
|
375
398
|
}
|
|
376
399
|
if (entry.repo !== null && !REPO.test(entry.repo)) {
|
|
377
|
-
return `${at} repo must be a repo under wtfalch, or null
|
|
400
|
+
return refusal('entry_repo_invalid', `hosting.entries.${entry.key}.repo`, `${at} repo must be a repo under wtfalch, or null`);
|
|
378
401
|
}
|
|
379
402
|
if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
|
|
380
|
-
return `${at} folder must be a relative path inside the repo
|
|
403
|
+
return refusal('entry_folder_invalid', `hosting.entries.${entry.key}.folder`, `${at} folder must be a relative path inside the repo`);
|
|
381
404
|
}
|
|
382
405
|
if (!entry.branch) {
|
|
383
|
-
return `${at} branch must be non-empty
|
|
406
|
+
return refusal('entry_branch_empty', `hosting.entries.${entry.key}.branch`, `${at} branch must be non-empty`);
|
|
384
407
|
}
|
|
385
408
|
if (!HOSTING_BUILD_PACKS.has(entry.buildPack)) {
|
|
386
|
-
return `${at} buildPack has an unknown value '${entry.buildPack}'
|
|
409
|
+
return refusal('entry_build_pack_unknown', `hosting.entries.${entry.key}.buildPack`, `${at} buildPack has an unknown value '${entry.buildPack}'`);
|
|
387
410
|
}
|
|
388
411
|
if (entry.buildPack === 'dockercompose') {
|
|
389
|
-
return `${at} buildPack dockercompose is not available for an entry
|
|
412
|
+
return refusal('entry_build_pack_unavailable', `hosting.entries.${entry.key}.buildPack`, `${at} buildPack dockercompose is not available for an entry`);
|
|
390
413
|
}
|
|
391
414
|
if (entry.port !== null &&
|
|
392
415
|
!(Number.isInteger(entry.port) && entry.port >= 1 && entry.port <= 65535)) {
|
|
393
|
-
return `${at} port must be a whole number from 1 to 65535, or null
|
|
416
|
+
return refusal('entry_port_invalid', `hosting.entries.${entry.key}.port`, `${at} port must be a whole number from 1 to 65535, or null`);
|
|
394
417
|
}
|
|
395
418
|
if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
|
|
396
|
-
return `${at} healthPath must start with '/', or be null
|
|
419
|
+
return refusal('entry_health_path_invalid', `hosting.entries.${entry.key}.healthPath`, `${at} healthPath must start with '/', or be null`);
|
|
397
420
|
}
|
|
398
421
|
if (entry.capabilities !== undefined &&
|
|
399
422
|
entry.capabilities !== 'restricted' &&
|
|
400
423
|
entry.capabilities !== 'standard') {
|
|
401
|
-
return `${at} capabilities must be 'restricted' or 'standard'
|
|
424
|
+
return refusal('entry_capabilities_invalid', `hosting.entries.${entry.key}.capabilities`, `${at} capabilities must be 'restricted' or 'standard'`);
|
|
402
425
|
}
|
|
403
426
|
if (entry.database && !enabled) {
|
|
404
|
-
return `${at} database requires hosting.enabled
|
|
427
|
+
return refusal('entry_database_needs_hosting', `hosting.entries.${entry.key}.database`, `${at} database requires hosting.enabled`);
|
|
405
428
|
}
|
|
406
429
|
const invalidVolumes = validateEntryVolumes(entry, at);
|
|
407
430
|
if (invalidVolumes)
|
|
408
431
|
return invalidVolumes;
|
|
409
|
-
const invalidBlueGreen =
|
|
432
|
+
const invalidBlueGreen = refuseBlueGreen(entry.blueGreen, at, `hosting.entries.${entry.key}.blueGreen`);
|
|
410
433
|
if (invalidBlueGreen)
|
|
411
434
|
return invalidBlueGreen;
|
|
412
435
|
if (entry.blueGreen !== undefined &&
|
|
413
|
-
entry
|
|
436
|
+
!isMainEntry(entry) &&
|
|
414
437
|
!entry.hostnames.some((name) => name !== '*')) {
|
|
415
|
-
return `${at} blueGreen needs a public hostname: the release check reaches each color through it
|
|
438
|
+
return refusal('blue_green_needs_hostname', `hosting.entries.${entry.key}.blueGreen`, `${at} blueGreen needs a public hostname: the release check reaches each color through it`);
|
|
416
439
|
}
|
|
417
440
|
for (const [name, value] of Object.entries(entry.env)) {
|
|
418
441
|
if (!name) {
|
|
419
|
-
return `${at} env needs a non-empty env var name
|
|
442
|
+
return refusal('env_name_empty', `hosting.entries.${entry.key}.env`, `${at} env needs a non-empty env var name`);
|
|
420
443
|
}
|
|
421
444
|
if (FOUNDRY_ENV_NAME.test(name) || (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
|
|
422
|
-
return `${at} env '${name}' is set by foundry and cannot be declared
|
|
445
|
+
return refusal('env_name_reserved', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' is set by foundry and cannot be declared`);
|
|
423
446
|
}
|
|
424
447
|
if (typeof value !== 'string') {
|
|
425
448
|
if (value?.from === 'platformMailKey') {
|
|
426
|
-
return `${at} env '${name}' has an unknown source 'platformMailKey': each company uses its own mail key
|
|
449
|
+
return refusal('env_source_platform_mail_key', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' has an unknown source 'platformMailKey': each company uses its own mail key`);
|
|
427
450
|
}
|
|
428
451
|
if (!APP_ENV_SOURCES.has(value?.from)) {
|
|
429
|
-
return `${at} env '${name}' has an unknown source '${value?.from}'
|
|
452
|
+
return refusal('env_source_unknown', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' has an unknown source '${value?.from}'`);
|
|
430
453
|
}
|
|
431
454
|
if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
|
|
432
|
-
return `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)
|
|
455
|
+
return refusal('env_shared_secret_name_invalid', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)`);
|
|
433
456
|
}
|
|
434
457
|
if (value.from === 'heldSecret') {
|
|
435
458
|
if (value.name !== name) {
|
|
436
|
-
return `${at} env '${name}' heldSecret name must be '${name}', the env name it is declared under
|
|
459
|
+
return refusal('env_held_secret_name_mismatch', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' heldSecret name must be '${name}', the env name it is declared under`);
|
|
437
460
|
}
|
|
438
461
|
if (!HELD_ENV_NAME.test(name)) {
|
|
439
|
-
return `${at} env '${name}' heldSecret needs an env name of letters, digits and underscores
|
|
462
|
+
return refusal('env_held_secret_name_invalid', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' heldSecret needs an env name of letters, digits and underscores`);
|
|
440
463
|
}
|
|
441
464
|
}
|
|
442
465
|
}
|
|
443
466
|
else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
|
|
444
|
-
return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}
|
|
467
|
+
return refusal('env_looks_like_secret', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`);
|
|
445
468
|
}
|
|
446
469
|
}
|
|
447
470
|
return null;
|
|
448
471
|
}
|
|
449
472
|
/** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
|
|
450
473
|
export function validateBlueGreen(value, at) {
|
|
474
|
+
return refuseBlueGreen(value, at, at)?.message ?? null;
|
|
475
|
+
}
|
|
476
|
+
/** {@link validateBlueGreen} with the code; `path` is the dotted path of the `blueGreen` value. */
|
|
477
|
+
function refuseBlueGreen(value, at, path) {
|
|
451
478
|
if (value === undefined)
|
|
452
479
|
return null;
|
|
453
480
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
454
|
-
return `${at} blueGreen must be an object
|
|
481
|
+
return refusal('blue_green_not_object', path, `${at} blueGreen must be an object`);
|
|
455
482
|
}
|
|
456
483
|
const { steps, stepHoldMinutes } = value;
|
|
457
484
|
if (steps !== undefined) {
|
|
458
485
|
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 20) {
|
|
459
|
-
return `${at} blueGreen steps must be a list of 1 to 20 percentages
|
|
486
|
+
return refusal('blue_green_steps_length', `${path}.steps`, `${at} blueGreen steps must be a list of 1 to 20 percentages`);
|
|
460
487
|
}
|
|
461
488
|
if (!steps.every((n) => Number.isInteger(n) && n >= 1 && n <= 100)) {
|
|
462
|
-
return `${at} blueGreen steps must be whole numbers from 1 to 100
|
|
489
|
+
return refusal('blue_green_steps_range', `${path}.steps`, `${at} blueGreen steps must be whole numbers from 1 to 100`);
|
|
463
490
|
}
|
|
464
491
|
if (!steps.every((n, i) => i === 0 || n > steps[i - 1])) {
|
|
465
|
-
return `${at} blueGreen steps must be ascending
|
|
492
|
+
return refusal('blue_green_steps_order', `${path}.steps`, `${at} blueGreen steps must be ascending`);
|
|
466
493
|
}
|
|
467
494
|
if (steps[steps.length - 1] !== 100)
|
|
468
|
-
return `${at} blueGreen steps must end at 100
|
|
495
|
+
return refusal('blue_green_steps_end', `${path}.steps`, `${at} blueGreen steps must end at 100`);
|
|
469
496
|
}
|
|
470
497
|
if (stepHoldMinutes !== undefined &&
|
|
471
498
|
!(Number.isInteger(stepHoldMinutes) && stepHoldMinutes >= 1 && stepHoldMinutes <= 1440)) {
|
|
472
|
-
return `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440
|
|
499
|
+
return refusal('blue_green_hold_invalid', `${path}.stepHoldMinutes`, `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440`);
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
/** {@link refuseBlueGreen} for the `blueGreen` of the company's Boule and Agora; the same codes. */
|
|
504
|
+
export function refuseBuiltinBlueGreen(org) {
|
|
505
|
+
for (const app of ['boule', 'agora']) {
|
|
506
|
+
const value = org[app];
|
|
507
|
+
if (value === undefined)
|
|
508
|
+
continue;
|
|
509
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
510
|
+
return refusal('builtin_app_invalid', app, `${app} must be an object with an optional blueGreen`);
|
|
511
|
+
}
|
|
512
|
+
const refused = refuseBlueGreen(value.blueGreen, app, `${app}.blueGreen`);
|
|
513
|
+
if (refused)
|
|
514
|
+
return refused;
|
|
473
515
|
}
|
|
474
516
|
return null;
|
|
475
517
|
}
|
|
@@ -480,38 +522,38 @@ function validateEntryVolumes(entry, at) {
|
|
|
480
522
|
if (volumes === undefined)
|
|
481
523
|
return null;
|
|
482
524
|
if (!Array.isArray(volumes))
|
|
483
|
-
return `${at} volumes must be a list
|
|
525
|
+
return refusal('volumes_not_list', `hosting.entries.${entry.key}.volumes`, `${at} volumes must be a list`);
|
|
484
526
|
if (volumes.length > MAX_ENTRY_VOLUMES) {
|
|
485
|
-
return `${at} volumes holds at most ${MAX_ENTRY_VOLUMES} volumes
|
|
527
|
+
return refusal('volumes_too_many', `hosting.entries.${entry.key}.volumes`, `${at} volumes holds at most ${MAX_ENTRY_VOLUMES} volumes`);
|
|
486
528
|
}
|
|
487
529
|
const names = new Set();
|
|
488
530
|
const paths = new Set();
|
|
489
531
|
for (const volume of volumes) {
|
|
490
532
|
const { name, mountPath } = volume ?? {};
|
|
491
533
|
if (typeof name !== 'string' || !ENTRY_KEY.test(name)) {
|
|
492
|
-
return `${at} volumes needs a name that is a DNS label (lowercase letters, digits and hyphens, 1-63 characters)
|
|
534
|
+
return refusal('volume_name_invalid', `hosting.entries.${entry.key}.volumes`, `${at} volumes needs a name that is a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`);
|
|
493
535
|
}
|
|
494
536
|
if (name === 'logs') {
|
|
495
|
-
return `${at} volumes name 'logs' is the log volume's, which every app already has
|
|
537
|
+
return refusal('volume_name_logs', `hosting.entries.${entry.key}.volumes`, `${at} volumes name 'logs' is the log volume's, which every app already has`);
|
|
496
538
|
}
|
|
497
539
|
if (names.has(name))
|
|
498
|
-
return `${at} volumes name '${name}' is not unique
|
|
540
|
+
return refusal('volume_name_not_unique', `hosting.entries.${entry.key}.volumes`, `${at} volumes name '${name}' is not unique`);
|
|
499
541
|
names.add(name);
|
|
500
542
|
if (typeof mountPath !== 'string' || !VOLUME_MOUNT_PATH.test(mountPath)) {
|
|
501
|
-
return `${at} volumes '${name}' mountPath must be an absolute path of letters, digits, '.', '_', '-' and '/', with no '..', no empty part and no trailing '/'
|
|
543
|
+
return refusal('volume_mount_path_invalid', `hosting.entries.${entry.key}.volumes`, `${at} volumes '${name}' mountPath must be an absolute path of letters, digits, '.', '_', '-' and '/', with no '..', no empty part and no trailing '/'`);
|
|
502
544
|
}
|
|
503
545
|
if (mountPath.length > VOLUME_MOUNT_PATH_MAX) {
|
|
504
|
-
return `${at} volumes '${name}' mountPath holds at most ${VOLUME_MOUNT_PATH_MAX} characters
|
|
546
|
+
return refusal('volume_mount_path_too_long', `hosting.entries.${entry.key}.volumes`, `${at} volumes '${name}' mountPath holds at most ${VOLUME_MOUNT_PATH_MAX} characters`);
|
|
505
547
|
}
|
|
506
548
|
if (mountPath === LOG_DIR_PATH || mountPath.startsWith(`${LOG_DIR_PATH}/`)) {
|
|
507
|
-
return `${at} volumes '${name}' mountPath ${LOG_DIR_PATH} is the log volume's, which every app already has
|
|
549
|
+
return refusal('volume_mount_path_log_dir', `hosting.entries.${entry.key}.volumes`, `${at} volumes '${name}' mountPath ${LOG_DIR_PATH} is the log volume's, which every app already has`);
|
|
508
550
|
}
|
|
509
551
|
if (mountPath === '/app' ||
|
|
510
552
|
VOLUME_FORBIDDEN_ROOTS.some((root) => mountPath === root || mountPath.startsWith(`${root}/`))) {
|
|
511
|
-
return `${at} volumes '${name}' mountPath must not be /app or a system path (${VOLUME_FORBIDDEN_ROOTS.join(', ')})
|
|
553
|
+
return refusal('volume_mount_path_forbidden', `hosting.entries.${entry.key}.volumes`, `${at} volumes '${name}' mountPath must not be /app or a system path (${VOLUME_FORBIDDEN_ROOTS.join(', ')})`);
|
|
512
554
|
}
|
|
513
555
|
if (paths.has(mountPath))
|
|
514
|
-
return `${at} volumes mountPath '${mountPath}' is not unique
|
|
556
|
+
return refusal('volume_mount_path_not_unique', `hosting.entries.${entry.key}.volumes`, `${at} volumes mountPath '${mountPath}' is not unique`);
|
|
515
557
|
paths.add(mountPath);
|
|
516
558
|
}
|
|
517
559
|
return null;
|
|
@@ -528,36 +570,36 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
528
570
|
return null;
|
|
529
571
|
const at = `hosting.entries '${entry.key}' uses.auth`;
|
|
530
572
|
if (!hosting.enabled)
|
|
531
|
-
return `${at} requires hosting.enabled
|
|
573
|
+
return refusal('auth_needs_hosting', `hosting.entries.${entry.key}.uses.auth`, `${at} requires hosting.enabled`);
|
|
532
574
|
if (auth.users !== 'staff' && auth.users !== 'customers') {
|
|
533
|
-
return `${at} users must be 'staff' or 'customers'
|
|
575
|
+
return refusal('auth_users_invalid', `hosting.entries.${entry.key}.uses.auth.users`, `${at} users must be 'staff' or 'customers'`);
|
|
534
576
|
}
|
|
535
577
|
if (auth.registration !== 'open' && auth.registration !== 'closed') {
|
|
536
|
-
return `${at} registration must be 'open' or 'closed'
|
|
578
|
+
return refusal('auth_registration_invalid', `hosting.entries.${entry.key}.uses.auth.registration`, `${at} registration must be 'open' or 'closed'`);
|
|
537
579
|
}
|
|
538
580
|
// The staff organisation holds the org's own people and its project.
|
|
539
581
|
if (auth.users === 'staff' && auth.registration === 'open') {
|
|
540
|
-
return `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff
|
|
582
|
+
return refusal('registration_open_needs_customers', `hosting.entries.${entry.key}.uses.auth.registration`, `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff`);
|
|
541
583
|
}
|
|
542
584
|
if (auth.replacesBrokerEntry !== undefined &&
|
|
543
585
|
(typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
|
|
544
|
-
return `${at} replacesBrokerEntry must be the non-empty name of a broker entry
|
|
586
|
+
return refusal('auth_replaces_broker_entry_invalid', `hosting.entries.${entry.key}.uses.auth.replacesBrokerEntry`, `${at} replacesBrokerEntry must be the non-empty name of a broker entry`);
|
|
545
587
|
}
|
|
546
588
|
if (typeof auth.replacesBrokerEntry === 'string' &&
|
|
547
589
|
isFoundryOwnedBrokerEntryName(auth.replacesBrokerEntry)) {
|
|
548
|
-
return `${at} replacesBrokerEntry '${auth.replacesBrokerEntry}' is a broker entry foundry writes itself
|
|
590
|
+
return refusal('auth_replaces_broker_entry_owned', `hosting.entries.${entry.key}.uses.auth.replacesBrokerEntry`, `${at} replacesBrokerEntry '${auth.replacesBrokerEntry}' is a broker entry foundry writes itself`);
|
|
549
591
|
}
|
|
550
592
|
if (!hasExactHostname(entry, hosting, domain)) {
|
|
551
|
-
return `${at} needs an exact hostname to sign in at
|
|
593
|
+
return refusal('auth_needs_exact_hostname', `hosting.entries.${entry.key}.hostnames`, `${at} needs an exact hostname to sign in at`);
|
|
552
594
|
}
|
|
553
595
|
if (auth.mcp !== undefined) {
|
|
554
596
|
if (auth.users !== 'customers' || auth.registration !== 'open') {
|
|
555
|
-
return `${at} mcp needs users 'customers' and registration 'open': an MCP client signs the customer in, and only customers register themselves
|
|
597
|
+
return refusal('mcp_needs_open_customers', `hosting.entries.${entry.key}.uses.auth.mcp`, `${at} mcp needs users 'customers' and registration 'open': an MCP client signs the customer in, and only customers register themselves`);
|
|
556
598
|
}
|
|
557
599
|
if (typeof auth.mcp?.path !== 'string' ||
|
|
558
600
|
!MCP_PATH.test(auth.mcp.path) ||
|
|
559
601
|
auth.mcp.path.length > 200) {
|
|
560
|
-
return `${at} mcp.path must be an absolute path of letters, digits, '_', '~' and '-' segments, such as '/mcp'
|
|
602
|
+
return refusal('mcp_path_invalid', `hosting.entries.${entry.key}.uses.auth.mcp.path`, `${at} mcp.path must be an absolute path of letters, digits, '_', '~' and '-' segments, such as '/mcp'`);
|
|
561
603
|
}
|
|
562
604
|
const onboarding = auth.mcp.onboardingPath;
|
|
563
605
|
if (onboarding !== undefined &&
|
|
@@ -570,21 +612,21 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
570
612
|
onboarding.includes('\\') ||
|
|
571
613
|
/[\s\p{Cc}]/u.test(onboarding) ||
|
|
572
614
|
onboarding.length > ONBOARDING_PATH_MAX_LENGTH)) {
|
|
573
|
-
return `${at} mcp.onboardingPath must be an absolute path that starts with '/', such as '/onboarding', with no '..', '?', '#' or spaces, and at most ${ONBOARDING_PATH_MAX_LENGTH} characters
|
|
615
|
+
return refusal('mcp_onboarding_path_invalid', `hosting.entries.${entry.key}.uses.auth.mcp.onboardingPath`, `${at} mcp.onboardingPath must be an absolute path that starts with '/', such as '/onboarding', with no '..', '?', '#' or spaces, and at most ${ONBOARDING_PATH_MAX_LENGTH} characters`);
|
|
574
616
|
}
|
|
575
617
|
}
|
|
576
618
|
const signIn = auth.signIn;
|
|
577
619
|
if (signIn !== undefined && signIn !== 'password' && signIn !== 'email-code') {
|
|
578
|
-
return `${at} signIn must be 'password' or 'email-code'
|
|
620
|
+
return refusal('auth_sign_in_invalid', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn must be 'password' or 'email-code'`);
|
|
579
621
|
}
|
|
580
622
|
if (signIn === 'email-code' && auth.users !== 'customers') {
|
|
581
|
-
return `${at} signIn 'email-code' needs users 'customers': only customers sign in with an emailed code
|
|
623
|
+
return refusal('email_code_needs_customers', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn 'email-code' needs users 'customers': only customers sign in with an emailed code`);
|
|
582
624
|
}
|
|
583
625
|
if (entry.key === 'agora' ||
|
|
584
626
|
entry.key === 'boule' ||
|
|
585
627
|
entry.key === 'cms' ||
|
|
586
628
|
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
587
|
-
return `${at} client name '${entry.key}' is already held by another ZITADEL client
|
|
629
|
+
return refusal('client_name_taken', `hosting.entries.${entry.key}.key`, `${at} client name '${entry.key}' is already held by another ZITADEL client`);
|
|
588
630
|
}
|
|
589
631
|
return null;
|
|
590
632
|
}
|
|
@@ -594,7 +636,7 @@ function hasExactHostname(entry, hosting, domain) {
|
|
|
594
636
|
return true; // Its host is workerApp.host, or <slug>[-<key>].oikos.host.
|
|
595
637
|
return hosting.usesDomain && domain !== null
|
|
596
638
|
? entry.hostnames.some((name) => name !== '*')
|
|
597
|
-
: entry
|
|
639
|
+
: isMainEntry(entry);
|
|
598
640
|
}
|
|
599
641
|
/**
|
|
600
642
|
* `uses.ai`: at least one offering, permissions from the known set, a
|
|
@@ -607,32 +649,32 @@ function validateEntryAi(entry, hosting, domain) {
|
|
|
607
649
|
return null;
|
|
608
650
|
const at = `hosting.entries '${entry.key}' uses.ai`;
|
|
609
651
|
if (!hosting.enabled)
|
|
610
|
-
return `${at} requires hosting.enabled
|
|
652
|
+
return refusal('ai_needs_hosting', `hosting.entries.${entry.key}.uses.ai`, `${at} requires hosting.enabled`);
|
|
611
653
|
const isNames = (value) => Array.isArray(value) &&
|
|
612
654
|
value.length > 0 &&
|
|
613
655
|
value.every((v) => typeof v === 'string' && v !== '') &&
|
|
614
656
|
new Set(value).size === value.length;
|
|
615
657
|
if (!isNames(ai.offerings))
|
|
616
|
-
return `${at} offerings must be a list of distinct, non-empty names
|
|
658
|
+
return refusal('ai_offerings_invalid', `hosting.entries.${entry.key}.uses.ai.offerings`, `${at} offerings must be a list of distinct, non-empty names`);
|
|
617
659
|
if (ai.permissions !== undefined) {
|
|
618
660
|
const known = AI_ENTRY_PERMISSIONS;
|
|
619
661
|
if (!isNames(ai.permissions) || !ai.permissions.every((p) => known.includes(p))) {
|
|
620
|
-
return `${at} permissions must be distinct values of: ${known.join(', ')}
|
|
662
|
+
return refusal('ai_permissions_invalid', `hosting.entries.${entry.key}.uses.ai.permissions`, `${at} permissions must be distinct values of: ${known.join(', ')}`);
|
|
621
663
|
}
|
|
622
664
|
}
|
|
623
665
|
if (ai.webhookPath !== undefined) {
|
|
624
666
|
if (typeof ai.webhookPath !== 'string' ||
|
|
625
667
|
!/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,199}$/.test(ai.webhookPath) ||
|
|
626
668
|
ai.webhookPath.startsWith('//')) {
|
|
627
|
-
return `${at} webhookPath must be a path that starts with one '/', with no query or fragment
|
|
669
|
+
return refusal('ai_webhook_path_invalid', `hosting.entries.${entry.key}.uses.ai.webhookPath`, `${at} webhookPath must be a path that starts with one '/', with no query or fragment`);
|
|
628
670
|
}
|
|
629
671
|
if (!hasExactHostname(entry, hosting, domain)) {
|
|
630
|
-
return `${at} webhookPath needs an exact hostname to receive the webhook at
|
|
672
|
+
return refusal('ai_webhook_needs_exact_hostname', `hosting.entries.${entry.key}.uses.ai.webhookPath`, `${at} webhookPath needs an exact hostname to receive the webhook at`);
|
|
631
673
|
}
|
|
632
674
|
}
|
|
633
675
|
const named = Object.keys(entry.env ?? {}).find((name) => AI_ENTRY_ENV_NAMES.includes(name));
|
|
634
676
|
if (named)
|
|
635
|
-
return `${at} writes ${named}; remove it from env
|
|
677
|
+
return refusal('ai_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
636
678
|
return null;
|
|
637
679
|
}
|
|
638
680
|
/**
|
|
@@ -646,31 +688,31 @@ function validateEntryFiles(entry, hosting, domain) {
|
|
|
646
688
|
return null;
|
|
647
689
|
const at = `hosting.entries '${entry.key}' uses.files`;
|
|
648
690
|
if (!hosting.enabled)
|
|
649
|
-
return `${at} requires hosting.enabled
|
|
691
|
+
return refusal('files_needs_hosting', `hosting.entries.${entry.key}.uses.files`, `${at} requires hosting.enabled`);
|
|
650
692
|
const names = (value) => Array.isArray(value) &&
|
|
651
693
|
value.every((v) => typeof v === 'string' && v !== '') &&
|
|
652
694
|
new Set(value).size === value.length;
|
|
653
695
|
if (!names(files.collections) || !files.collections.every((n) => /^[A-Z][A-Z0-9_]*$/.test(n))) {
|
|
654
|
-
return `${at} collections must be distinct names of uppercase letters, digits and '_', starting with a letter
|
|
696
|
+
return refusal('files_collections_invalid', `hosting.entries.${entry.key}.uses.files.collections`, `${at} collections must be distinct names of uppercase letters, digits and '_', starting with a letter`);
|
|
655
697
|
}
|
|
656
698
|
if (files.permissions !== undefined) {
|
|
657
699
|
const known = FILES_ENTRY_PERMISSIONS;
|
|
658
700
|
if (!names(files.permissions) ||
|
|
659
701
|
files.permissions.length === 0 ||
|
|
660
702
|
!files.permissions.every((p) => known.includes(p))) {
|
|
661
|
-
return `${at} permissions must be distinct values of: ${known.join(', ')}
|
|
703
|
+
return refusal('files_permissions_invalid', `hosting.entries.${entry.key}.uses.files.permissions`, `${at} permissions must be distinct values of: ${known.join(', ')}`);
|
|
662
704
|
}
|
|
663
705
|
}
|
|
664
706
|
if (files.shareHost !== undefined && typeof files.shareHost !== 'boolean') {
|
|
665
|
-
return `${at} shareHost must be true or false
|
|
707
|
+
return refusal('files_share_host_invalid', `hosting.entries.${entry.key}.uses.files.shareHost`, `${at} shareHost must be true or false`);
|
|
666
708
|
}
|
|
667
709
|
if (files.shareHost && !(hosting.usesDomain && domain)) {
|
|
668
|
-
return `${at} shareHost needs hosting.usesDomain and a domain: it is served at share.<domain
|
|
710
|
+
return refusal('files_share_host_needs_domain', `hosting.entries.${entry.key}.uses.files.shareHost`, `${at} shareHost needs hosting.usesDomain and a domain: it is served at share.<domain>`);
|
|
669
711
|
}
|
|
670
712
|
const written = [...FILES_ENTRY_ENV_NAMES, ...files.collections.map(filesCollectionEnvName)];
|
|
671
713
|
const named = Object.keys(entry.env ?? {}).find((name) => written.includes(name));
|
|
672
714
|
if (named)
|
|
673
|
-
return `${at} writes ${named}; remove it from env
|
|
715
|
+
return refusal('files_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
674
716
|
return null;
|
|
675
717
|
}
|
|
676
718
|
/** A route pattern's zone apex: the host part before the first `/`, with one leading `*.` label stripped. */
|
|
@@ -708,35 +750,36 @@ function validateEntryWorker(entry, hosting, domain) {
|
|
|
708
750
|
return null;
|
|
709
751
|
const at = `hosting.entries '${entry.key}' worker`;
|
|
710
752
|
if (!hosting.enabled)
|
|
711
|
-
return `${at} requires hosting.enabled
|
|
753
|
+
return refusal('worker_needs_hosting', `hosting.entries.${entry.key}.worker`, `${at} requires hosting.enabled`);
|
|
712
754
|
if (entry.repo === null) {
|
|
713
|
-
return `${at} needs its own repo (repo: null is not a Worker source)
|
|
755
|
+
return refusal('worker_needs_repo', `hosting.entries.${entry.key}.repo`, `${at} needs its own repo (repo: null is not a Worker source)`);
|
|
714
756
|
}
|
|
715
757
|
if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
|
|
716
|
-
return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit
|
|
758
|
+
return refusal('worker_key_too_long', `hosting.entries.${entry.key}.key`, `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`);
|
|
717
759
|
}
|
|
718
760
|
const invalidArtifact = validateArtifact(at, worker.artifactUrl, worker.artifactSha256, entry.repo);
|
|
719
|
-
if (invalidArtifact)
|
|
720
|
-
return invalidArtifact;
|
|
761
|
+
if (invalidArtifact) {
|
|
762
|
+
return refusal('worker_artifact_invalid', `hosting.entries.${entry.key}.worker`, invalidArtifact);
|
|
763
|
+
}
|
|
721
764
|
if (!WORKER_ROUTE.test(worker.route)) {
|
|
722
|
-
return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'
|
|
765
|
+
return refusal('worker_route_invalid', `hosting.entries.${entry.key}.worker.route`, `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`);
|
|
723
766
|
}
|
|
724
767
|
const zone = workerRouteZone(worker.route);
|
|
725
768
|
if (!domain || zone !== domain) {
|
|
726
|
-
return `${at} route '${worker.route}' must be on the org's own domain${domain ? ` (${domain})` : ''}: worker is only for our own projects
|
|
769
|
+
return refusal('worker_route_off_domain', `hosting.entries.${entry.key}.worker.route`, `${at} route '${worker.route}' must be on the org's own domain${domain ? ` (${domain})` : ''}: worker is only for our own projects`);
|
|
727
770
|
}
|
|
728
771
|
// A wildcard route (`*.<domain>/*`) is allowed beside hostnames foundry owns:
|
|
729
772
|
// `planWorkers` plans a no-Worker route for each of them, so they stay out of it.
|
|
730
773
|
for (const limit of worker.bindings?.rateLimits ?? []) {
|
|
731
774
|
if (!limit.name.trim())
|
|
732
|
-
return `${at} bindings.rateLimits needs a non-empty name
|
|
775
|
+
return refusal('worker_rate_limit_name_missing', `hosting.entries.${entry.key}.worker.bindings.rateLimits`, `${at} bindings.rateLimits needs a non-empty name`);
|
|
733
776
|
if (!limit.namespaceId.trim())
|
|
734
|
-
return `${at} bindings.rateLimits needs a non-empty namespaceId
|
|
777
|
+
return refusal('worker_rate_limit_namespace_missing', `hosting.entries.${entry.key}.worker.bindings.rateLimits`, `${at} bindings.rateLimits needs a non-empty namespaceId`);
|
|
735
778
|
if (!(Number.isInteger(limit.limit) && limit.limit > 0)) {
|
|
736
|
-
return `${at} bindings.rateLimits limit must be a positive whole number
|
|
779
|
+
return refusal('worker_rate_limit_invalid', `hosting.entries.${entry.key}.worker.bindings.rateLimits`, `${at} bindings.rateLimits limit must be a positive whole number`);
|
|
737
780
|
}
|
|
738
781
|
if (limit.period !== 10 && limit.period !== 60) {
|
|
739
|
-
return `${at} bindings.rateLimits period must be 10 or 60 (Cloudflare allows no other value)
|
|
782
|
+
return refusal('worker_rate_limit_period_invalid', `hosting.entries.${entry.key}.worker.bindings.rateLimits`, `${at} bindings.rateLimits period must be 10 or 60 (Cloudflare allows no other value)`);
|
|
740
783
|
}
|
|
741
784
|
}
|
|
742
785
|
return null;
|
|
@@ -752,15 +795,15 @@ function validateEntryEdge(entry, hosting, domain) {
|
|
|
752
795
|
return null;
|
|
753
796
|
const at = `hosting.entries '${entry.key}' uses.edge`;
|
|
754
797
|
if (edge !== true)
|
|
755
|
-
return `${at} must be true
|
|
798
|
+
return refusal('edge_not_true', `hosting.entries.${entry.key}.uses.edge`, `${at} must be true`);
|
|
756
799
|
if (!hosting.enabled)
|
|
757
|
-
return `${at} requires hosting.enabled
|
|
800
|
+
return refusal('edge_needs_hosting', `hosting.entries.${entry.key}.uses.edge`, `${at} requires hosting.enabled`);
|
|
758
801
|
if (!hosting.usesDomain || !domain) {
|
|
759
|
-
return `${at} needs hosting.usesDomain and an org domain: edge serves only the org's own domain
|
|
802
|
+
return refusal('edge_needs_domain', `hosting.entries.${entry.key}.uses.edge`, `${at} needs hosting.usesDomain and an org domain: edge serves only the org's own domain`);
|
|
760
803
|
}
|
|
761
804
|
const named = Object.keys(entry.env ?? {}).find((name) => EDGE_ENTRY_ENV_NAMES.includes(name));
|
|
762
805
|
if (named)
|
|
763
|
-
return `${at} writes ${named}; remove it from env
|
|
806
|
+
return refusal('edge_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
764
807
|
return null;
|
|
765
808
|
}
|
|
766
809
|
function urlHasPassword(value) {
|
|
@@ -984,6 +1027,10 @@ export function validateSignIn(org) {
|
|
|
984
1027
|
* the customers organisation.
|
|
985
1028
|
*/
|
|
986
1029
|
export function validateCustomers(org) {
|
|
1030
|
+
return refuseCustomers(org)?.message ?? null;
|
|
1031
|
+
}
|
|
1032
|
+
/** {@link validateCustomers} with the code and path of the refusal. */
|
|
1033
|
+
export function refuseCustomers(org) {
|
|
987
1034
|
const customers = org.customers;
|
|
988
1035
|
if (customers !== undefined) {
|
|
989
1036
|
const value = customers;
|
|
@@ -993,14 +1040,15 @@ export function validateCustomers(org) {
|
|
|
993
1040
|
typeof value?.enabled !== 'boolean' ||
|
|
994
1041
|
typeof value?.signUp !== 'boolean' ||
|
|
995
1042
|
Object.keys(customers).some((key) => key !== 'enabled' && key !== 'signUp')) {
|
|
996
|
-
return 'customers must be { enabled: boolean, signUp: boolean }';
|
|
1043
|
+
return refusal('customers_invalid', 'customers', 'customers must be { enabled: boolean, signUp: boolean }');
|
|
1044
|
+
}
|
|
1045
|
+
if (value.signUp && !value.enabled) {
|
|
1046
|
+
return refusal('customers_sign_up_needs_enabled', 'customers.signUp', 'customers.signUp needs customers.enabled');
|
|
997
1047
|
}
|
|
998
|
-
if (value.signUp && !value.enabled)
|
|
999
|
-
return 'customers.signUp needs customers.enabled';
|
|
1000
1048
|
}
|
|
1001
1049
|
const open = org.hosting.entries.find((entry) => entry.uses?.auth?.registration === 'open');
|
|
1002
1050
|
if (open && !(org.customers?.enabled && org.customers.signUp)) {
|
|
1003
|
-
return `hosting.entries '${open.key}' uses.auth registration 'open' needs customers.enabled and customers.signUp
|
|
1051
|
+
return refusal('registration_open_needs_sign_up', `hosting.entries.${open.key}.uses.auth.registration`, `hosting.entries '${open.key}' uses.auth registration 'open' needs customers.enabled and customers.signUp`);
|
|
1004
1052
|
}
|
|
1005
1053
|
return null;
|
|
1006
1054
|
}
|