@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/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
- /** The org's `app` entry: today's one app. */
48
- export function appEntry(hosting) {
49
- return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
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 an entry keyed {@link APP_ENTRY_KEY}.
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 && !appEntry(hosting)) {
145
- return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
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.key !== APP_ENTRY_KEY && entry.hostnames.length > 0) {
317
- return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
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.key === APP_ENTRY_KEY &&
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 = validateBlueGreen(entry.blueGreen, at);
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.key !== APP_ENTRY_KEY &&
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.key === APP_ENTRY_KEY;
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
  }