@wtfalch/foundry 0.45.1 → 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.
Files changed (78) 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/org.d.ts +68 -5
  7. package/dist/org.js +287 -135
  8. package/dist/plan/agora.d.ts +14 -0
  9. package/dist/plan/agora.js +21 -0
  10. package/dist/plan/backup-gate.d.ts +15 -0
  11. package/dist/plan/backup-gate.js +38 -0
  12. package/dist/plan/boule-owners-http.d.ts +12 -0
  13. package/dist/plan/boule-owners-http.js +59 -0
  14. package/dist/plan/boule.d.ts +38 -2
  15. package/dist/plan/boule.js +72 -1
  16. package/dist/plan/company-backups.d.ts +7 -1
  17. package/dist/plan/company-backups.js +42 -2
  18. package/dist/plan/cookie-domain.d.ts +41 -0
  19. package/dist/plan/cookie-domain.js +71 -0
  20. package/dist/plan/desktop-auth.d.ts +58 -0
  21. package/dist/plan/desktop-auth.js +144 -0
  22. package/dist/plan/desktop-callback.d.ts +26 -0
  23. package/dist/plan/desktop-callback.js +59 -0
  24. package/dist/plan/diff.d.ts +13 -4
  25. package/dist/plan/diff.js +277 -88
  26. package/dist/plan/executor.d.ts +136 -6
  27. package/dist/plan/executor.js +732 -105
  28. package/dist/plan/health-gaps.js +5 -0
  29. package/dist/plan/health-targets.js +1 -2
  30. package/dist/plan/hostnames.d.ts +2 -2
  31. package/dist/plan/hostnames.js +6 -6
  32. package/dist/plan/index.d.ts +4 -1
  33. package/dist/plan/index.js +3 -1
  34. package/dist/plan/job-app.d.ts +23 -0
  35. package/dist/plan/job-app.js +41 -0
  36. package/dist/plan/live.d.ts +6 -0
  37. package/dist/plan/live.js +16 -0
  38. package/dist/plan/login-host.d.ts +28 -0
  39. package/dist/plan/login-host.js +36 -0
  40. package/dist/plan/org-names.js +9 -1
  41. package/dist/plan/org-postgres-job.d.ts +22 -0
  42. package/dist/plan/org-postgres-job.js +91 -0
  43. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  44. package/dist/plan/org-postgres-migrate.js +466 -0
  45. package/dist/plan/org-postgres.d.ts +90 -0
  46. package/dist/plan/org-postgres.js +293 -0
  47. package/dist/plan/plan-apply.d.ts +60 -3
  48. package/dist/plan/plan-apply.js +174 -8
  49. package/dist/plan/platform-diff.d.ts +45 -0
  50. package/dist/plan/platform-diff.js +159 -4
  51. package/dist/plan/platform-org.d.ts +2 -0
  52. package/dist/plan/platform-org.js +4 -0
  53. package/dist/plan/platform-providers.d.ts +12 -2
  54. package/dist/plan/platform-providers.js +282 -9
  55. package/dist/plan/post-logout-uris.d.ts +22 -0
  56. package/dist/plan/post-logout-uris.js +43 -0
  57. package/dist/plan/providers-readers.d.ts +10 -1
  58. package/dist/plan/providers-readers.js +56 -5
  59. package/dist/plan/sign-in-bindings.d.ts +4 -0
  60. package/dist/plan/sign-in-bindings.js +10 -0
  61. package/dist/plan/sign-in-org.d.ts +19 -6
  62. package/dist/plan/sign-in-org.js +35 -10
  63. package/dist/plan/sign-in.d.ts +12 -5
  64. package/dist/plan/sign-in.js +12 -5
  65. package/dist/plan/signin-db-move.js +10 -34
  66. package/dist/plan/worker-app.js +3 -1
  67. package/dist/refuse-org.d.ts +7 -0
  68. package/dist/refuse-org.js +10 -0
  69. package/dist/requested-changes.d.ts +50 -4
  70. package/dist/requested-changes.js +9 -1
  71. package/dist/store/postgres.d.ts +5 -1
  72. package/dist/store/postgres.js +67 -8
  73. package/dist/store/schema.d.ts +72 -0
  74. package/dist/store/schema.js +4 -0
  75. package/dist/store.d.ts +24 -1
  76. package/dist/worker-app-config.d.ts +8 -2
  77. package/dist/worker-app-config.js +20 -6
  78. package/package.json +5 -5
package/dist/org.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_RULES, checkName, explain } from '@wtfalch/auth/names';
1
2
  import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
2
3
  import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
3
4
  /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
@@ -9,6 +10,9 @@ export const ACCESS_RULE_KINDS = new Set(['bypass', 'service-token']);
9
10
  export const DEFAULT_GATE_LIMIT = 3;
10
11
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
11
12
  export const APP_ENTRY_KEY = 'app';
13
+ function refusal(code, path, message) {
14
+ return { code, path, message };
15
+ }
12
16
  /** The most volumes one entry may declare. */
13
17
  export const MAX_ENTRY_VOLUMES = 8;
14
18
  /** `uses.auth.mcp.onboardingPath`: absolute, no query, no fragment, no dot segments. */
