@wtfalch/foundry 0.46.0 → 0.47.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 +8 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/org.d.ts +31 -5
- package/dist/org.js +152 -125
- package/dist/plan/diff.js +6 -5
- package/dist/plan/executor.js +8 -8
- 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 +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/plan-apply.d.ts +30 -1
- package/dist/plan/plan-apply.js +70 -1
- package/dist/plan/platform-providers.js +3 -0
- package/dist/plan/providers-readers.js +15 -4
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +10 -0
- 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 +67 -8
- package/dist/store/schema.d.ts +72 -0
- package/dist/store/schema.js +4 -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,20 +146,20 @@ 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
|
}
|
|
147
159
|
const seenKeys = new Set();
|
|
148
160
|
for (const entry of hosting.entries) {
|
|
149
161
|
if (seenKeys.has(entry.key)) {
|
|
150
|
-
return `hosting.entries key '${entry.key}' is not unique
|
|
162
|
+
return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
|
|
151
163
|
}
|
|
152
164
|
seenKeys.add(entry.key);
|
|
153
165
|
const invalid = validateHostingEntry(entry, hosting.enabled);
|
|
@@ -166,14 +178,19 @@ export function validateHosting(hosting, names) {
|
|
|
166
178
|
if (invalidWorker)
|
|
167
179
|
return invalidWorker;
|
|
168
180
|
const invalidWorkerApp = validateEntryWorkerApp(entry, hosting, names);
|
|
169
|
-
if (invalidWorkerApp)
|
|
170
|
-
return invalidWorkerApp;
|
|
181
|
+
if (invalidWorkerApp) {
|
|
182
|
+
return refusal('worker_app_invalid', `hosting.entries.${entry.key}.workerApp`, invalidWorkerApp);
|
|
183
|
+
}
|
|
171
184
|
const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
|
|
172
185
|
if (invalidEdgeUse)
|
|
173
186
|
return invalidEdgeUse;
|
|
174
187
|
}
|
|
188
|
+
const mains = hosting.entries.filter(isMainEntry);
|
|
189
|
+
if (mains.length > 1) {
|
|
190
|
+
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`);
|
|
191
|
+
}
|
|
175
192
|
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';
|
|
193
|
+
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
194
|
}
|
|
178
195
|
// #318 re-review: two entries declaring the same worker route both throw at
|
|
179
196
|
// apply (`cloudflareWorker` refuses a route already bound to a Worker
|
|
@@ -185,7 +202,7 @@ export function validateHosting(hosting, names) {
|
|
|
185
202
|
if (!entry.worker)
|
|
186
203
|
continue;
|
|
187
204
|
if (seenWorkerRoutes.has(entry.worker.route)) {
|
|
188
|
-
return `hosting.entries '${entry.key}' worker route '${entry.worker.route}' is already declared by another entry
|
|
205
|
+
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
206
|
}
|
|
190
207
|
seenWorkerRoutes.add(entry.worker.route);
|
|
191
208
|
}
|
|
@@ -193,7 +210,7 @@ export function validateHosting(hosting, names) {
|
|
|
193
210
|
if (invalidNames)
|
|
194
211
|
return invalidNames;
|
|
195
212
|
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';
|
|
213
|
+
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
214
|
}
|
|
198
215
|
const invalidGates = validateGates(hosting, names?.domain, names?.locked);
|
|
199
216
|
if (invalidGates)
|
|
@@ -204,7 +221,7 @@ export function validateHosting(hosting, names) {
|
|
|
204
221
|
if (invalidRule)
|
|
205
222
|
return invalidRule;
|
|
206
223
|
if (seenAccessHostnames.has(rule.hostname)) {
|
|
207
|
-
return `hosting.access hostname '${rule.hostname}' is listed twice
|
|
224
|
+
return refusal('access_hostname_not_unique', 'hosting.access', `hosting.access hostname '${rule.hostname}' is listed twice`);
|
|
208
225
|
}
|
|
209
226
|
seenAccessHostnames.add(rule.hostname);
|
|
210
227
|
}
|
|
@@ -220,20 +237,20 @@ const ACCESS_RULE_HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-
|
|
|
220
237
|
function validateAccessRule(rule, hosting, names) {
|
|
221
238
|
const at = `hosting.access '${rule.hostname}'`;
|
|
222
239
|
if (!rule.hostname || !ACCESS_RULE_HOSTNAME.test(rule.hostname)) {
|
|
223
|
-
return `hosting.access entries need a full hostname ('${rule.hostname}' is not one)
|
|
240
|
+
return refusal('access_hostname_invalid', 'hosting.access', `hosting.access entries need a full hostname ('${rule.hostname}' is not one)`);
|
|
224
241
|
}
|
|
225
242
|
if (!hosting.enabled) {
|
|
226
|
-
return `${at} requires hosting.enabled
|
|
243
|
+
return refusal('access_needs_hosting', 'hosting.access', `${at} requires hosting.enabled`);
|
|
227
244
|
}
|
|
228
245
|
if (!ACCESS_RULE_KINDS.has(rule.kind)) {
|
|
229
|
-
return `${at} has an unknown kind '${rule.kind}'
|
|
246
|
+
return refusal('access_kind_unknown', 'hosting.access', `${at} has an unknown kind '${rule.kind}'`);
|
|
230
247
|
}
|
|
231
248
|
if (rule.kind === 'service-token' && !rule.serviceTokenName) {
|
|
232
|
-
return `${at} kind 'service-token' needs a non-empty serviceTokenName
|
|
249
|
+
return refusal('access_service_token_name_missing', 'hosting.access', `${at} kind 'service-token' needs a non-empty serviceTokenName`);
|
|
233
250
|
}
|
|
234
251
|
if (names) {
|
|
235
252
|
if (names.locked.has(rule.hostname)) {
|
|
236
|
-
return `${at} is a name foundry owns
|
|
253
|
+
return refusal('access_hostname_locked', 'hosting.access', `${at} is a name foundry owns`);
|
|
237
254
|
}
|
|
238
255
|
const { domain } = names;
|
|
239
256
|
// Any name under the domain: `share.<domain>` and `storage.<domain>` are
|
|
@@ -242,7 +259,7 @@ function validateAccessRule(rule, hosting, names) {
|
|
|
242
259
|
domain !== null &&
|
|
243
260
|
(rule.hostname === domain || rule.hostname.endsWith(`.${domain}`));
|
|
244
261
|
if (!served) {
|
|
245
|
-
return `${at} is not a name the org serves under its own domain
|
|
262
|
+
return refusal('access_hostname_not_served', 'hosting.access', `${at} is not a name the org serves under its own domain`);
|
|
246
263
|
}
|
|
247
264
|
}
|
|
248
265
|
return null;
|
|
@@ -256,7 +273,7 @@ function validateAccessRule(rule, hosting, names) {
|
|
|
256
273
|
*/
|
|
257
274
|
function validateGates(hosting, domain, locked) {
|
|
258
275
|
if (!Number.isInteger(hosting.gateLimit) || hosting.gateLimit < 1) {
|
|
259
|
-
return 'hosting.gateLimit must be a positive whole number';
|
|
276
|
+
return refusal('gate_limit_invalid', 'hosting.gateLimit', 'hosting.gateLimit must be a positive whole number');
|
|
260
277
|
}
|
|
261
278
|
const seenKeys = new Set();
|
|
262
279
|
for (const entry of hosting.entries) {
|
|
@@ -264,33 +281,33 @@ function validateGates(hosting, domain, locked) {
|
|
|
264
281
|
for (const gate of entry.gates) {
|
|
265
282
|
const at = `hosting.entries '${entry.key}' gate '${gate.key}'`;
|
|
266
283
|
if (!hosting.enabled || !hosting.usesDomain || domain === null) {
|
|
267
|
-
return `${at} needs hosting.enabled, hosting.usesDomain and an org domain
|
|
284
|
+
return refusal('gate_needs_domain', `hosting.entries.${entry.key}.gates.${gate.key}`, `${at} needs hosting.enabled, hosting.usesDomain and an org domain`);
|
|
268
285
|
}
|
|
269
286
|
if (!ENTRY_KEY.test(gate.key)) {
|
|
270
|
-
return `${at} key must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)
|
|
287
|
+
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
288
|
}
|
|
272
289
|
if (seenKeys.has(gate.key))
|
|
273
|
-
return `${at} key is used by two gates
|
|
290
|
+
return refusal('gate_key_not_unique', `hosting.entries.${entry.key}.gates.${gate.key}.key`, `${at} key is used by two gates`);
|
|
274
291
|
seenKeys.add(gate.key);
|
|
275
292
|
if (gate.hostnames.length === 0)
|
|
276
|
-
return `${at} needs at least one hostname
|
|
293
|
+
return refusal('gate_needs_hostname', `hosting.entries.${entry.key}.gates.${gate.key}.hostnames`, `${at} needs at least one hostname`);
|
|
277
294
|
for (const name of gate.hostnames) {
|
|
278
295
|
if (!entry.hostnames.includes(name)) {
|
|
279
|
-
return `${at} hostname '${name}' is not one of the entry's hostnames
|
|
296
|
+
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
297
|
}
|
|
281
298
|
if (taken.has(name))
|
|
282
|
-
return `${at} hostname '${name}' is held by two gates or listed twice
|
|
299
|
+
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
300
|
taken.add(name);
|
|
284
301
|
if (domain && name !== '*') {
|
|
285
302
|
const full = name === '@' ? domain : `${name}.${domain}`;
|
|
286
303
|
if (locked?.has(full))
|
|
287
|
-
return `${at} hostname '${name}' is a name foundry owns: ${full}
|
|
304
|
+
return refusal('gate_hostname_locked', `hosting.entries.${entry.key}.gates.${gate.key}.hostnames`, `${at} hostname '${name}' is a name foundry owns: ${full}`);
|
|
288
305
|
}
|
|
289
306
|
}
|
|
290
307
|
}
|
|
291
308
|
}
|
|
292
309
|
if (seenKeys.size > hosting.gateLimit) {
|
|
293
|
-
return `hosting declares ${seenKeys.size} gates, over its limit of ${hosting.gateLimit}
|
|
310
|
+
return refusal('gate_limit_exceeded', 'hosting.gateLimit', `hosting declares ${seenKeys.size} gates, over its limit of ${hosting.gateLimit}`);
|
|
294
311
|
}
|
|
295
312
|
return null;
|
|
296
313
|
}
|
|
@@ -307,34 +324,34 @@ function validateEntryHostnames(hosting, names) {
|
|
|
307
324
|
const at = `hosting.entries '${entry.key}'`;
|
|
308
325
|
if (entry.hostnames.includes('*')) {
|
|
309
326
|
if (!hosting.usesDomain || (names && !names.domain)) {
|
|
310
|
-
return `${at} hostname '*' needs hosting.usesDomain and an org domain: it serves any name under the domain
|
|
327
|
+
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
328
|
}
|
|
312
329
|
if (hosting.entries.filter((e) => e.hostnames.includes('*')).length > 1) {
|
|
313
|
-
return `hostname '*' is held by two entries: an org has one wildcard
|
|
330
|
+
return refusal('wildcard_held_twice', `hosting.entries.${entry.key}.hostnames`, `hostname '*' is held by two entries: an org has one wildcard`);
|
|
314
331
|
}
|
|
315
332
|
}
|
|
316
|
-
if (!hosting.usesDomain && entry
|
|
317
|
-
return `${at} hostnames need hosting.usesDomain: without it only the
|
|
333
|
+
if (!hosting.usesDomain && !isMainEntry(entry) && entry.hostnames.length > 0) {
|
|
334
|
+
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
335
|
}
|
|
319
336
|
const reads = hosting.usesDomain && names?.domain;
|
|
320
337
|
if (reads &&
|
|
321
|
-
entry
|
|
338
|
+
isMainEntry(entry) &&
|
|
322
339
|
!entry.workerApp &&
|
|
323
340
|
!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
|
|
341
|
+
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
342
|
}
|
|
326
343
|
for (const name of entry.hostnames) {
|
|
327
344
|
if (name !== '@' && name !== '*' && !ENTRY_KEY.test(name)) {
|
|
328
|
-
return `${at} hostname '${name}' must be '@', '*' or a DNS label
|
|
345
|
+
return refusal('hostname_invalid', `hosting.entries.${entry.key}.hostnames`, `${at} hostname '${name}' must be '@', '*' or a DNS label`);
|
|
329
346
|
}
|
|
330
347
|
if (taken.has(name))
|
|
331
|
-
return `hostname '${name}' is held by two entries or listed twice
|
|
348
|
+
return refusal('hostname_not_unique', `hosting.entries.${entry.key}.hostnames`, `hostname '${name}' is held by two entries or listed twice`);
|
|
332
349
|
taken.add(name);
|
|
333
350
|
if (names?.domain &&
|
|
334
351
|
name !== '@' &&
|
|
335
352
|
name !== '*' &&
|
|
336
353
|
names.locked.has(`${name}.${names.domain}`)) {
|
|
337
|
-
return `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}
|
|
354
|
+
return refusal('hostname_locked', `hosting.entries.${entry.key}.hostnames`, `${at} hostname '${name}' is a name foundry owns: ${name}.${names.domain}`);
|
|
338
355
|
}
|
|
339
356
|
}
|
|
340
357
|
}
|
|
@@ -371,105 +388,109 @@ const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
|
|
|
371
388
|
function validateHostingEntry(entry, enabled) {
|
|
372
389
|
const at = `hosting.entries '${entry.key}'`;
|
|
373
390
|
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)
|
|
391
|
+
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)`);
|
|
375
392
|
}
|
|
376
393
|
if (entry.repo !== null && !REPO.test(entry.repo)) {
|
|
377
|
-
return `${at} repo must be a repo under wtfalch, or null
|
|
394
|
+
return refusal('entry_repo_invalid', `hosting.entries.${entry.key}.repo`, `${at} repo must be a repo under wtfalch, or null`);
|
|
378
395
|
}
|
|
379
396
|
if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
|
|
380
|
-
return `${at} folder must be a relative path inside the repo
|
|
397
|
+
return refusal('entry_folder_invalid', `hosting.entries.${entry.key}.folder`, `${at} folder must be a relative path inside the repo`);
|
|
381
398
|
}
|
|
382
399
|
if (!entry.branch) {
|
|
383
|
-
return `${at} branch must be non-empty
|
|
400
|
+
return refusal('entry_branch_empty', `hosting.entries.${entry.key}.branch`, `${at} branch must be non-empty`);
|
|
384
401
|
}
|
|
385
402
|
if (!HOSTING_BUILD_PACKS.has(entry.buildPack)) {
|
|
386
|
-
return `${at} buildPack has an unknown value '${entry.buildPack}'
|
|
403
|
+
return refusal('entry_build_pack_unknown', `hosting.entries.${entry.key}.buildPack`, `${at} buildPack has an unknown value '${entry.buildPack}'`);
|
|
387
404
|
}
|
|
388
405
|
if (entry.buildPack === 'dockercompose') {
|
|
389
|
-
return `${at} buildPack dockercompose is not available for an entry
|
|
406
|
+
return refusal('entry_build_pack_unavailable', `hosting.entries.${entry.key}.buildPack`, `${at} buildPack dockercompose is not available for an entry`);
|
|
390
407
|
}
|
|
391
408
|
if (entry.port !== null &&
|
|
392
409
|
!(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
|
|
410
|
+
return refusal('entry_port_invalid', `hosting.entries.${entry.key}.port`, `${at} port must be a whole number from 1 to 65535, or null`);
|
|
394
411
|
}
|
|
395
412
|
if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
|
|
396
|
-
return `${at} healthPath must start with '/', or be null
|
|
413
|
+
return refusal('entry_health_path_invalid', `hosting.entries.${entry.key}.healthPath`, `${at} healthPath must start with '/', or be null`);
|
|
397
414
|
}
|
|
398
415
|
if (entry.capabilities !== undefined &&
|
|
399
416
|
entry.capabilities !== 'restricted' &&
|
|
400
417
|
entry.capabilities !== 'standard') {
|
|
401
|
-
return `${at} capabilities must be 'restricted' or 'standard'
|
|
418
|
+
return refusal('entry_capabilities_invalid', `hosting.entries.${entry.key}.capabilities`, `${at} capabilities must be 'restricted' or 'standard'`);
|
|
402
419
|
}
|
|
403
420
|
if (entry.database && !enabled) {
|
|
404
|
-
return `${at} database requires hosting.enabled
|
|
421
|
+
return refusal('entry_database_needs_hosting', `hosting.entries.${entry.key}.database`, `${at} database requires hosting.enabled`);
|
|
405
422
|
}
|
|
406
423
|
const invalidVolumes = validateEntryVolumes(entry, at);
|
|
407
424
|
if (invalidVolumes)
|
|
408
425
|
return invalidVolumes;
|
|
409
|
-
const invalidBlueGreen =
|
|
426
|
+
const invalidBlueGreen = refuseBlueGreen(entry.blueGreen, at, `hosting.entries.${entry.key}.blueGreen`);
|
|
410
427
|
if (invalidBlueGreen)
|
|
411
428
|
return invalidBlueGreen;
|
|
412
429
|
if (entry.blueGreen !== undefined &&
|
|
413
|
-
entry
|
|
430
|
+
!isMainEntry(entry) &&
|
|
414
431
|
!entry.hostnames.some((name) => name !== '*')) {
|
|
415
|
-
return `${at} blueGreen needs a public hostname: the release check reaches each color through it
|
|
432
|
+
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
433
|
}
|
|
417
434
|
for (const [name, value] of Object.entries(entry.env)) {
|
|
418
435
|
if (!name) {
|
|
419
|
-
return `${at} env needs a non-empty env var name
|
|
436
|
+
return refusal('env_name_empty', `hosting.entries.${entry.key}.env`, `${at} env needs a non-empty env var name`);
|
|
420
437
|
}
|
|
421
438
|
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
|
|
439
|
+
return refusal('env_name_reserved', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' is set by foundry and cannot be declared`);
|
|
423
440
|
}
|
|
424
441
|
if (typeof value !== 'string') {
|
|
425
442
|
if (value?.from === 'platformMailKey') {
|
|
426
|
-
return `${at} env '${name}' has an unknown source 'platformMailKey': each company uses its own mail key
|
|
443
|
+
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
444
|
}
|
|
428
445
|
if (!APP_ENV_SOURCES.has(value?.from)) {
|
|
429
|
-
return `${at} env '${name}' has an unknown source '${value?.from}'
|
|
446
|
+
return refusal('env_source_unknown', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' has an unknown source '${value?.from}'`);
|
|
430
447
|
}
|
|
431
448
|
if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
|
|
432
|
-
return `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)
|
|
449
|
+
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
450
|
}
|
|
434
451
|
if (value.from === 'heldSecret') {
|
|
435
452
|
if (value.name !== name) {
|
|
436
|
-
return `${at} env '${name}' heldSecret name must be '${name}', the env name it is declared under
|
|
453
|
+
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
454
|
}
|
|
438
455
|
if (!HELD_ENV_NAME.test(name)) {
|
|
439
|
-
return `${at} env '${name}' heldSecret needs an env name of letters, digits and underscores
|
|
456
|
+
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
457
|
}
|
|
441
458
|
}
|
|
442
459
|
}
|
|
443
460
|
else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
|
|
444
|
-
return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}
|
|
461
|
+
return refusal('env_looks_like_secret', `hosting.entries.${entry.key}.env.${name}`, `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`);
|
|
445
462
|
}
|
|
446
463
|
}
|
|
447
464
|
return null;
|
|
448
465
|
}
|
|
449
466
|
/** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
|
|
450
467
|
export function validateBlueGreen(value, at) {
|
|
468
|
+
return refuseBlueGreen(value, at, at)?.message ?? null;
|
|
469
|
+
}
|
|
470
|
+
/** {@link validateBlueGreen} with the code; `path` is the dotted path of the `blueGreen` value. */
|
|
471
|
+
function refuseBlueGreen(value, at, path) {
|
|
451
472
|
if (value === undefined)
|
|
452
473
|
return null;
|
|
453
474
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
454
|
-
return `${at} blueGreen must be an object
|
|
475
|
+
return refusal('blue_green_not_object', path, `${at} blueGreen must be an object`);
|
|
455
476
|
}
|
|
456
477
|
const { steps, stepHoldMinutes } = value;
|
|
457
478
|
if (steps !== undefined) {
|
|
458
479
|
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 20) {
|
|
459
|
-
return `${at} blueGreen steps must be a list of 1 to 20 percentages
|
|
480
|
+
return refusal('blue_green_steps_length', `${path}.steps`, `${at} blueGreen steps must be a list of 1 to 20 percentages`);
|
|
460
481
|
}
|
|
461
482
|
if (!steps.every((n) => Number.isInteger(n) && n >= 1 && n <= 100)) {
|
|
462
|
-
return `${at} blueGreen steps must be whole numbers from 1 to 100
|
|
483
|
+
return refusal('blue_green_steps_range', `${path}.steps`, `${at} blueGreen steps must be whole numbers from 1 to 100`);
|
|
463
484
|
}
|
|
464
485
|
if (!steps.every((n, i) => i === 0 || n > steps[i - 1])) {
|
|
465
|
-
return `${at} blueGreen steps must be ascending
|
|
486
|
+
return refusal('blue_green_steps_order', `${path}.steps`, `${at} blueGreen steps must be ascending`);
|
|
466
487
|
}
|
|
467
488
|
if (steps[steps.length - 1] !== 100)
|
|
468
|
-
return `${at} blueGreen steps must end at 100
|
|
489
|
+
return refusal('blue_green_steps_end', `${path}.steps`, `${at} blueGreen steps must end at 100`);
|
|
469
490
|
}
|
|
470
491
|
if (stepHoldMinutes !== undefined &&
|
|
471
492
|
!(Number.isInteger(stepHoldMinutes) && stepHoldMinutes >= 1 && stepHoldMinutes <= 1440)) {
|
|
472
|
-
return `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440
|
|
493
|
+
return refusal('blue_green_hold_invalid', `${path}.stepHoldMinutes`, `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440`);
|
|
473
494
|
}
|
|
474
495
|
return null;
|
|
475
496
|
}
|
|
@@ -480,38 +501,38 @@ function validateEntryVolumes(entry, at) {
|
|
|
480
501
|
if (volumes === undefined)
|
|
481
502
|
return null;
|
|
482
503
|
if (!Array.isArray(volumes))
|
|
483
|
-
return `${at} volumes must be a list
|
|
504
|
+
return refusal('volumes_not_list', `hosting.entries.${entry.key}.volumes`, `${at} volumes must be a list`);
|
|
484
505
|
if (volumes.length > MAX_ENTRY_VOLUMES) {
|
|
485
|
-
return `${at} volumes holds at most ${MAX_ENTRY_VOLUMES} volumes
|
|
506
|
+
return refusal('volumes_too_many', `hosting.entries.${entry.key}.volumes`, `${at} volumes holds at most ${MAX_ENTRY_VOLUMES} volumes`);
|
|
486
507
|
}
|
|
487
508
|
const names = new Set();
|
|
488
509
|
const paths = new Set();
|
|
489
510
|
for (const volume of volumes) {
|
|
490
511
|
const { name, mountPath } = volume ?? {};
|
|
491
512
|
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)
|
|
513
|
+
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
514
|
}
|
|
494
515
|
if (name === 'logs') {
|
|
495
|
-
return `${at} volumes name 'logs' is the log volume's, which every app already has
|
|
516
|
+
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
517
|
}
|
|
497
518
|
if (names.has(name))
|
|
498
|
-
return `${at} volumes name '${name}' is not unique
|
|
519
|
+
return refusal('volume_name_not_unique', `hosting.entries.${entry.key}.volumes`, `${at} volumes name '${name}' is not unique`);
|
|
499
520
|
names.add(name);
|
|
500
521
|
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 '/'
|
|
522
|
+
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
523
|
}
|
|
503
524
|
if (mountPath.length > VOLUME_MOUNT_PATH_MAX) {
|
|
504
|
-
return `${at} volumes '${name}' mountPath holds at most ${VOLUME_MOUNT_PATH_MAX} characters
|
|
525
|
+
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
526
|
}
|
|
506
527
|
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
|
|
528
|
+
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
529
|
}
|
|
509
530
|
if (mountPath === '/app' ||
|
|
510
531
|
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(', ')})
|
|
532
|
+
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
533
|
}
|
|
513
534
|
if (paths.has(mountPath))
|
|
514
|
-
return `${at} volumes mountPath '${mountPath}' is not unique
|
|
535
|
+
return refusal('volume_mount_path_not_unique', `hosting.entries.${entry.key}.volumes`, `${at} volumes mountPath '${mountPath}' is not unique`);
|
|
515
536
|
paths.add(mountPath);
|
|
516
537
|
}
|
|
517
538
|
return null;
|
|
@@ -528,36 +549,36 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
528
549
|
return null;
|
|
529
550
|
const at = `hosting.entries '${entry.key}' uses.auth`;
|
|
530
551
|
if (!hosting.enabled)
|
|
531
|
-
return `${at} requires hosting.enabled
|
|
552
|
+
return refusal('auth_needs_hosting', `hosting.entries.${entry.key}.uses.auth`, `${at} requires hosting.enabled`);
|
|
532
553
|
if (auth.users !== 'staff' && auth.users !== 'customers') {
|
|
533
|
-
return `${at} users must be 'staff' or 'customers'
|
|
554
|
+
return refusal('auth_users_invalid', `hosting.entries.${entry.key}.uses.auth.users`, `${at} users must be 'staff' or 'customers'`);
|
|
534
555
|
}
|
|
535
556
|
if (auth.registration !== 'open' && auth.registration !== 'closed') {
|
|
536
|
-
return `${at} registration must be 'open' or 'closed'
|
|
557
|
+
return refusal('auth_registration_invalid', `hosting.entries.${entry.key}.uses.auth.registration`, `${at} registration must be 'open' or 'closed'`);
|
|
537
558
|
}
|
|
538
559
|
// The staff organisation holds the org's own people and its project.
|
|
539
560
|
if (auth.users === 'staff' && auth.registration === 'open') {
|
|
540
|
-
return `${at} registration 'open' needs users 'customers': nobody signs themselves up as staff
|
|
561
|
+
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
562
|
}
|
|
542
563
|
if (auth.replacesBrokerEntry !== undefined &&
|
|
543
564
|
(typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
|
|
544
|
-
return `${at} replacesBrokerEntry must be the non-empty name of a broker entry
|
|
565
|
+
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
566
|
}
|
|
546
567
|
if (typeof auth.replacesBrokerEntry === 'string' &&
|
|
547
568
|
isFoundryOwnedBrokerEntryName(auth.replacesBrokerEntry)) {
|
|
548
|
-
return `${at} replacesBrokerEntry '${auth.replacesBrokerEntry}' is a broker entry foundry writes itself
|
|
569
|
+
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
570
|
}
|
|
550
571
|
if (!hasExactHostname(entry, hosting, domain)) {
|
|
551
|
-
return `${at} needs an exact hostname to sign in at
|
|
572
|
+
return refusal('auth_needs_exact_hostname', `hosting.entries.${entry.key}.hostnames`, `${at} needs an exact hostname to sign in at`);
|
|
552
573
|
}
|
|
553
574
|
if (auth.mcp !== undefined) {
|
|
554
575
|
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
|
|
576
|
+
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
577
|
}
|
|
557
578
|
if (typeof auth.mcp?.path !== 'string' ||
|
|
558
579
|
!MCP_PATH.test(auth.mcp.path) ||
|
|
559
580
|
auth.mcp.path.length > 200) {
|
|
560
|
-
return `${at} mcp.path must be an absolute path of letters, digits, '_', '~' and '-' segments, such as '/mcp'
|
|
581
|
+
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
582
|
}
|
|
562
583
|
const onboarding = auth.mcp.onboardingPath;
|
|
563
584
|
if (onboarding !== undefined &&
|
|
@@ -570,21 +591,21 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
570
591
|
onboarding.includes('\\') ||
|
|
571
592
|
/[\s\p{Cc}]/u.test(onboarding) ||
|
|
572
593
|
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
|
|
594
|
+
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
595
|
}
|
|
575
596
|
}
|
|
576
597
|
const signIn = auth.signIn;
|
|
577
598
|
if (signIn !== undefined && signIn !== 'password' && signIn !== 'email-code') {
|
|
578
|
-
return `${at} signIn must be 'password' or 'email-code'
|
|
599
|
+
return refusal('auth_sign_in_invalid', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn must be 'password' or 'email-code'`);
|
|
579
600
|
}
|
|
580
601
|
if (signIn === 'email-code' && auth.users !== 'customers') {
|
|
581
|
-
return `${at} signIn 'email-code' needs users 'customers': only customers sign in with an emailed code
|
|
602
|
+
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
603
|
}
|
|
583
604
|
if (entry.key === 'agora' ||
|
|
584
605
|
entry.key === 'boule' ||
|
|
585
606
|
entry.key === 'cms' ||
|
|
586
607
|
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
587
|
-
return `${at} client name '${entry.key}' is already held by another ZITADEL client
|
|
608
|
+
return refusal('client_name_taken', `hosting.entries.${entry.key}.key`, `${at} client name '${entry.key}' is already held by another ZITADEL client`);
|
|
588
609
|
}
|
|
589
610
|
return null;
|
|
590
611
|
}
|
|
@@ -594,7 +615,7 @@ function hasExactHostname(entry, hosting, domain) {
|
|
|
594
615
|
return true; // Its host is workerApp.host, or <slug>[-<key>].oikos.host.
|
|
595
616
|
return hosting.usesDomain && domain !== null
|
|
596
617
|
? entry.hostnames.some((name) => name !== '*')
|
|
597
|
-
: entry
|
|
618
|
+
: isMainEntry(entry);
|
|
598
619
|
}
|
|
599
620
|
/**
|
|
600
621
|
* `uses.ai`: at least one offering, permissions from the known set, a
|
|
@@ -607,32 +628,32 @@ function validateEntryAi(entry, hosting, domain) {
|
|
|
607
628
|
return null;
|
|
608
629
|
const at = `hosting.entries '${entry.key}' uses.ai`;
|
|
609
630
|
if (!hosting.enabled)
|
|
610
|
-
return `${at} requires hosting.enabled
|
|
631
|
+
return refusal('ai_needs_hosting', `hosting.entries.${entry.key}.uses.ai`, `${at} requires hosting.enabled`);
|
|
611
632
|
const isNames = (value) => Array.isArray(value) &&
|
|
612
633
|
value.length > 0 &&
|
|
613
634
|
value.every((v) => typeof v === 'string' && v !== '') &&
|
|
614
635
|
new Set(value).size === value.length;
|
|
615
636
|
if (!isNames(ai.offerings))
|
|
616
|
-
return `${at} offerings must be a list of distinct, non-empty names
|
|
637
|
+
return refusal('ai_offerings_invalid', `hosting.entries.${entry.key}.uses.ai.offerings`, `${at} offerings must be a list of distinct, non-empty names`);
|
|
617
638
|
if (ai.permissions !== undefined) {
|
|
618
639
|
const known = AI_ENTRY_PERMISSIONS;
|
|
619
640
|
if (!isNames(ai.permissions) || !ai.permissions.every((p) => known.includes(p))) {
|
|
620
|
-
return `${at} permissions must be distinct values of: ${known.join(', ')}
|
|
641
|
+
return refusal('ai_permissions_invalid', `hosting.entries.${entry.key}.uses.ai.permissions`, `${at} permissions must be distinct values of: ${known.join(', ')}`);
|
|
621
642
|
}
|
|
622
643
|
}
|
|
623
644
|
if (ai.webhookPath !== undefined) {
|
|
624
645
|
if (typeof ai.webhookPath !== 'string' ||
|
|
625
646
|
!/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,199}$/.test(ai.webhookPath) ||
|
|
626
647
|
ai.webhookPath.startsWith('//')) {
|
|
627
|
-
return `${at} webhookPath must be a path that starts with one '/', with no query or fragment
|
|
648
|
+
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
649
|
}
|
|
629
650
|
if (!hasExactHostname(entry, hosting, domain)) {
|
|
630
|
-
return `${at} webhookPath needs an exact hostname to receive the webhook at
|
|
651
|
+
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
652
|
}
|
|
632
653
|
}
|
|
633
654
|
const named = Object.keys(entry.env ?? {}).find((name) => AI_ENTRY_ENV_NAMES.includes(name));
|
|
634
655
|
if (named)
|
|
635
|
-
return `${at} writes ${named}; remove it from env
|
|
656
|
+
return refusal('ai_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
636
657
|
return null;
|
|
637
658
|
}
|
|
638
659
|
/**
|
|
@@ -646,31 +667,31 @@ function validateEntryFiles(entry, hosting, domain) {
|
|
|
646
667
|
return null;
|
|
647
668
|
const at = `hosting.entries '${entry.key}' uses.files`;
|
|
648
669
|
if (!hosting.enabled)
|
|
649
|
-
return `${at} requires hosting.enabled
|
|
670
|
+
return refusal('files_needs_hosting', `hosting.entries.${entry.key}.uses.files`, `${at} requires hosting.enabled`);
|
|
650
671
|
const names = (value) => Array.isArray(value) &&
|
|
651
672
|
value.every((v) => typeof v === 'string' && v !== '') &&
|
|
652
673
|
new Set(value).size === value.length;
|
|
653
674
|
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
|
|
675
|
+
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
676
|
}
|
|
656
677
|
if (files.permissions !== undefined) {
|
|
657
678
|
const known = FILES_ENTRY_PERMISSIONS;
|
|
658
679
|
if (!names(files.permissions) ||
|
|
659
680
|
files.permissions.length === 0 ||
|
|
660
681
|
!files.permissions.every((p) => known.includes(p))) {
|
|
661
|
-
return `${at} permissions must be distinct values of: ${known.join(', ')}
|
|
682
|
+
return refusal('files_permissions_invalid', `hosting.entries.${entry.key}.uses.files.permissions`, `${at} permissions must be distinct values of: ${known.join(', ')}`);
|
|
662
683
|
}
|
|
663
684
|
}
|
|
664
685
|
if (files.shareHost !== undefined && typeof files.shareHost !== 'boolean') {
|
|
665
|
-
return `${at} shareHost must be true or false
|
|
686
|
+
return refusal('files_share_host_invalid', `hosting.entries.${entry.key}.uses.files.shareHost`, `${at} shareHost must be true or false`);
|
|
666
687
|
}
|
|
667
688
|
if (files.shareHost && !(hosting.usesDomain && domain)) {
|
|
668
|
-
return `${at} shareHost needs hosting.usesDomain and a domain: it is served at share.<domain
|
|
689
|
+
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
690
|
}
|
|
670
691
|
const written = [...FILES_ENTRY_ENV_NAMES, ...files.collections.map(filesCollectionEnvName)];
|
|
671
692
|
const named = Object.keys(entry.env ?? {}).find((name) => written.includes(name));
|
|
672
693
|
if (named)
|
|
673
|
-
return `${at} writes ${named}; remove it from env
|
|
694
|
+
return refusal('files_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
674
695
|
return null;
|
|
675
696
|
}
|
|
676
697
|
/** A route pattern's zone apex: the host part before the first `/`, with one leading `*.` label stripped. */
|
|
@@ -708,35 +729,36 @@ function validateEntryWorker(entry, hosting, domain) {
|
|
|
708
729
|
return null;
|
|
709
730
|
const at = `hosting.entries '${entry.key}' worker`;
|
|
710
731
|
if (!hosting.enabled)
|
|
711
|
-
return `${at} requires hosting.enabled
|
|
732
|
+
return refusal('worker_needs_hosting', `hosting.entries.${entry.key}.worker`, `${at} requires hosting.enabled`);
|
|
712
733
|
if (entry.repo === null) {
|
|
713
|
-
return `${at} needs its own repo (repo: null is not a Worker source)
|
|
734
|
+
return refusal('worker_needs_repo', `hosting.entries.${entry.key}.repo`, `${at} needs its own repo (repo: null is not a Worker source)`);
|
|
714
735
|
}
|
|
715
736
|
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
|
|
737
|
+
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
738
|
}
|
|
718
739
|
const invalidArtifact = validateArtifact(at, worker.artifactUrl, worker.artifactSha256, entry.repo);
|
|
719
|
-
if (invalidArtifact)
|
|
720
|
-
return invalidArtifact;
|
|
740
|
+
if (invalidArtifact) {
|
|
741
|
+
return refusal('worker_artifact_invalid', `hosting.entries.${entry.key}.worker`, invalidArtifact);
|
|
742
|
+
}
|
|
721
743
|
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 '/'
|
|
744
|
+
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
745
|
}
|
|
724
746
|
const zone = workerRouteZone(worker.route);
|
|
725
747
|
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
|
|
748
|
+
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
749
|
}
|
|
728
750
|
// A wildcard route (`*.<domain>/*`) is allowed beside hostnames foundry owns:
|
|
729
751
|
// `planWorkers` plans a no-Worker route for each of them, so they stay out of it.
|
|
730
752
|
for (const limit of worker.bindings?.rateLimits ?? []) {
|
|
731
753
|
if (!limit.name.trim())
|
|
732
|
-
return `${at} bindings.rateLimits needs a non-empty name
|
|
754
|
+
return refusal('worker_rate_limit_name_missing', `hosting.entries.${entry.key}.worker.bindings.rateLimits`, `${at} bindings.rateLimits needs a non-empty name`);
|
|
733
755
|
if (!limit.namespaceId.trim())
|
|
734
|
-
return `${at} bindings.rateLimits needs a non-empty namespaceId
|
|
756
|
+
return refusal('worker_rate_limit_namespace_missing', `hosting.entries.${entry.key}.worker.bindings.rateLimits`, `${at} bindings.rateLimits needs a non-empty namespaceId`);
|
|
735
757
|
if (!(Number.isInteger(limit.limit) && limit.limit > 0)) {
|
|
736
|
-
return `${at} bindings.rateLimits limit must be a positive whole number
|
|
758
|
+
return refusal('worker_rate_limit_invalid', `hosting.entries.${entry.key}.worker.bindings.rateLimits`, `${at} bindings.rateLimits limit must be a positive whole number`);
|
|
737
759
|
}
|
|
738
760
|
if (limit.period !== 10 && limit.period !== 60) {
|
|
739
|
-
return `${at} bindings.rateLimits period must be 10 or 60 (Cloudflare allows no other value)
|
|
761
|
+
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
762
|
}
|
|
741
763
|
}
|
|
742
764
|
return null;
|
|
@@ -752,15 +774,15 @@ function validateEntryEdge(entry, hosting, domain) {
|
|
|
752
774
|
return null;
|
|
753
775
|
const at = `hosting.entries '${entry.key}' uses.edge`;
|
|
754
776
|
if (edge !== true)
|
|
755
|
-
return `${at} must be true
|
|
777
|
+
return refusal('edge_not_true', `hosting.entries.${entry.key}.uses.edge`, `${at} must be true`);
|
|
756
778
|
if (!hosting.enabled)
|
|
757
|
-
return `${at} requires hosting.enabled
|
|
779
|
+
return refusal('edge_needs_hosting', `hosting.entries.${entry.key}.uses.edge`, `${at} requires hosting.enabled`);
|
|
758
780
|
if (!hosting.usesDomain || !domain) {
|
|
759
|
-
return `${at} needs hosting.usesDomain and an org domain: edge serves only the org's own domain
|
|
781
|
+
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
782
|
}
|
|
761
783
|
const named = Object.keys(entry.env ?? {}).find((name) => EDGE_ENTRY_ENV_NAMES.includes(name));
|
|
762
784
|
if (named)
|
|
763
|
-
return `${at} writes ${named}; remove it from env
|
|
785
|
+
return refusal('edge_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
764
786
|
return null;
|
|
765
787
|
}
|
|
766
788
|
function urlHasPassword(value) {
|
|
@@ -984,6 +1006,10 @@ export function validateSignIn(org) {
|
|
|
984
1006
|
* the customers organisation.
|
|
985
1007
|
*/
|
|
986
1008
|
export function validateCustomers(org) {
|
|
1009
|
+
return refuseCustomers(org)?.message ?? null;
|
|
1010
|
+
}
|
|
1011
|
+
/** {@link validateCustomers} with the code and path of the refusal. */
|
|
1012
|
+
export function refuseCustomers(org) {
|
|
987
1013
|
const customers = org.customers;
|
|
988
1014
|
if (customers !== undefined) {
|
|
989
1015
|
const value = customers;
|
|
@@ -993,14 +1019,15 @@ export function validateCustomers(org) {
|
|
|
993
1019
|
typeof value?.enabled !== 'boolean' ||
|
|
994
1020
|
typeof value?.signUp !== 'boolean' ||
|
|
995
1021
|
Object.keys(customers).some((key) => key !== 'enabled' && key !== 'signUp')) {
|
|
996
|
-
return 'customers must be { enabled: boolean, signUp: boolean }';
|
|
1022
|
+
return refusal('customers_invalid', 'customers', 'customers must be { enabled: boolean, signUp: boolean }');
|
|
1023
|
+
}
|
|
1024
|
+
if (value.signUp && !value.enabled) {
|
|
1025
|
+
return refusal('customers_sign_up_needs_enabled', 'customers.signUp', 'customers.signUp needs customers.enabled');
|
|
997
1026
|
}
|
|
998
|
-
if (value.signUp && !value.enabled)
|
|
999
|
-
return 'customers.signUp needs customers.enabled';
|
|
1000
1027
|
}
|
|
1001
1028
|
const open = org.hosting.entries.find((entry) => entry.uses?.auth?.registration === 'open');
|
|
1002
1029
|
if (open && !(org.customers?.enabled && org.customers.signUp)) {
|
|
1003
|
-
return `hosting.entries '${open.key}' uses.auth registration 'open' needs customers.enabled and customers.signUp
|
|
1030
|
+
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
1031
|
}
|
|
1005
1032
|
return null;
|
|
1006
1033
|
}
|