@wtfalch/foundry 0.46.0 → 0.48.0

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