@@ -43,9 +47,14 @@ export const FILES_ENTRY_ENV_NAMES = [
43
47
  export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
44
48
  /** The env names `uses.edge` writes; `env` may not name them (#317). */
45
49
  export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
46
- /** The org's `app` entry: today's one app. */
47
- export function appEntry(hosting) {
48
- 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));
49
58
  }
50
59
  /**
51
60
  * `sharedSecret` ({@link EnvSharedSecret}) and `heldSecret` ({@link EnvHeldSecret}) are the secret sources. The
@@ -106,26 +115,30 @@ const HOSTING_BUILD_PACKS = new Set([
106
115
  * - `tunnel` requires `enabled` -- it means nothing without the apps running.
107
116
  * - Every `issuerApps[]` entry has a non-empty `name`, unique within the
108
117
  * list, and a `kind` that is one of `IssuerAppKind`'s three values.
109
- * - `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}).
110
119
  * - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
111
120
  * unique, and at most one entry has a `database`, because the one
112
121
  * `<slug>_rt` runtime role cannot serve two apps.
113
122
  */
114
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) {
115
128
  if (hosting.tunnel && !hosting.enabled) {
116
- return 'hosting.tunnel requires hosting.enabled';
129
+ return refusal('hosting_tunnel_needs_hosting', 'hosting.tunnel', 'hosting.tunnel requires hosting.enabled');
117
130
  }
118
131
  const seenNames = new Set();
119
132
  for (const app of hosting.issuerApps) {
120
133
  if (!app.name) {
121
- 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');
122
135
  }
123
136
  if (seenNames.has(app.name)) {
124
- 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`);
125
138
  }
126
139
  seenNames.add(app.name);
127
140
  if (!ISSUER_APP_KINDS.has(app.kind)) {
128
- 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}'`);
129
142
  }
130
143
  // The fixed 'agora' ZITADEL client (#164, `plan/diff.ts`'s
131
144
  // `planAgoraClient`) already owns the `zitadel:app:agora` key, for
@@ -133,20 +146,20 @@ export function validateHosting(hosting, names) {
133
146
  // reservation (`validateSite` below), which only applies once
134
147
  // `site.enabled` is true.
135
148
  if (app.name === 'agora') {
136
- 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`);
137
150
  }
138
151
  // #161: the same for the always-on Boule client.
139
152
  if (app.name === 'boule') {
140
- 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`);
141
154
  }
142
155
  }
143
- if (hosting.enabled && !appEntry(hosting)) {
144
- 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');
145
158
  }
146
159
  const seenKeys = new Set();
147
160
  for (const entry of hosting.entries) {
148
161
  if (seenKeys.has(entry.key)) {
149
- 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`);
150
163
  }
151
164
  seenKeys.add(entry.key);
152
165
  const invalid = validateHostingEntry(entry, hosting.enabled);
@@ -165,14 +178,19 @@ export function validateHosting(hosting, names) {
165
178
  if (invalidWorker)
166
179
  return invalidWorker;
167
180
  const invalidWorkerApp = validateEntryWorkerApp(entry, hosting, names);
168
- if (invalidWorkerApp)
169
- return invalidWorkerApp;
181
+ if (invalidWorkerApp) {
182
+ return refusal('worker_app_invalid', `hosting.entries.${entry.key}.workerApp`, invalidWorkerApp);
183
+ }
170
184
  const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
171
185
  if (invalidEdgeUse)
172
186
  return invalidEdgeUse;
173
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
+ }
174
192
  if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
175
- 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');
176
194
  }
177
195
  // #318 re-review: two entries declaring the same worker route both throw at
178
196
  // apply (`cloudflareWorker` refuses a route already bound to a Worker
@@ -184,7 +202,7 @@ export function validateHosting(hosting, names) {
184
202
  if (!entry.worker)
185
203
  continue;
186
204
  if (seenWorkerRoutes.has(entry.worker.route)) {
187
- 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`);
188
206
  }
189
207
  seenWorkerRoutes.add(entry.worker.route);
190
208
  }
@@ -192,7 +210,7 @@ export function validateHosting(hosting, names) {
192
210
  if (invalidNames)
193
211
  return invalidNames;
194
212
  if (hosting.entries.filter(entryHasDatabase).length > 1) {
195
- 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');
196
214
  }
197
215
  const invalidGates = validateGates(hosting, names?.domain, names?.locked);
198
216
  if (invalidGates)
@@ -203,7 +221,7 @@ export function validateHosting(hosting, names) {
203
221
  if (invalidRule)
204
222
  return invalidRule;
205
223
  if (seenAccessHostnames.has(rule.hostname)) {
206
- 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`);
207
225
  }
208
226
  seenAccessHostnames.add(rule.hostname);
209
227
  }
@@ -219,20 +237,20 @@ const ACCESS_RULE_HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-
219
237
  function validateAccessRule(rule, hosting, names) {
220
238
  const at = `hosting.access '${rule.hostname}'`;
221
239
  if (!rule.hostname || !ACCESS_RULE_HOSTNAME.test(rule.hostname)) {
222
- 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)`);
223
241
  }
224
242
  if (!hosting.enabled) {
225
- return `${at} requires hosting.enabled`;
243
+ return refusal('access_needs_hosting', 'hosting.access', `${at} requires hosting.enabled`);
226
244
  }
227
245
  if (!ACCESS_RULE_KINDS.has(rule.kind)) {
228
- return `${at} has an unknown kind '${rule.kind}'`;
246
+ return refusal('access_kind_unknown', 'hosting.access', `${at} has an unknown kind '${rule.kind}'`);
229
247
  }
230
248
  if (rule.kind === 'service-token' && !rule.serviceTokenName) {
231
- 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`);
232
250
  }
233
251
  if (names) {
234
252
  if (names.locked.has(rule.hostname)) {
235
- return `${at} is a name foundry owns`;
253
+ return refusal('access_hostname_locked', 'hosting.access', `${at} is a name foundry owns`);
236
254
  }
237
255
  const { domain } = names;
238
256
  // Any name under the domain: `share.<domain>` and `storage.<domain>` are
@@ -241,7 +259,7 @@ function validateAccessRule(rule, hosting, names) {
241
259
  domain !== null &&
242
260
  (rule.hostname === domain || rule.hostname.endsWith(`.${domain}`));
243
261
  if (!served) {
244
- 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`);
245
263
  }
