@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.
- package/dist/build-json.d.ts +28 -0
- package/dist/build-json.js +95 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -2
- package/dist/migrations/0058_app_requests.sql +32 -0
- package/dist/org.d.ts +68 -5
- package/dist/org.js +287 -135
- package/dist/plan/agora.d.ts +14 -0
- package/dist/plan/agora.js +21 -0
- package/dist/plan/backup-gate.d.ts +15 -0
- package/dist/plan/backup-gate.js +38 -0
- package/dist/plan/boule-owners-http.d.ts +12 -0
- package/dist/plan/boule-owners-http.js +59 -0
- package/dist/plan/boule.d.ts +38 -2
- package/dist/plan/boule.js +72 -1
- package/dist/plan/company-backups.d.ts +7 -1
- package/dist/plan/company-backups.js +42 -2
- package/dist/plan/cookie-domain.d.ts +41 -0
- package/dist/plan/cookie-domain.js +71 -0
- package/dist/plan/desktop-auth.d.ts +58 -0
- package/dist/plan/desktop-auth.js +144 -0
- package/dist/plan/desktop-callback.d.ts +26 -0
- package/dist/plan/desktop-callback.js +59 -0
- package/dist/plan/diff.d.ts +13 -4
- package/dist/plan/diff.js +277 -88
- package/dist/plan/executor.d.ts +136 -6
- package/dist/plan/executor.js +732 -105
- package/dist/plan/health-gaps.js +5 -0
- package/dist/plan/health-targets.js +1 -2
- package/dist/plan/hostnames.d.ts +2 -2
- package/dist/plan/hostnames.js +6 -6
- package/dist/plan/index.d.ts +4 -1
- package/dist/plan/index.js +3 -1
- package/dist/plan/job-app.d.ts +23 -0
- package/dist/plan/job-app.js +41 -0
- package/dist/plan/live.d.ts +6 -0
- package/dist/plan/live.js +16 -0
- package/dist/plan/login-host.d.ts +28 -0
- package/dist/plan/login-host.js +36 -0
- package/dist/plan/org-names.js +9 -1
- package/dist/plan/org-postgres-job.d.ts +22 -0
- package/dist/plan/org-postgres-job.js +91 -0
- package/dist/plan/org-postgres-migrate.d.ts +115 -0
- package/dist/plan/org-postgres-migrate.js +466 -0
- package/dist/plan/org-postgres.d.ts +90 -0
- package/dist/plan/org-postgres.js +293 -0
- package/dist/plan/plan-apply.d.ts +60 -3
- package/dist/plan/plan-apply.js +174 -8
- package/dist/plan/platform-diff.d.ts +45 -0
- package/dist/plan/platform-diff.js +159 -4
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +4 -0
- package/dist/plan/platform-providers.d.ts +12 -2
- package/dist/plan/platform-providers.js +282 -9
- package/dist/plan/post-logout-uris.d.ts +22 -0
- package/dist/plan/post-logout-uris.js +43 -0
- package/dist/plan/providers-readers.d.ts +10 -1
- package/dist/plan/providers-readers.js +56 -5
- package/dist/plan/sign-in-bindings.d.ts +4 -0
- package/dist/plan/sign-in-bindings.js +10 -0
- package/dist/plan/sign-in-org.d.ts +19 -6
- package/dist/plan/sign-in-org.js +35 -10
- package/dist/plan/sign-in.d.ts +12 -5
- package/dist/plan/sign-in.js +12 -5
- package/dist/plan/signin-db-move.js +10 -34
- package/dist/plan/worker-app.js +3 -1
- package/dist/refuse-org.d.ts +7 -0
- package/dist/refuse-org.js +10 -0
- package/dist/requested-changes.d.ts +50 -4
- package/dist/requested-changes.js +9 -1
- package/dist/store/postgres.d.ts +5 -1
- package/dist/store/postgres.js +67 -8
- package/dist/store/schema.d.ts +72 -0
- package/dist/store/schema.js +4 -0
- package/dist/store.d.ts +24 -1
- package/dist/worker-app-config.d.ts +8 -2
- package/dist/worker-app-config.js +20 -6
- package/package.json +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
|
-
/**
|
|
47
|
-
export function
|
|
48
|
-
return
|
|
50
|
+
/** True for the entry marked `main`, and for an entry keyed {@link APP_ENTRY_KEY} that leaves `main` unset. */
|
|
51
|
+
export function isMainEntry(entry) {
|
|
52
|
+
return entry.main ?? entry.key === APP_ENTRY_KEY;
|
|
53
|
+
}
|
|
54
|
+
/** The org's main app: the entry marked `main`, else the entry keyed {@link APP_ENTRY_KEY}. */
|
|
55
|
+
export function mainEntry(hosting) {
|
|
56
|
+
return (hosting.entries.find((entry) => entry.main === true) ??
|
|
57
|
+
hosting.entries.find((entry) => entry.main === undefined && entry.key === APP_ENTRY_KEY));
|
|
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
|
|
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 &&
|
|
144
|
-
return
|
|
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
|
|
316
|
-
return `${at} hostnames need hosting.usesDomain: without it only the
|
|
333
|
+
if (!hosting.usesDomain && !isMainEntry(entry) && entry.hostnames.length > 0) {
|
|
334
|
+
return refusal('hostnames_need_domain', `hosting.entries.${entry.key}.hostnames`, `${at} hostnames need hosting.usesDomain: without it only the main app is served, at its own address`);
|
|
317
335
|
}
|
|
318
336
|
const reads = hosting.usesDomain && names?.domain;
|
|
319
337
|
if (reads &&
|
|
320
|
-
entry
|
|
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 =
|
|
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
|
|
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
|
|
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
|
+
}
|