246
264
  }
247
265
  return null;
@@ -255,7 +273,7 @@ function validateAccessRule(rule, hosting, names) {
255
273
  */
256
274
  function validateGates(hosting, domain, locked) {
257
275
  if (!Number.isInteger(hosting.gateLimit) || hosting.gateLimit < 1) {
258
- 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');
259
277
  }
260
278
  const seenKeys = new Set();
261
279
  for (const entry of hosting.entries) {
@@ -263,33 +281,33 @@ function validateGates(hosting, domain, locked) {
263
281
  for (const gate of entry.gates) {
264
282
  const at = `hosting.entries '${entry.key}' gate '${gate.key}'`;
265
283
  if (!hosting.enabled || !hosting.usesDomain || domain === null) {
266
- 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`);
267
285
  }
268
286
  if (!ENTRY_KEY.test(gate.key)) {
269
- 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)`);
270
288
  }
271
289
  if (seenKeys.has(gate.key))
272
- 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`);
273
291
  seenKeys.add(gate.key);
274
292
  if (gate.hostnames.length === 0)
275
- 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`);
276
294
  for (const name of gate.hostnames) {
277
295
  if (!entry.hostnames.includes(name)) {
278
- 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`);
279
297
  }
280
298
  if (taken.has(name))
281
- 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`);
282
300
  taken.add(name);
283
301
  if (domain && name !== '*') {
284
302
  const full = name === '@' ? domain : `${name}.${domain}`;
285
303
  if (locked?.has(full))
286
- 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}`);
287
305
  }
288
306
  }
289
307
  }
290
308
  }
291
309
  if (seenKeys.size > hosting.gateLimit) {
292
- 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}`);
293
311
  }
294
312
  return null;
295
313
  }
@@ -306,34 +324,34 @@ function validateEntryHostnames(hosting, names) {
306
324
  const at = `hosting.entries '${entry.key}'`;
307
325
  if (entry.hostnames.includes('*')) {
308
326
  if (!hosting.usesDomain || (names && !names.domain)) {
309
- 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`);
310
328
  }
311
329
  if (hosting.entries.filter((e) => e.hostnames.includes('*')).length > 1) {
312
- 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`);
313
331
  }
314
332
  }
315
- if (!hosting.usesDomain && entry.key !== APP_ENTRY_KEY && entry.hostnames.length > 0) {
316
- 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`);
317
335
  }
318
336
  const reads = hosting.usesDomain && names?.domain;
319
337
  if (reads &&
320
- entry.key === APP_ENTRY_KEY &&
338
+ isMainEntry(entry) &&
321
339
  !entry.workerApp &&
322
340
  !entry.hostnames.some((n) => n !== '*')) {
323
- 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`);
324
342
  }
325
343
  for (const name of entry.hostnames) {
326
344
  if (name !== '@' && name !== '*' && !ENTRY_KEY.test(name)) {
327
- 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`);
328
346
  }
329
347
  if (taken.has(name))
330
- 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`);
331
349
  taken.add(name);
332
350
  if (names?.domain &&
333
351
  name !== '@' &&
334
352
  name !== '*' &&
335
353
  names.locked.has(`${name}.${names.domain}`)) {
336
- 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}`);
337
355
  }
338
356
  }
339
357
  }
@@ -370,105 +388,109 @@ const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
370
388
  function validateHostingEntry(entry, enabled) {
371
389
  const at = `hosting.entries '${entry.key}'`;
372
390
  if (!ENTRY_KEY.test(entry.key)) {
373
- 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)`);
374
392
  }
375
393
  if (entry.repo !== null && !REPO.test(entry.repo)) {
376
- 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`);
377
395
  }
378
396
  if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
379
- 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`);
380
398
  }
381
399
  if (!entry.branch) {
382
- return `${at} branch must be non-empty`;
400
+ return refusal('entry_branch_empty', `hosting.entries.${entry.key}.branch`, `${at} branch must be non-empty`);
383
401
  }
384
402
  if (!HOSTING_BUILD_PACKS.has(entry.buildPack)) {
385
- 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}'`);
386
404
  }
387
405
  if (entry.buildPack === 'dockercompose') {
388
- 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`);
389
407
  }
390
408
  if (entry.port !== null &&
391
409
  !(Number.isInteger(entry.port) && entry.port >= 1 && entry.port <= 65535)) {
392
- 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`);
393
411
  }
394
412
  if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
395
- 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`);
396
414
  }
397
415
  if (entry.capabilities !== undefined &&
398
416
  entry.capabilities !== 'restricted' &&
399
417
  entry.capabilities !== 'standard') {
400
- 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'`);
401
419
  }
402
420
  if (entry.database && !enabled) {
403
- return `${at} database requires hosting.enabled`;
421
+ return refusal('entry_database_needs_hosting', `hosting.entries.${entry.key}.database`, `${at} database requires hosting.enabled`);
404
422
  }
405
423
  const invalidVolumes = validateEntryVolumes(entry, at);
406
424
  if (invalidVolumes)
407
425
  return invalidVolumes;
408
- const invalidBlueGreen = validateBlueGreen(entry.blueGreen, at);
426
+ const invalidBlueGreen = refuseBlueGreen(entry.blueGreen, at, `hosting.entries.${entry.key}.blueGreen`);
409
427
  if (invalidBlueGreen)
410
428
  return invalidBlueGreen;
411
429
  if (entry.blueGreen !== undefined &&
412
- entry.key !== APP_ENTRY_KEY &&
430
+ !isMainEntry(entry) &&
413
431
  !entry.hostnames.some((name) => name !== '*')) {
414
- 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`);
415
433
  }
416
434
  for (const [name, value] of Object.entries(entry.env)) {
417
435
  if (!name) {
418
- 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`);
419
437
  }
420
438
  if (FOUNDRY_ENV_NAME.test(name) || (entry.blueGreen && BLUE_GREEN_ENV_NAME.test(name))) {
421
- 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`);
422
440
  }
423
441
  if (typeof value !== 'string') {
424
442
  if (value?.from === 'platformMailKey') {
425
- 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`);
426
444
  }
427
445
  if (!APP_ENV_SOURCES.has(value?.from)) {
428
- 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}'`);
429
447
  }
430
448
  if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
431
- 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)`);
432
450
  }
433
451
  if (value.from === 'heldSecret') {
434
452
  if (value.name !== name) {
435
- 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`);
436
454
  }
437
455
  if (!HELD_ENV_NAME.test(name)) {
438
- 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`);
439
457
  }
440
458
  }
441
459
  }
442
460
  else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
443
- 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}`);
444
462
  }
445
463
  }
446
464
  return null;
447
465
  }
448
466
  /** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
449
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) {
450
472
  if (value === undefined)
451
473
  return null;
452
474
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
453
- return `${at} blueGreen must be an object`;
475
+ return refusal('blue_green_not_object', path, `${at} blueGreen must be an object`);
454
476
  }
455
477
  const { steps, stepHoldMinutes } = value;
456
478
  if (steps !== undefined) {
457
479
  if (!Array.isArray(steps) || steps.length === 0 || steps.length > 20) {
458
- 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`);
459
481
  }
460
482
  if (!steps.every((n) => Number.isInteger(n) && n >= 1 && n <= 100)) {
461
- 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`);
462
484
  }
463
485
  if (!steps.every((n, i) => i === 0 || n > steps[i - 1])) {
464
- return `${at} blueGreen steps must be ascending`;
486
+ return refusal('blue_green_steps_order', `${path}.steps`, `${at} blueGreen steps must be ascending`);
465
487
  }
466
488
  if (steps[steps.length - 1] !== 100)
467
- return `${at} blueGreen steps must end at 100`;
489
+ return refusal('blue_green_steps_end', `${path}.steps`, `${at} blueGreen steps must end at 100`);
468
490
  }
469
491
  if (stepHoldMinutes !== undefined &&
470
492
  !(Number.isInteger(stepHoldMinutes) && stepHoldMinutes >= 1 && stepHoldMinutes <= 1440)) {
471
- 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`);
472
494
  }
473
495
  return null;
474
496
  }
@@ -479,38 +501,38 @@ function validateEntryVolumes(entry, at) {
479
501
  if (volumes === undefined)
480
502
  return null;
481
503
  if (!Array.isArray(volumes))
482
- return `${at} volumes must be a list`;
504
+ return refusal('volumes_not_list', `hosting.entries.${entry.key}.volumes`, `${at} volumes must be a list`);
483
505
  if (volumes.length > MAX_ENTRY_VOLUMES) {
484
- 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`);
485
507
  }
486
508
  const names = new Set();
487
509
  const paths = new Set();
488
510
  for (const volume of volumes) {
489
511
  const { name, mountPath } = volume ?? {};
490
512
  if (typeof name !== 'string' || !ENTRY_KEY.test(name)) {
491
- 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)`);
492
514
  }
493
515
  if (name === 'logs') {
494
- 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`);
495
517
  }
496
518
  if (names.has(name))
497
- 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`);
498
520
  names.add(name);
499
521
  if (typeof mountPath !== 'string' || !VOLUME_MOUNT_PATH.test(mountPath)) {
500
- 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 '/'`);
501
523
  }
502
524
  if (mountPath.length > VOLUME_MOUNT_PATH_MAX) {
503
- 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`);
504
526
  }
505
527
  if (mountPath === LOG_DIR_PATH || mountPath.startsWith(`${LOG_DIR_PATH}/`)) {
506
- 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`);
507
529
  }
508
530
  if (mountPath === '/app' ||
509
531
  VOLUME_FORBIDDEN_ROOTS.some((root) => mountPath === root || mountPath.startsWith(`${root}/`))) {
510
- 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(', ')})`);
511
533
  }
512
534
  if (paths.has(mountPath))
513
- 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`);
514
536
  paths.add(mountPath);
515
537
  }
516
538
  return null;
@@ -527,36 +549,36 @@ function validateEntryAuth(entry, hosting, domain) {
527
549
  return null;
528
550
  const at = `hosting.entries '${entry.key}' uses.auth`;
529
551
  if (!hosting.enabled)
530
- return `${at} requires hosting.enabled`;
552
+ return refusal('auth_needs_hosting', `hosting.entries.${entry.key}.uses.auth`, `${at} requires hosting.enabled`);
531
553
  if (auth.users !== 'staff' && auth.users !== 'customers') {
532
- 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'`);
533
555
  }
534
556
  if (auth.registration !== 'open' && auth.registration !== 'closed') {
535
- 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'`);
536
558
  }
537
559
  // The staff organisation holds the org's own people and its project.
538
560
  if (auth.users === 'staff' && auth.registration === 'open') {
539
- 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`);
540
562
  }
541
563
  if (auth.replacesBrokerEntry !== undefined &&
542
564
  (typeof auth.replacesBrokerEntry !== 'string' || auth.replacesBrokerEntry === '')) {
543
- 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`);
544
566
  }
545
567
  if (typeof auth.replacesBrokerEntry === 'string' &&
546
568
  isFoundryOwnedBrokerEntryName(auth.replacesBrokerEntry)) {
547
- 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`);
548
570
  }
549
571
  if (!hasExactHostname(entry, hosting, domain)) {
550
- 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`);
551
573
  }
552
574
  if (auth.mcp !== undefined) {
553
575
  if (auth.users !== 'customers' || auth.registration !== 'open') {
554
- 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`);
555
577
  }
556
578
  if (typeof auth.mcp?.path !== 'string' ||
557
579
  !MCP_PATH.test(auth.mcp.path) ||
558
580
  auth.mcp.path.length > 200) {
559
- 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'`);
560
582
  }
561
583
  const onboarding = auth.mcp.onboardingPath;
562
584
  if (onboarding !== undefined &&
@@ -569,21 +591,21 @@ function validateEntryAuth(entry, hosting, domain) {
569
591
  onboarding.includes('\\') ||
570
592
  /[\s\p{Cc}]/u.test(onboarding) ||
571
593
  onboarding.length > ONBOARDING_PATH_MAX_LENGTH)) {
572
- 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`);
573
595
  }
574
596
  }
575
597
  const signIn = auth.signIn;
576
598
  if (signIn !== undefined && signIn !== 'password' && signIn !== 'email-code') {
577
- 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'`);
578
600
  }
579
601
  if (signIn === 'email-code' && auth.users !== 'customers') {
580
- 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`);
581
603
  }
582
604
  if (entry.key === 'agora' ||
583
605
  entry.key === 'boule' ||
584
606
  entry.key === 'cms' ||
585
607
  hosting.issuerApps.some((app) => app.name === entry.key)) {
586
- 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`);
587
609
  }
588
610
  return null;
589
611
  }
@@ -593,7 +615,7 @@ function hasExactHostname(entry, hosting, domain) {
593
615
  return true; // Its host is workerApp.host, or <slug>[-<key>].oikos.host.
594
616
  return hosting.usesDomain && domain !== null
595
617
  ? entry.hostnames.some((name) => name !== '*')
596
- : entry.key === APP_ENTRY_KEY;
618
+ : isMainEntry(entry);
597
619
  }
598
620
  /**
599
621
  * `uses.ai`: at least one offering, permissions from the known set, a
@@ -606,32 +628,32 @@ function validateEntryAi(entry, hosting, domain) {
606
628
  return null;
607
629
  const at = `hosting.entries '${entry.key}' uses.ai`;
608
630
  if (!hosting.enabled)
609
- return `${at} requires hosting.enabled`;
631
+ return refusal('ai_needs_hosting', `hosting.entries.${entry.key}.uses.ai`, `${at} requires hosting.enabled`);
610
632
  const isNames = (value) => Array.isArray(value) &&
611
633
  value.length > 0 &&
612
634
  value.every((v) => typeof v === 'string' && v !== '') &&
613
635
  new Set(value).size === value.length;
614
636
  if (!isNames(ai.offerings))
615
- 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`);
616
638
  if (ai.permissions !== undefined) {
617
639
  const known = AI_ENTRY_PERMISSIONS;
618
640
  if (!isNames(ai.permissions) || !ai.permissions.every((p) => known.includes(p))) {
619
- 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(', ')}`);
620
642
  }
621
643
  }
622
644
  if (ai.webhookPath !== undefined) {
623
645
  if (typeof ai.webhookPath !== 'string' ||
624
646
  !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,199}$/.test(ai.webhookPath) ||
625
647
  ai.webhookPath.startsWith('//')) {
626
- 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`);
627
649
  }
628
650
  if (!hasExactHostname(entry, hosting, domain)) {
629
- 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`);
630
652
  }
631
653
  }
632
654
  const named = Object.keys(entry.env ?? {}).find((name) => AI_ENTRY_ENV_NAMES.includes(name));
633
655
  if (named)
634
- 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`);
635
657
  return null;
636
658
  }
637
659
  /**
@@ -645,31 +667,31 @@ function validateEntryFiles(entry, hosting, domain) {
645
667
  return null;
646
668
  const at = `hosting.entries '${entry.key}' uses.files`;
647
669
  if (!hosting.enabled)
648
- return `${at} requires hosting.enabled`;
670
+ return refusal('files_needs_hosting', `hosting.entries.${entry.key}.uses.files`, `${at} requires hosting.enabled`);
649
671
  const names = (value) => Array.isArray(value) &&
650
672
  value.every((v) => typeof v === 'string' && v !== '') &&
651
673
  new Set(value).size === value.length;
652
674
  if (!names(files.collections) || !files.collections.every((n) => /^[A-Z][A-Z0-9_]*$/.test(n))) {
653
- 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`);
654
676
  }
655
677
  if (files.permissions !== undefined) {
656
678
  const known = FILES_ENTRY_PERMISSIONS;
657
679
  if (!names(files.permissions) ||
658
680
  files.permissions.length === 0 ||
659
681
  !files.permissions.every((p) => known.includes(p))) {
660
- 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(', ')}`);
661
683
  }
662
684
  }
663
685
  if (files.shareHost !== undefined && typeof files.shareHost !== 'boolean') {
664
- 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`);
665
687
  }
666
688
  if (files.shareHost && !(hosting.usesDomain && domain)) {
667
- 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>`);
668
690
  }
669
691
  const written = [...FILES_ENTRY_ENV_NAMES, ...files.collections.map(filesCollectionEnvName)];
670
692
  const named = Object.keys(entry.env ?? {}).find((name) => written.includes(name));
671
693
  if (named)
672
- 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`);
673
695
  return null;
674
696
  }
675
697
  /** A route pattern's zone apex: the host part before the first `/`, with one leading `*.` label stripped. */
@@ -707,35 +729,36 @@ function validateEntryWorker(entry, hosting, domain) {
707
729
  return null;
708
730
  const at = `hosting.entries '${entry.key}' worker`;
709
731
  if (!hosting.enabled)
710
- return `${at} requires hosting.enabled`;
732
+ return refusal('worker_needs_hosting', `hosting.entries.${entry.key}.worker`, `${at} requires hosting.enabled`);
711
733
  if (entry.repo === null) {
712
- 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)`);
713
735
  }
714
736
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
715
- 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`);
716
738
  }
717
739
  const invalidArtifact = validateArtifact(at, worker.artifactUrl, worker.artifactSha256, entry.repo);
718
- if (invalidArtifact)
719
- return invalidArtifact;
740
+ if (invalidArtifact) {
741
+ return refusal('worker_artifact_invalid', `hosting.entries.${entry.key}.worker`, invalidArtifact);
742
+ }
720
743
  if (!WORKER_ROUTE.test(worker.route)) {
721
- 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 '/'`);
722
745
  }
723
746
  const zone = workerRouteZone(worker.route);
724
747
  if (!domain || zone !== domain) {
725
- 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`);
726
749
  }
727
750
  // A wildcard route (`*.<domain>/*`) is allowed beside hostnames foundry owns:
728
751
  // `planWorkers` plans a no-Worker route for each of them, so they stay out of it.
729
752
  for (const limit of worker.bindings?.rateLimits ?? []) {
730
753
  if (!limit.name.trim())
731
- 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`);
732
755
  if (!limit.namespaceId.trim())
733
- 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`);
734
757
  if (!(Number.isInteger(limit.limit) && limit.limit > 0)) {
735
- 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`);
736
759
  }
737
760
  if (limit.period !== 10 && limit.period !== 60) {
738
- 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)`);
739
762
  }
740
763
  }
741
764
  return null;
@@ -751,15 +774,15 @@ function validateEntryEdge(entry, hosting, domain) {
751
774
  return null;
752
775
  const at = `hosting.entries '${entry.key}' uses.edge`;
753
776
  if (edge !== true)
754
- return `${at} must be true`;
777
+ return refusal('edge_not_true', `hosting.entries.${entry.key}.uses.edge`, `${at} must be true`);
755
778
  if (!hosting.enabled)
756
- return `${at} requires hosting.enabled`;
779
+ return refusal('edge_needs_hosting', `hosting.entries.${entry.key}.uses.edge`, `${at} requires hosting.enabled`);
757
780
  if (!hosting.usesDomain || !domain) {
758
- 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`);
759
782
  }
760
783
  const named = Object.keys(entry.env ?? {}).find((name) => EDGE_ENTRY_ENV_NAMES.includes(name));
761
784
  if (named)
762
- 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`);
763
786
  return null;
764
787
  }
765
788
  function urlHasPassword(value) {
@@ -983,6 +1006,10 @@ export function validateSignIn(org) {
983
1006
  * the customers organisation.
984
1007
  */
985
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) {
986
1013
  const customers = org.customers;
987
1014
  if (customers !== undefined) {
988
1015
  const value = customers;
@@ -992,14 +1019,15 @@ export function validateCustomers(org) {
992
1019
  typeof value?.enabled !== 'boolean' ||
993
1020
  typeof value?.signUp !== 'boolean' ||
994
1021
  Object.keys(customers).some((key) => key !== 'enabled' && key !== 'signUp')) {
995
- 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');
996
1026
  }
997
- if (value.signUp && !value.enabled)
998
- return 'customers.signUp needs customers.enabled';
999
1027
  }
1000
1028
  const open = org.hosting.entries.find((entry) => entry.uses?.auth?.registration === 'open');
1001
1029
  if (open && !(org.customers?.enabled && org.customers.signUp)) {
1002
- 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`);
1003
1031
  }
1004
1032
  return null;
1005
1033
  }
@@ -1055,8 +1083,49 @@ export function validateBuyerIdentity(buyer) {
1055
1083
  }
1056
1084
  return null;
1057
1085
  }
1086
+ /** Longest `roleAtCompany` and `phone`: the limits Boule's owner-seeding door enforces. */
1087
+ const OWNER_ROLE_MAX = 100;
1088
+ const OWNER_PHONE_MAX = 40;
1089
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the point is to refuse them
1090
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
1091
+ /** A fixed sentence when an optional owner text field is not usable, `null` when absent or fine. The value is never in it. */
1092
+ function ownerTextProblem(owner, field, max) {
1093
+ const value = owner[field];
1094
+ if (value === undefined)
1095
+ return null;
1096
+ const trimmed = value.trim();
1097
+ if (!trimmed || trimmed.length > max || CONTROL_CHARACTER.test(value)) {
1098
+ return `owner ${owner.email} ${field} must be 1 to ${max} characters with no control character`;
1099
+ }
1100
+ return null;
1101
+ }
1058
1102
  /** A deliberately loose email shape check -- foundry never sends mail itself, ZITADEL does; this only catches an obviously malformed address before it reaches the invite call. */
1059
1103
  const OWNER_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1104
+ const ANY_TEXT = { minLength: 0, maxLength: Number.MAX_SAFE_INTEGER, pattern: /^/ };
1105
+ /**
1106
+ * `null` when the owner's sign-in name (the part of the work address before the
1107
+ * `@`) and display names may be used; otherwise a fixed sentence. The sign-in
1108
+ * name gets the broker's whole check, reserved names included. The given and
1109
+ * family names are free text and only refuse a slur. The sentence never holds
1110
+ * the value.
1111
+ */
1112
+ export function ownerNameProblem(mailboxAddress, givenName, familyName) {
1113
+ if (mailboxAddress) {
1114
+ const local = mailboxAddress.slice(0, mailboxAddress.lastIndexOf('@'));
1115
+ const verdict = checkName(local, DEFAULT_RULES);
1116
+ if (!verdict.ok)
1117
+ return `the work address name before the @ is refused: ${explain(verdict.reason)}`;
1118
+ }
1119
+ for (const [label, value] of [
1120
+ ['givenName', givenName],
1121
+ ['familyName', familyName],
1122
+ ]) {
1123
+ const verdict = checkName(value, ANY_TEXT);
1124
+ if (!verdict.ok && verdict.reason === 'disallowed')
1125
+ return `${label} is not allowed`;
1126
+ }
1127
+ return null;
1128
+ }
1060
1129
  /**
1061
1130
  * `null` when `owners` is valid; otherwise the reason it is not, same shape as
1062
1131
  * {@link validateBuyerIdentity} and `../slug.ts`'s `validateSlug`. An empty
@@ -1078,6 +1147,13 @@ export function validateOwners(owners) {
1078
1147
  if (owner.mailboxAddress !== undefined && !OWNER_EMAIL_PATTERN.test(owner.mailboxAddress)) {
1079
1148
  return `owner ${owner.email} mailboxAddress '${owner.mailboxAddress}' is not a valid email address`;
1080
1149
  }
1150
+ const problem = ownerNameProblem(owner.mailboxAddress, owner.givenName, owner.familyName);
1151
+ if (problem)
1152
+ return `owner ${owner.email}: ${problem}`;
1153
+ const textProblem = ownerTextProblem(owner, 'roleAtCompany', OWNER_ROLE_MAX) ??
1154
+ ownerTextProblem(owner, 'phone', OWNER_PHONE_MAX);
1155
+ if (textProblem)
1156
+ return textProblem;
1081
1157
  if (owner.mailboxAddress !== undefined) {
1082
1158
  const box = owner.mailboxAddress.toLowerCase();
1083
1159
  if (boxes.has(box))
@@ -1283,13 +1359,15 @@ export function validateMail(org, opts) {
1283
1359
  */
1284
1360
  export const ORG_POSTGRES_SERVER_SCOPE_KEY = 'org_postgres_server';
1285
1361
  /** Builds one {@link OrgServiceSchema}: its owner role, runtime role and scope key all follow from {@link schema} alone, so every caller gets the same shape. */
1286
- function orgServiceSchema(service, schema) {
1362
+ function orgServiceSchema(service, schema, legacyKey, split = true) {
1287
1363
  return {
1288
1364
  service,
1289
1365
  schema,
1290
1366
  ownerRole: `${schema}_owner`,
1291
1367
  runtimeRole: `${schema}_rt`,
1292
1368
  scopeKey: `org_postgres_schema.${schema}`,
1369
+ split,
1370
+ legacyKey,
1293
1371
  };
1294
1372
  }
1295
1373
  /**
@@ -1324,13 +1402,87 @@ function orgServiceSchema(service, schema) {
1324
1402
  * create.
1325
1403
  */
1326
1404
  export const ORG_SERVICE_SCHEMAS = [
1327
- orgServiceSchema('Boule', 'boule'),
1328
- orgServiceSchema('CMS', 'cms'),
1329
- orgServiceSchema("chat's Synapse homeserver", 'chat_synapse'),
1330
- orgServiceSchema("chat's matrix-authentication-service", 'chat_mas'),
1331
- orgServiceSchema("chat's own service", 'chat_service'),
1332
- orgServiceSchema("calls' Synapse homeserver", 'calls_synapse'),
1333
- orgServiceSchema("calls' matrix-authentication-service", 'calls_mas'),
1334
- orgServiceSchema("calls' own service", 'calls_service'),
1335
- orgServiceSchema("the org's own app", 'app'),
1405
+ orgServiceSchema('Boule', 'boule', () => 'coolify_boule_database'),
1406
+ orgServiceSchema('CMS', 'cms', () => 'coolify_cms_database'),
1407
+ orgServiceSchema("chat's Synapse homeserver", 'chat_synapse', () => 'coolify_chat_synapse_database', false),
1408
+ orgServiceSchema("chat's matrix-authentication-service", 'chat_mas', () => 'coolify_chat_mas_database', false),
1409
+ orgServiceSchema("chat's own service", 'chat_service', () => 'coolify_chat_service_database'),
1410
+ orgServiceSchema("calls' Synapse homeserver", 'calls_synapse', () => 'coolify_calls_synapse_database', false),
1411
+ orgServiceSchema("calls' matrix-authentication-service", 'calls_mas', () => 'coolify_calls_mas_database', false),
1412
+ orgServiceSchema("calls' own service", 'calls_service', () => 'coolify_calls_service_database'),
1413
+ orgServiceSchema("the org's own app", 'app', (org) => `coolify:database:${org.id}`),
1414
+ orgServiceSchema('desktop-auth', 'desktop_auth', () => null),
1336
1415
  ];
1416
+ const POSTGRES_NAME = /^[a-z][a-z0-9_]{0,62}$/;
1417
+ function postgresName(name) {
1418
+ if (!POSTGRES_NAME.test(name))
1419
+ throw new Error(`Postgres name is not valid: ${name}`);
1420
+ return name;
1421
+ }
1422
+ /**
1423
+ * Pure. The names a home is created under on the org's server. A role name is
1424
+ * cluster-wide, so the live runtime role names are kept: where a service's
1425
+ * runtime role is `<database>_rt` today, the database keeps its live name.
1426
+ * A new and a migrated company use the same names.
1427
+ */
1428
+ export function orgServiceDatabase(org, home) {
1429
+ const underscored = (name) => name.replace(/-/g, '_');
1430
+ let database;
1431
+ let runtimeRole = null;
1432
+ switch (home.schema) {
1433
+ case 'boule':
1434
+ database = underscored(`${org.slug}-boule-db`);
1435
+ break;
1436
+ case 'cms':
1437
+ database = underscored(`${org.slug}-cms-db`);
1438
+ runtimeRole = 'cms_rt';
1439
+ break;
1440
+ case 'chat_service':
1441
+ database = 'chat';
1442
+ break;
1443
+ case 'calls_service':
1444
+ database = underscored(`${org.slug}-calls-db`);
1445
+ break;
1446
+ case 'app':
1447
+ database = underscored(org.slug);
1448
+ break;
1449
+ default:
1450
+ database = home.schema;
1451
+ }
1452
+ postgresName(database);
1453
+ if (!home.split) {
1454
+ return {
1455
+ schema: home.schema,
1456
+ database,
1457
+ ownerRole: postgresName(`${database}_owner`),
1458
+ runtimeRole: postgresName(`${database}_owner`),
1459
+ split: false,
1460
+ collateC: home.schema.endsWith('_synapse'),
1461
+ };
1462
+ }
1463
+ return {
1464
+ schema: home.schema,
1465
+ database,
1466
+ ownerRole: postgresName(`${database}_owner`),
1467
+ runtimeRole: postgresName(runtimeRole ?? `${database}_rt`),
1468
+ split: true,
1469
+ collateC: false,
1470
+ };
1471
+ }
1472
+ /** Every home's names for `org`. Throws when two homes would share a database or a role (a slug such as `chat` collides with chat's own service). */
1473
+ export function orgServiceDatabases(org) {
1474
+ const all = ORG_SERVICE_SCHEMAS.map((home) => orgServiceDatabase(org, home));
1475
+ const databases = new Set();
1476
+ const roles = new Set();
1477
+ for (const entry of all) {
1478
+ if (databases.has(entry.database))
1479
+ throw new Error(`Postgres database is used by two homes: ${entry.database}`);
1480
+ databases.add(entry.database);
1481
+ for (const role of new Set([entry.ownerRole, entry.runtimeRole])) {
1482
+ if (roles.has(role))
1483
+ throw new Error(`Postgres role is used by two homes: ${role}`);
1484
+ roles.add(role);
1485
+ }
1486
+ }
1487
+ return all;
1488
+ }