@wtfalch/foundry 0.60.0 → 0.62.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/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +7 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +60 -8
- package/dist/plan/blue-green.js +115 -10
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +231 -19
- package/dist/plan/executor.js +1648 -129
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +19 -2
package/dist/org.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { DEFAULT_RULES, checkName, explain } from '@wtfalch/auth/names';
|
|
2
2
|
import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
|
|
3
|
+
import { AI_ENTRY_PERMISSIONS, AI_WEBHOOK_PATH, FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, ROOM_UNSAFE_TEXT, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, isPagePath, } from './entry-rules.js';
|
|
4
|
+
import { hostedLabelRefusal } from './hosted-label.js';
|
|
3
5
|
import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
|
|
6
|
+
export { AI_ENTRY_PERMISSIONS, FILES_ENTRY_PERMISSIONS, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, } from './entry-rules.js';
|
|
4
7
|
/** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
|
|
5
8
|
export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
|
|
6
9
|
/** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
|
|
@@ -13,24 +16,29 @@ export const APP_ENTRY_KEY = 'app';
|
|
|
13
16
|
function refusal(code, path, message) {
|
|
14
17
|
return { code, path, message };
|
|
15
18
|
}
|
|
19
|
+
export const MAX_PRIVATE_PAGES = 20;
|
|
20
|
+
export const MAX_PRIVATE_PAGE_ROLES = 50;
|
|
21
|
+
export const MAX_PRIVATE_PAGE_PEOPLE = 200;
|
|
22
|
+
export const PRIVATE_PAGE_PATH = /^(\/[A-Za-z0-9._~-]+)+$/;
|
|
23
|
+
/** The keys of the Access gates private pages get: `pp-` (live) or `wp-` (workspace) and 16 hex characters. */
|
|
24
|
+
export const PRIVATE_PAGE_GATE_KEY = /^(pp|wp)-[0-9a-f]{16}$/;
|
|
25
|
+
/** A full git commit: 40 lowercase hex characters. */
|
|
26
|
+
export const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
27
|
+
export const MAX_WORKSPACE_TESTERS = 50;
|
|
28
|
+
/** An entry is live unless it says `live: false`. */
|
|
29
|
+
export const isLiveEntry = (entry) => entry.live !== false;
|
|
30
|
+
/** Trimmed, lowercased and distinct testers, in the order first seen. The planner and hosts read testers through this. */
|
|
31
|
+
export function normalizeTesters(testers) {
|
|
32
|
+
return [...new Set(testers.map((email) => email.trim().toLowerCase()))];
|
|
33
|
+
}
|
|
34
|
+
/** True while the company's Boule runs apps (`org.boule.apps`). */
|
|
35
|
+
export const appsOn = (org) => org.boule?.apps === true;
|
|
36
|
+
/** True while the company's kosmos is turned on (`org.kosmos.enabled`). */
|
|
37
|
+
export const kosmosOn = (org) => org.kosmos?.enabled === true;
|
|
16
38
|
/** The most volumes one entry may declare. */
|
|
17
39
|
export const MAX_ENTRY_VOLUMES = 8;
|
|
18
|
-
/**
|
|
19
|
-
export const
|
|
20
|
-
name: 40,
|
|
21
|
-
headline: 120,
|
|
22
|
-
line: 200,
|
|
23
|
-
title: 60,
|
|
24
|
-
description: 200,
|
|
25
|
-
};
|
|
26
|
-
export const ROOM_STEPS_MIN = 2;
|
|
27
|
-
export const ROOM_STEPS_MAX = 9;
|
|
28
|
-
/** `uses.auth.mcp.onboardingPath`: absolute, no query, no fragment, no dot segments. */
|
|
29
|
-
export const ONBOARDING_PATH_MAX_LENGTH = 200;
|
|
30
|
-
/** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
|
|
31
|
-
export const MCP_PATH = /^\/[A-Za-z0-9_~-]+(\/[A-Za-z0-9_~-]+)*$|^\/$/;
|
|
32
|
-
/** What an entry's ai key may hold: the most the org's own key holds. */
|
|
33
|
-
export const AI_ENTRY_PERMISSIONS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
40
|
+
/** The env names `uses.auth.workspaces` writes; `env` may not name them. */
|
|
41
|
+
export const WORKSPACES_ENTRY_ENV_NAMES = ['WORKSPACES_URL', 'WORKSPACES_API_KEY'];
|
|
34
42
|
/** The env names `uses.ai` writes; `env` may not name them. */
|
|
35
43
|
export const AI_ENTRY_ENV_NAMES = [
|
|
36
44
|
'AI_URL',
|
|
@@ -39,15 +47,6 @@ export const AI_ENTRY_ENV_NAMES = [
|
|
|
39
47
|
'AI_WEBHOOK_SECRET',
|
|
40
48
|
'AI_OFFERINGS_JSON',
|
|
41
49
|
];
|
|
42
|
-
/** What an entry's files key may hold: read and write on its collections, and the most the org's own key holds besides minting. */
|
|
43
|
-
export const FILES_ENTRY_PERMISSIONS = [
|
|
44
|
-
'files.collections:read',
|
|
45
|
-
'files.files:read',
|
|
46
|
-
'files.files:download',
|
|
47
|
-
'files.files:upload',
|
|
48
|
-
'files.files:replace',
|
|
49
|
-
'files.files:delete',
|
|
50
|
-
];
|
|
51
50
|
/** The env names `uses.files` always writes, besides one `FILES_COLLECTION_<NAME>_ID` per collection. */
|
|
52
51
|
export const FILES_ENTRY_ENV_NAMES = [
|
|
53
52
|
'FILES_URL',
|
|
@@ -57,6 +56,14 @@ export const FILES_ENTRY_ENV_NAMES = [
|
|
|
57
56
|
export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
58
57
|
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
59
58
|
export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
|
|
59
|
+
/** The env names `uses.mail` writes; `env` may not name them. */
|
|
60
|
+
export const MAIL_ENTRY_ENV_NAMES = ['LETTERMINT_API_TOKEN', 'MAIL_FROM'];
|
|
61
|
+
/** The env names `uses.jobs` writes; `env` may not name them. */
|
|
62
|
+
export const JOBS_ENTRY_ENV_NAMES = [
|
|
63
|
+
'JOBS_SCHEDULER_URL',
|
|
64
|
+
'JOBS_SIGNING_SECRET',
|
|
65
|
+
'JOBS_SYNC_KEY',
|
|
66
|
+
];
|
|
60
67
|
/** True for the entry marked `main`, and for an entry keyed {@link APP_ENTRY_KEY} that leaves `main` unset. */
|
|
61
68
|
export function isMainEntry(entry) {
|
|
62
69
|
return entry.main ?? entry.key === APP_ENTRY_KEY;
|
|
@@ -161,6 +168,9 @@ export function refuseHosting(hosting, names) {
|
|
|
161
168
|
const badRepos = refuseHostingRepos(hosting.repos);
|
|
162
169
|
if (badRepos)
|
|
163
170
|
return badRepos;
|
|
171
|
+
const badGithub = refuseHostingGithub(hosting.github);
|
|
172
|
+
if (badGithub)
|
|
173
|
+
return badGithub;
|
|
164
174
|
if (hosting.tunnel && !hosting.enabled) {
|
|
165
175
|
return refusal('hosting_tunnel_needs_hosting', 'hosting.tunnel', 'hosting.tunnel requires hosting.enabled');
|
|
166
176
|
}
|
|
@@ -192,16 +202,31 @@ export function refuseHosting(hosting, names) {
|
|
|
192
202
|
if (hosting.enabled && hosting.entries.length === 0) {
|
|
193
203
|
return refusal('hosting_needs_entry', 'hosting.entries', 'hosting.enabled requires at least one entry');
|
|
194
204
|
}
|
|
195
|
-
const reserveKeys = names?.builtinBlueGreen === true ||
|
|
205
|
+
const reserveKeys = names?.builtinBlueGreen === true ||
|
|
206
|
+
hosting.entries.some((e) => e.blueGreen !== undefined || e.workspace !== undefined);
|
|
196
207
|
const seenKeys = new Set();
|
|
208
|
+
const seenHostedNames = new Set();
|
|
197
209
|
for (const entry of hosting.entries) {
|
|
198
210
|
if (seenKeys.has(entry.key)) {
|
|
199
211
|
return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
|
|
200
212
|
}
|
|
201
213
|
seenKeys.add(entry.key);
|
|
202
|
-
const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys);
|
|
214
|
+
const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys, hosting.github);
|
|
203
215
|
if (invalid)
|
|
204
216
|
return invalid;
|
|
217
|
+
if (entry.hostedName !== undefined) {
|
|
218
|
+
const path = `hosting.entries.${entry.key}.hostedName`;
|
|
219
|
+
const bad = hostedLabelRefusal(entry.hostedName);
|
|
220
|
+
if (bad)
|
|
221
|
+
return refusal('hosted_name_invalid', path, bad.message);
|
|
222
|
+
if (entry.workerApp) {
|
|
223
|
+
return refusal('hosted_name_worker_app', path, 'A Worker app is served at the address its own host setting names. An app address on companyincluded.app is for apps that run as servers.');
|
|
224
|
+
}
|
|
225
|
+
if (seenHostedNames.has(entry.hostedName)) {
|
|
226
|
+
return refusal('hosted_name_not_unique', path, 'Two apps in this company cannot use the same app address. Choose a different one for one of them.');
|
|
227
|
+
}
|
|
228
|
+
seenHostedNames.add(entry.hostedName);
|
|
229
|
+
}
|
|
205
230
|
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
206
231
|
if (invalidAuth)
|
|
207
232
|
return invalidAuth;
|
|
@@ -221,6 +246,18 @@ export function refuseHosting(hosting, names) {
|
|
|
221
246
|
const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
|
|
222
247
|
if (invalidEdgeUse)
|
|
223
248
|
return invalidEdgeUse;
|
|
249
|
+
const invalidMailJobs = validateEntryMailJobs(entry, hosting);
|
|
250
|
+
if (invalidMailJobs)
|
|
251
|
+
return invalidMailJobs;
|
|
252
|
+
const invalidGrants = validateEntryCustomerGrants(entry);
|
|
253
|
+
if (invalidGrants)
|
|
254
|
+
return invalidGrants;
|
|
255
|
+
const invalidRepoUse = validateEntryRepoUse(entry);
|
|
256
|
+
if (invalidRepoUse)
|
|
257
|
+
return invalidRepoUse;
|
|
258
|
+
const invalidAuditUse = validateEntryAudit(entry, hosting);
|
|
259
|
+
if (invalidAuditUse)
|
|
260
|
+
return invalidAuditUse;
|
|
224
261
|
}
|
|
225
262
|
const mains = hosting.entries.filter(isMainEntry);
|
|
226
263
|
if (mains.length > 1) {
|
|
@@ -317,6 +354,14 @@ function validateGates(hosting, domain, locked) {
|
|
|
317
354
|
return refusal('gate_limit_invalid', 'hosting.gateLimit', 'hosting.gateLimit must be a positive whole number');
|
|
318
355
|
}
|
|
319
356
|
const seenKeys = new Set();
|
|
357
|
+
const workspaceGateKeys = new Set();
|
|
358
|
+
for (const entry of hosting.entries) {
|
|
359
|
+
if (entry.workspace === undefined)
|
|
360
|
+
continue;
|
|
361
|
+
workspaceGateKeys.add(`ws-${entry.key}`);
|
|
362
|
+
if (entry.workerApp === undefined)
|
|
363
|
+
workspaceGateKeys.add('ws--inbox');
|
|
364
|
+
}
|
|
320
365
|
for (const entry of hosting.entries) {
|
|
321
366
|
const taken = new Set();
|
|
322
367
|
for (const gate of entry.gates) {
|
|
@@ -327,6 +372,12 @@ function validateGates(hosting, domain, locked) {
|
|
|
327
372
|
if (!ENTRY_KEY.test(gate.key)) {
|
|
328
373
|
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)`);
|
|
329
374
|
}
|
|
375
|
+
if (workspaceGateKeys.has(gate.key)) {
|
|
376
|
+
return refusal('gate_key_taken_by_workspace', `hosting.entries.${entry.key}.gates.${gate.key}.key`, `${at} key is used for a private workspace's sign-in. Choose a different key.`);
|
|
377
|
+
}
|
|
378
|
+
if (PRIVATE_PAGE_GATE_KEY.test(gate.key)) {
|
|
379
|
+
return refusal('gate_key_taken_by_private_page', `hosting.entries.${entry.key}.gates.${gate.key}.key`, `${at} key is used for a private page's sign-in. Choose a different key.`);
|
|
380
|
+
}
|
|
330
381
|
if (seenKeys.has(gate.key))
|
|
331
382
|
return refusal('gate_key_not_unique', `hosting.entries.${entry.key}.gates.${gate.key}.key`, `${at} key is used by two gates`);
|
|
332
383
|
seenKeys.add(gate.key);
|
|
@@ -400,7 +451,7 @@ function validateEntryHostnames(hosting, names) {
|
|
|
400
451
|
}
|
|
401
452
|
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
402
453
|
/** Names of the parts blue/green adds to an app (`<app>-stage`, `-blue`, `-green`, `-lb`): an entry of that name would adopt one. */
|
|
403
|
-
const RESERVED_ENTRY_KEY = /^(stage|blue|green|lb)$|-(stage|blue|green|lb)$/;
|
|
454
|
+
const RESERVED_ENTRY_KEY = /^(stage|blue|green|lb|apps-db)$|-(stage|blue|green|lb)$/;
|
|
404
455
|
const VOLUME_MOUNT_PATH = /^(\/(?!\.\.?(\/|$))[A-Za-z0-9._-]+)+$/;
|
|
405
456
|
/** The log volume's mount path; `executor.ts`'s `LOG_DIR` is the same value. */
|
|
406
457
|
const LOG_DIR_PATH = '/app/logs';
|
|
@@ -422,6 +473,46 @@ const VOLUME_FORBIDDEN_ROOTS = [
|
|
|
422
473
|
const VOLUME_MOUNT_PATH_MAX = 255;
|
|
423
474
|
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
424
475
|
const HOSTING_REPO = /^wtfalch\/[A-Za-z0-9._-]{1,100}$/;
|
|
476
|
+
const GITHUB_ACCOUNT = /^[A-Za-z0-9-]{1,39}$/;
|
|
477
|
+
const REPO_NAME = /^[A-Za-z0-9._-]+$/;
|
|
478
|
+
/** The account whose repos the platform's own source builds; a company cannot claim it as its own. */
|
|
479
|
+
const PLATFORM_GITHUB_ACCOUNT = 'wtfalch';
|
|
480
|
+
/** The rules for `hosting.github`: a positive integer installation id and a GitHub login (1-39 letters, digits, hyphens) that is not the platform's own. */
|
|
481
|
+
function refuseHostingGithub(github) {
|
|
482
|
+
const invalid = (message) => refusal('hosting_github_invalid', 'hosting.github', message);
|
|
483
|
+
if (github === undefined)
|
|
484
|
+
return null;
|
|
485
|
+
if (typeof github !== 'object' || github === null)
|
|
486
|
+
return invalid('hosting.github must be an object');
|
|
487
|
+
const { installationId, account } = github;
|
|
488
|
+
if (typeof installationId !== 'number' ||
|
|
489
|
+
!Number.isSafeInteger(installationId) ||
|
|
490
|
+
installationId <= 0) {
|
|
491
|
+
return invalid('hosting.github.installationId must be a positive integer');
|
|
492
|
+
}
|
|
493
|
+
if (typeof account !== 'string' || !GITHUB_ACCOUNT.test(account)) {
|
|
494
|
+
return invalid('hosting.github.account must be a GitHub login: 1-39 letters, digits or hyphens');
|
|
495
|
+
}
|
|
496
|
+
if (account.toLowerCase() === PLATFORM_GITHUB_ACCOUNT) {
|
|
497
|
+
return invalid(`hosting.github.account cannot be ${PLATFORM_GITHUB_ACCOUNT}, the platform's own account`);
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
/** True when `repo` is `<account>/<name>` with the account compared ignoring case. */
|
|
502
|
+
function repoUnderAccount(repo, account) {
|
|
503
|
+
const slash = repo.indexOf('/');
|
|
504
|
+
return (slash > 0 &&
|
|
505
|
+
repo.slice(0, slash).toLowerCase() === account.toLowerCase() &&
|
|
506
|
+
REPO_NAME.test(repo.slice(slash + 1)));
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The company's GitHub config when `repo` is under its account, else `undefined`: such an entry is built
|
|
510
|
+
* through the company's own Coolify source, any other through the platform's. Read it only through this.
|
|
511
|
+
*/
|
|
512
|
+
export function companyGithubFor(hosting, repo) {
|
|
513
|
+
const github = hosting.github;
|
|
514
|
+
return github && repo !== null && repoUnderAccount(repo, github.account) ? github : undefined;
|
|
515
|
+
}
|
|
425
516
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
426
517
|
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
427
518
|
// Set only on the colors of a blueGreen entry; any other entry may declare them.
|
|
@@ -429,16 +520,126 @@ const BLUE_GREEN_ENV_NAME = /^(REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)|NEXT_SERVE
|
|
|
429
520
|
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
430
521
|
const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
|
|
431
522
|
/** The rules for one {@link HostingEntry}; `null` when it is valid. */
|
|
432
|
-
|
|
523
|
+
/** Why an entry's `workspace` and `live` are refused, or `null`. */
|
|
524
|
+
function validateEntryWorkspace(entry, at, hostingEnabled) {
|
|
525
|
+
const path = `hosting.entries.${entry.key}`;
|
|
526
|
+
if (entry.live !== undefined && typeof entry.live !== 'boolean') {
|
|
527
|
+
return refusal('entry_workspace_invalid', `${path}.live`, `${at} live must be true or false`);
|
|
528
|
+
}
|
|
529
|
+
if (entry.liveCommit !== undefined) {
|
|
530
|
+
const commitAt = `${path}.liveCommit`;
|
|
531
|
+
if (typeof entry.liveCommit !== 'string' || !FULL_COMMIT_SHA.test(entry.liveCommit)) {
|
|
532
|
+
return refusal('entry_live_commit_invalid', commitAt, `${at} liveCommit must be a full commit: 40 lowercase letters a-f and digits`);
|
|
533
|
+
}
|
|
534
|
+
if (entry.workspace === undefined || entry.workerApp !== undefined) {
|
|
535
|
+
return refusal('entry_live_commit_invalid', commitAt, `${at} liveCommit needs a workspace and an app that runs as a server`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (entry.workspace === undefined) {
|
|
539
|
+
return entry.live === false
|
|
540
|
+
? refusal('entry_live_needs_workspace', `${path}.live`, `${at} live false needs a workspace: with neither, the app would exist nowhere`)
|
|
541
|
+
: null;
|
|
542
|
+
}
|
|
543
|
+
const unsupported = (message) => refusal('entry_workspace_unsupported', `${path}.workspace`, `${at} ${message}`);
|
|
544
|
+
if (!hostingEnabled)
|
|
545
|
+
return unsupported('workspace requires hosting.enabled');
|
|
546
|
+
if (entry.blueGreen !== undefined) {
|
|
547
|
+
return unsupported('workspace cannot be combined with blueGreen: the workspace is the stage, and an entry has one');
|
|
548
|
+
}
|
|
549
|
+
if (entry.worker !== undefined) {
|
|
550
|
+
return unsupported('workspace cannot be combined with a plain Worker');
|
|
551
|
+
}
|
|
552
|
+
if (entry.workerApp?.database !== undefined) {
|
|
553
|
+
return unsupported('workspace cannot be combined with a Worker app that has a database: its workspace would have to share the live one');
|
|
554
|
+
}
|
|
555
|
+
if (entry.repo === null && entry.workerApp === undefined) {
|
|
556
|
+
return unsupported('workspace needs a repo or a Worker app to run');
|
|
557
|
+
}
|
|
558
|
+
const { testers } = entry.workspace;
|
|
559
|
+
if (!Array.isArray(testers)) {
|
|
560
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace testers must be a list of email addresses`);
|
|
561
|
+
}
|
|
562
|
+
if (testers.length > MAX_WORKSPACE_TESTERS) {
|
|
563
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace can have at most ${MAX_WORKSPACE_TESTERS} testers`);
|
|
564
|
+
}
|
|
565
|
+
const seen = new Set();
|
|
566
|
+
for (const email of testers) {
|
|
567
|
+
if (typeof email !== 'string' || email.length > 254 || !OWNER_EMAIL_PATTERN.test(email)) {
|
|
568
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace tester '${String(email).slice(0, 80)}' is not a valid email address`);
|
|
569
|
+
}
|
|
570
|
+
const key = email.toLowerCase();
|
|
571
|
+
if (seen.has(key)) {
|
|
572
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace tester '${email}' is listed more than once`);
|
|
573
|
+
}
|
|
574
|
+
seen.add(key);
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
/** Why an entry's `privatePages` are refused, or `null`. */
|
|
579
|
+
function validateEntryPrivatePages(entry, at, hostingEnabled) {
|
|
580
|
+
const pages = entry.privatePages;
|
|
581
|
+
if (pages === undefined)
|
|
582
|
+
return null;
|
|
583
|
+
const path = `hosting.entries.${entry.key}.privatePages`;
|
|
584
|
+
if (!Array.isArray(pages)) {
|
|
585
|
+
return refusal('private_page_invalid', path, `${at} privatePages must be a list`);
|
|
586
|
+
}
|
|
587
|
+
if (pages.length === 0)
|
|
588
|
+
return null;
|
|
589
|
+
if (!hostingEnabled) {
|
|
590
|
+
return refusal('private_page_needs_hosting', path, `${at} private pages need hosting.enabled: there is no address to protect`);
|
|
591
|
+
}
|
|
592
|
+
if (pages.length > MAX_PRIVATE_PAGES) {
|
|
593
|
+
return refusal('private_page_limit', path, `${at} can have at most ${MAX_PRIVATE_PAGES} private pages`);
|
|
594
|
+
}
|
|
595
|
+
const seen = new Set();
|
|
596
|
+
for (const [index, page] of pages.entries()) {
|
|
597
|
+
const pageAt = `${path}.${index}`;
|
|
598
|
+
if (page === null || typeof page !== 'object') {
|
|
599
|
+
return refusal('private_page_invalid', pageAt, `${at} private page ${index + 1} must be an object`);
|
|
600
|
+
}
|
|
601
|
+
const pagePath = page.path;
|
|
602
|
+
if (typeof pagePath !== 'string' ||
|
|
603
|
+
pagePath.length > 200 ||
|
|
604
|
+
!PRIVATE_PAGE_PATH.test(pagePath) ||
|
|
605
|
+
pagePath.split('/').some((part) => part === '.' || part === '..')) {
|
|
606
|
+
return refusal('private_page_path_invalid', `${pageAt}.path`, `${at} private page path '${String(pagePath).slice(0, 80)}' must start with '/', hold only letters, digits and . _ ~ - /, have no query or trailing slash, and be more than '/' (200 characters at most)`);
|
|
607
|
+
}
|
|
608
|
+
if (seen.has(pagePath)) {
|
|
609
|
+
return refusal('private_page_duplicate', `${pageAt}.path`, `${at} private page '${pagePath}' is listed more than once`);
|
|
610
|
+
}
|
|
611
|
+
seen.add(pagePath);
|
|
612
|
+
const { roles } = page;
|
|
613
|
+
if (!Array.isArray(roles) ||
|
|
614
|
+
roles.length > MAX_PRIVATE_PAGE_ROLES ||
|
|
615
|
+
roles.some((role) => typeof role !== 'string' || role === '' || role.length > 100) ||
|
|
616
|
+
new Set(roles).size !== roles.length) {
|
|
617
|
+
return refusal('private_page_roles_invalid', `${pageAt}.roles`, `${at} private page '${pagePath}' roles must be a list of at most ${MAX_PRIVATE_PAGE_ROLES} distinct non-empty role ids`);
|
|
618
|
+
}
|
|
619
|
+
const { people } = page;
|
|
620
|
+
if (people !== undefined &&
|
|
621
|
+
(!Array.isArray(people) ||
|
|
622
|
+
people.length > MAX_PRIVATE_PAGE_PEOPLE ||
|
|
623
|
+
people.some((email) => typeof email !== 'string' || email.length > 254 || !OWNER_EMAIL_PATTERN.test(email)))) {
|
|
624
|
+
return refusal('private_page_people_invalid', `${pageAt}.people`, `${at} private page '${pagePath}' people must be a list of at most ${MAX_PRIVATE_PAGE_PEOPLE} valid email addresses`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
function validateHostingEntry(entry, enabled, reserveKeys, github) {
|
|
433
630
|
const at = `hosting.entries '${entry.key}'`;
|
|
434
631
|
if (!ENTRY_KEY.test(entry.key)) {
|
|
435
632
|
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)`);
|
|
436
633
|
}
|
|
437
634
|
if (reserveKeys && RESERVED_ENTRY_KEY.test(entry.key)) {
|
|
438
|
-
return refusal('entry_key_reserved', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is reserved: stage, blue, green and lb, and any key ending in -stage, -blue, -green or -lb, name the parts blue/green adds to an app`);
|
|
635
|
+
return refusal('entry_key_reserved', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is reserved: apps-db (the company's apps database route), stage, blue, green and lb, and any key ending in -stage, -blue, -green or -lb, name the parts blue/green adds to an app`);
|
|
439
636
|
}
|
|
440
|
-
if (entry.repo !== null &&
|
|
441
|
-
|
|
637
|
+
if (entry.repo !== null &&
|
|
638
|
+
!REPO.test(entry.repo) &&
|
|
639
|
+
!(github && repoUnderAccount(entry.repo, github.account))) {
|
|
640
|
+
return refusal('entry_repo_invalid', `hosting.entries.${entry.key}.repo`, github
|
|
641
|
+
? `${at} repo must be a repo under wtfalch or under ${github.account}, or null`
|
|
642
|
+
: `${at} repo must be a repo under wtfalch, or null`);
|
|
442
643
|
}
|
|
443
644
|
if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
|
|
444
645
|
return refusal('entry_folder_invalid', `hosting.entries.${entry.key}.folder`, `${at} folder must be a relative path inside the repo`);
|
|
@@ -473,6 +674,12 @@ function validateHostingEntry(entry, enabled, reserveKeys) {
|
|
|
473
674
|
const invalidBlueGreen = refuseBlueGreen(entry.blueGreen, at, `hosting.entries.${entry.key}.blueGreen`);
|
|
474
675
|
if (invalidBlueGreen)
|
|
475
676
|
return invalidBlueGreen;
|
|
677
|
+
const invalidWorkspace = validateEntryWorkspace(entry, at, enabled);
|
|
678
|
+
if (invalidWorkspace)
|
|
679
|
+
return invalidWorkspace;
|
|
680
|
+
const invalidPrivatePages = validateEntryPrivatePages(entry, at, enabled);
|
|
681
|
+
if (invalidPrivatePages)
|
|
682
|
+
return invalidPrivatePages;
|
|
476
683
|
if (entry.blueGreen !== undefined &&
|
|
477
684
|
!isMainEntry(entry) &&
|
|
478
685
|
!entry.hostnames.some((name) => name !== '*')) {
|
|
@@ -521,7 +728,7 @@ function refuseBlueGreen(value, at, path) {
|
|
|
521
728
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
522
729
|
return refusal('blue_green_not_object', path, `${at} blueGreen must be an object`);
|
|
523
730
|
}
|
|
524
|
-
const { steps, stepHoldMinutes } = value;
|
|
731
|
+
const { steps, stepHoldMinutes, advance, minRequests, errorMargin, staffFirst } = value;
|
|
525
732
|
if (steps !== undefined) {
|
|
526
733
|
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 20) {
|
|
527
734
|
return refusal('blue_green_steps_length', `${path}.steps`, `${at} blueGreen steps must be a list of 1 to 20 percentages`);
|
|
@@ -539,11 +746,33 @@ function refuseBlueGreen(value, at, path) {
|
|
|
539
746
|
!(Number.isInteger(stepHoldMinutes) && stepHoldMinutes >= 1 && stepHoldMinutes <= 1440)) {
|
|
540
747
|
return refusal('blue_green_hold_invalid', `${path}.stepHoldMinutes`, `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440`);
|
|
541
748
|
}
|
|
749
|
+
if (advance !== undefined &&
|
|
750
|
+
advance !== 'time' &&
|
|
751
|
+
advance !== 'signal' &&
|
|
752
|
+
advance !== 'approval') {
|
|
753
|
+
return refusal('blue_green_advance_invalid', `${path}.advance`, `${at} blueGreen advance must be time, signal or approval`);
|
|
754
|
+
}
|
|
755
|
+
if (minRequests !== undefined &&
|
|
756
|
+
!(Number.isInteger(minRequests) && minRequests >= 1 && minRequests <= 100000)) {
|
|
757
|
+
return refusal('blue_green_min_requests_invalid', `${path}.minRequests`, `${at} blueGreen minRequests must be a whole number from 1 to 100000`);
|
|
758
|
+
}
|
|
759
|
+
if (errorMargin !== undefined &&
|
|
760
|
+
!(typeof errorMargin === 'number' && errorMargin >= 0 && errorMargin <= 1)) {
|
|
761
|
+
return refusal('blue_green_error_margin_invalid', `${path}.errorMargin`, `${at} blueGreen errorMargin must be a number from 0 to 1`);
|
|
762
|
+
}
|
|
763
|
+
if (staffFirst !== undefined && typeof staffFirst !== 'boolean') {
|
|
764
|
+
return refusal('blue_green_staff_first_invalid', `${path}.staffFirst`, `${at} blueGreen staffFirst must be true or false`);
|
|
765
|
+
}
|
|
542
766
|
return null;
|
|
543
767
|
}
|
|
544
|
-
/**
|
|
768
|
+
/**
|
|
769
|
+
* {@link refuseBlueGreen} for the `blueGreen` of the company's Boule, Agora and kosmos; the same codes.
|
|
770
|
+
* Also refuses an `issuerApps` entry named `kosmos` while kosmos is on -- the same reservation
|
|
771
|
+
* {@link refuseHosting}'s own loop already gives Agora and Boule, but kosmos is opt-in, so it is checked here
|
|
772
|
+
* instead, the same way {@link validateSite} reserves `cms` only while `site.enabled` is true.
|
|
773
|
+
*/
|
|
545
774
|
export function refuseBuiltinBlueGreen(org) {
|
|
546
|
-
for (const app of ['boule', 'agora']) {
|
|
775
|
+
for (const app of ['boule', 'agora', 'kosmos']) {
|
|
547
776
|
const value = org[app];
|
|
548
777
|
if (value === undefined)
|
|
549
778
|
continue;
|
|
@@ -553,6 +782,21 @@ export function refuseBuiltinBlueGreen(org) {
|
|
|
553
782
|
const refused = refuseBlueGreen(value.blueGreen, app, `${app}.blueGreen`);
|
|
554
783
|
if (refused)
|
|
555
784
|
return refused;
|
|
785
|
+
const apps = value.apps;
|
|
786
|
+
if (apps !== undefined && (app !== 'boule' || typeof apps !== 'boolean')) {
|
|
787
|
+
return refusal('builtin_app_invalid', `${app}.apps`, app === 'boule'
|
|
788
|
+
? 'boule.apps must be true or false'
|
|
789
|
+
: `${app} has no apps switch: only the company's Boule runs apps`);
|
|
790
|
+
}
|
|
791
|
+
const enabled = value.enabled;
|
|
792
|
+
if (enabled !== undefined && (app !== 'kosmos' || typeof enabled !== 'boolean')) {
|
|
793
|
+
return refusal('builtin_app_invalid', `${app}.enabled`, app === 'kosmos'
|
|
794
|
+
? 'kosmos.enabled must be true or false'
|
|
795
|
+
: `${app} has no enabled switch: it is always on`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (kosmosOn(org) && org.hosting.issuerApps.some((app) => app.name === 'kosmos')) {
|
|
799
|
+
return refusal('issuer_app_name_reserved_kosmos', 'hosting.issuerApps.kosmos.name', "kosmos.enabled reserves the issuerApps name 'kosmos'");
|
|
556
800
|
}
|
|
557
801
|
return null;
|
|
558
802
|
}
|
|
@@ -643,19 +887,15 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
643
887
|
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'`);
|
|
644
888
|
}
|
|
645
889
|
const onboarding = auth.mcp.onboardingPath;
|
|
646
|
-
if (onboarding !== undefined &&
|
|
647
|
-
(typeof onboarding !== 'string' ||
|
|
648
|
-
!onboarding.startsWith('/') ||
|
|
649
|
-
onboarding.startsWith('//') ||
|
|
650
|
-
onboarding.includes('..') ||
|
|
651
|
-
onboarding.includes('?') ||
|
|
652
|
-
onboarding.includes('#') ||
|
|
653
|
-
onboarding.includes('\\') ||
|
|
654
|
-
/[\s\p{Cc}]/u.test(onboarding) ||
|
|
655
|
-
onboarding.length > ONBOARDING_PATH_MAX_LENGTH)) {
|
|
890
|
+
if (onboarding !== undefined && !isPagePath(onboarding)) {
|
|
656
891
|
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`);
|
|
657
892
|
}
|
|
658
893
|
}
|
|
894
|
+
if (auth.workspaces !== undefined) {
|
|
895
|
+
const refused = validateEntryWorkspaces(entry, auth.workspaces, at);
|
|
896
|
+
if (refused)
|
|
897
|
+
return refused;
|
|
898
|
+
}
|
|
659
899
|
const signIn = auth.signIn;
|
|
660
900
|
if (signIn !== undefined && signIn !== 'password' && signIn !== 'email-code') {
|
|
661
901
|
return refusal('auth_sign_in_invalid', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn must be 'password' or 'email-code'`);
|
|
@@ -675,14 +915,62 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
675
915
|
if (entry.key === 'agora' ||
|
|
676
916
|
entry.key === 'boule' ||
|
|
677
917
|
entry.key === 'cms' ||
|
|
918
|
+
entry.key === 'kosmos' ||
|
|
678
919
|
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
679
920
|
return refusal('client_name_taken', `hosting.entries.${entry.key}.key`, `${at} client name '${entry.key}' is already held by another ZITADEL client`);
|
|
680
921
|
}
|
|
922
|
+
// A server workspace signs in with its own client `<key>-work`.
|
|
923
|
+
const workspaceClient = `${entry.key}-work`;
|
|
924
|
+
if (entry.workspace !== undefined &&
|
|
925
|
+
entry.workerApp === undefined &&
|
|
926
|
+
(hosting.entries.some((e) => e.key === workspaceClient) ||
|
|
927
|
+
hosting.issuerApps.some((app) => app.name === workspaceClient))) {
|
|
928
|
+
return refusal('client_name_taken', `hosting.entries.${entry.key}.key`, `${at} workspace client name '${workspaceClient}' is already held by another entry or ZITADEL client`);
|
|
929
|
+
}
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* `uses.auth.workspaces`: customers only, a server entry (a Worker app has nowhere to keep the key), roles the
|
|
934
|
+
* broker accepts (1 to 20 distinct names, no `c_` prefix, `owner` only as the first, never `self_service`),
|
|
935
|
+
* a plain invite path, and no `env` entry that names a value it writes.
|
|
936
|
+
*/
|
|
937
|
+
function validateEntryWorkspaces(entry, workspaces, at) {
|
|
938
|
+
const path = `hosting.entries.${entry.key}.uses.auth.workspaces`;
|
|
939
|
+
if (entry.uses?.auth?.users !== 'customers') {
|
|
940
|
+
return refusal('workspaces_needs_customers', path, `${at} workspaces needs users 'customers': workspaces belong to an app's own customers`);
|
|
941
|
+
}
|
|
942
|
+
if (entry.workerApp !== undefined) {
|
|
943
|
+
return refusal('workspaces_needs_server', path, `${at} workspaces needs an app that runs as a server: a Worker app has nowhere to keep the workspaces key`);
|
|
944
|
+
}
|
|
945
|
+
const config = workspaces;
|
|
946
|
+
const roles = config?.roles;
|
|
947
|
+
if (!Array.isArray(roles) || roles.length < 1 || roles.length > WORKSPACE_ROLES_MAX) {
|
|
948
|
+
return refusal('workspaces_roles_invalid', `${path}.roles`, `${at} workspaces needs between 1 and ${WORKSPACE_ROLES_MAX} roles; the first is the owner role`);
|
|
949
|
+
}
|
|
950
|
+
const seen = new Set();
|
|
951
|
+
for (const [index, role] of roles.entries()) {
|
|
952
|
+
if (typeof role !== 'string' || !WORKSPACE_ROLE.test(role) || role.startsWith('c_')) {
|
|
953
|
+
return refusal('workspaces_roles_invalid', `${path}.roles`, `${at} workspaces role ${index + 1} must start with a lowercase letter and hold only lowercase letters, digits, '_' and '-' (at most 64 characters), and may not start with 'c_'`);
|
|
954
|
+
}
|
|
955
|
+
if (seen.has(role)) {
|
|
956
|
+
return refusal('workspaces_roles_invalid', `${path}.roles`, `${at} workspaces names the role '${role}' twice`);
|
|
957
|
+
}
|
|
958
|
+
if (role === 'self_service' || (role === 'owner' && index > 0)) {
|
|
959
|
+
return refusal('workspaces_role_reserved', `${path}.roles`, `${at} workspaces role '${role}' is reserved: only the first role may be called 'owner', and 'self_service' is never allowed`);
|
|
960
|
+
}
|
|
961
|
+
seen.add(role);
|
|
962
|
+
}
|
|
963
|
+
const invitePath = config?.invitePath;
|
|
964
|
+
if (!isPagePath(invitePath)) {
|
|
965
|
+
return refusal('workspaces_invite_path_invalid', `${path}.invitePath`, `${at} workspaces invitePath must be an absolute path that starts with '/', such as '/join', with no '..', '?', '#' or spaces, and at most ${ONBOARDING_PATH_MAX_LENGTH} characters`);
|
|
966
|
+
}
|
|
967
|
+
const named = Object.keys(entry.env ?? {}).find((name) => WORKSPACES_ENTRY_ENV_NAMES.includes(name));
|
|
968
|
+
if (named) {
|
|
969
|
+
return refusal('workspaces_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} workspaces writes ${named}; remove it from env`);
|
|
970
|
+
}
|
|
681
971
|
return null;
|
|
682
972
|
}
|
|
683
973
|
const ROOM_KEYS = [...Object.keys(ROOM_TEXT_MAX), 'steps'];
|
|
684
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: the point is to refuse them
|
|
685
|
-
const ROOM_UNSAFE_TEXT = /[\u0000-\u001f\u007f<>]/;
|
|
686
974
|
/** A fixed sentence when `uses.auth.room` is not usable, `null` when it is. The value is never in it. */
|
|
687
975
|
function roomProblem(room) {
|
|
688
976
|
if (typeof room !== 'object' || room === null || Array.isArray(room)) {
|
|
@@ -747,7 +1035,7 @@ function validateEntryAi(entry, hosting, domain) {
|
|
|
747
1035
|
}
|
|
748
1036
|
if (ai.webhookPath !== undefined) {
|
|
749
1037
|
if (typeof ai.webhookPath !== 'string' ||
|
|
750
|
-
|
|
1038
|
+
!AI_WEBHOOK_PATH.test(ai.webhookPath) ||
|
|
751
1039
|
ai.webhookPath.startsWith('//')) {
|
|
752
1040
|
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`);
|
|
753
1041
|
}
|
|
@@ -775,7 +1063,7 @@ function validateEntryFiles(entry, hosting, domain) {
|
|
|
775
1063
|
const names = (value) => Array.isArray(value) &&
|
|
776
1064
|
value.every((v) => typeof v === 'string' && v !== '') &&
|
|
777
1065
|
new Set(value).size === value.length;
|
|
778
|
-
if (!names(files.collections) || !files.collections.every((n) =>
|
|
1066
|
+
if (!names(files.collections) || !files.collections.every((n) => FILES_COLLECTION_NAME.test(n))) {
|
|
779
1067
|
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`);
|
|
780
1068
|
}
|
|
781
1069
|
if (files.permissions !== undefined) {
|
|
@@ -837,6 +1125,9 @@ function validateEntryWorker(entry, hosting, domain) {
|
|
|
837
1125
|
if (entry.repo === null) {
|
|
838
1126
|
return refusal('worker_needs_repo', `hosting.entries.${entry.key}.repo`, `${at} needs its own repo (repo: null is not a Worker source)`);
|
|
839
1127
|
}
|
|
1128
|
+
if (!REPO.test(entry.repo)) {
|
|
1129
|
+
return refusal('worker_needs_own_repo', `hosting.entries.${entry.key}.repo`, `${at} can only be built from a repo under wtfalch: worker is only for our own projects`);
|
|
1130
|
+
}
|
|
840
1131
|
if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
|
|
841
1132
|
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`);
|
|
842
1133
|
}
|
|
@@ -889,6 +1180,98 @@ function validateEntryEdge(entry, hosting, domain) {
|
|
|
889
1180
|
return refusal('edge_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
890
1181
|
return null;
|
|
891
1182
|
}
|
|
1183
|
+
/** The env names `uses.repo` writes; `env` may not name them. */
|
|
1184
|
+
export const REPO_ENTRY_ENV_NAMES = ['GIT_REPO_URL', 'GIT_SSH_KEY'];
|
|
1185
|
+
/**
|
|
1186
|
+
* `uses.repo` (F12): `{ access: 'read' | 'write' }` and nothing else, on an entry that has its own `repo` (a
|
|
1187
|
+
* Worker app has none to clone and is already refused for any use but `uses.auth`), and no `env` entry that
|
|
1188
|
+
* names a value it writes.
|
|
1189
|
+
*/
|
|
1190
|
+
function validateEntryRepoUse(entry) {
|
|
1191
|
+
const value = entry.uses?.repo;
|
|
1192
|
+
if (value === undefined)
|
|
1193
|
+
return null;
|
|
1194
|
+
const path = `hosting.entries.${entry.key}.uses.repo`;
|
|
1195
|
+
const at = `hosting.entries '${entry.key}' uses.repo`;
|
|
1196
|
+
const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1197
|
+
const access = isObject ? value.access : undefined;
|
|
1198
|
+
if (!isObject ||
|
|
1199
|
+
(access !== 'read' && access !== 'write') ||
|
|
1200
|
+
Object.keys(value).some((k) => k !== 'access')) {
|
|
1201
|
+
return refusal('repo_use_invalid', path, `${at} must be { access: 'read' } or { access: 'write' }: read lets the app clone its repo, write lets it push too`);
|
|
1202
|
+
}
|
|
1203
|
+
if (entry.repo === null) {
|
|
1204
|
+
return refusal('repo_use_needs_repo', path, `${at} gives the app access to its own repo, and this entry has none: set repo, or remove uses.repo`);
|
|
1205
|
+
}
|
|
1206
|
+
const named = Object.keys(entry.env ?? {}).find((name) => REPO_ENTRY_ENV_NAMES.includes(name));
|
|
1207
|
+
if (named) {
|
|
1208
|
+
return refusal('repo_use_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
1209
|
+
}
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
/** The env name `uses.customerGrants` writes; `env` may not name it. */
|
|
1213
|
+
export const CUSTOMER_GRANTS_ENTRY_ENV_NAMES = ['CUSTOMER_GRANTS_CREDENTIAL'];
|
|
1214
|
+
/**
|
|
1215
|
+
* `uses.customerGrants`: `true`, customers signing in (`uses.auth` with `users: 'customers'`, which already
|
|
1216
|
+
* needs hosting on and an exact hostname for the app), and no `env` entry that names the credential. A Worker
|
|
1217
|
+
* app is already refused for any use but `uses.auth` (`validateEntryWorkerApp`).
|
|
1218
|
+
*/
|
|
1219
|
+
function validateEntryCustomerGrants(entry) {
|
|
1220
|
+
const value = entry.uses?.customerGrants;
|
|
1221
|
+
if (value === undefined)
|
|
1222
|
+
return null;
|
|
1223
|
+
const path = `hosting.entries.${entry.key}.uses.customerGrants`;
|
|
1224
|
+
const at = `hosting.entries '${entry.key}' uses.customerGrants`;
|
|
1225
|
+
if (value !== true)
|
|
1226
|
+
return refusal('customer_grants_not_true', path, `${at} must be true`);
|
|
1227
|
+
if (entry.uses?.auth?.users !== 'customers') {
|
|
1228
|
+
return refusal('customer_grants_needs_customers', path, `${at} is for an app whose customers sign in: declare uses.auth with users 'customers', or remove it`);
|
|
1229
|
+
}
|
|
1230
|
+
const named = Object.keys(entry.env ?? {}).find((name) => CUSTOMER_GRANTS_ENTRY_ENV_NAMES.includes(name));
|
|
1231
|
+
if (named) {
|
|
1232
|
+
return refusal('customer_grants_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
1233
|
+
}
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
/** `uses.mail` and `uses.jobs`: `true`, `hosting.enabled`, and no `env` entry that names a value they write. */
|
|
1237
|
+
function validateEntryMailJobs(entry, hosting) {
|
|
1238
|
+
for (const [use, written, code] of [
|
|
1239
|
+
['mail', MAIL_ENTRY_ENV_NAMES, 'mail'],
|
|
1240
|
+
['jobs', JOBS_ENTRY_ENV_NAMES, 'jobs'],
|
|
1241
|
+
]) {
|
|
1242
|
+
const value = entry.uses?.[use];
|
|
1243
|
+
if (value === undefined)
|
|
1244
|
+
continue;
|
|
1245
|
+
const path = `hosting.entries.${entry.key}.uses.${use}`;
|
|
1246
|
+
const at = `hosting.entries '${entry.key}' uses.${use}`;
|
|
1247
|
+
if (value !== true)
|
|
1248
|
+
return refusal(`${code}_not_true`, path, `${at} must be true`);
|
|
1249
|
+
if (!hosting.enabled) {
|
|
1250
|
+
return refusal(`${code}_needs_hosting`, path, `${at} requires hosting.enabled`);
|
|
1251
|
+
}
|
|
1252
|
+
const named = Object.keys(entry.env ?? {}).find((name) => written.includes(name));
|
|
1253
|
+
if (named) {
|
|
1254
|
+
return refusal(`${code}_env_clash`, `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
/** `uses.audit`: `true`, `hosting.enabled`, and a Worker app with a database (the ledger lives in it). */
|
|
1260
|
+
function validateEntryAudit(entry, hosting) {
|
|
1261
|
+
const value = entry.uses?.audit;
|
|
1262
|
+
if (value === undefined)
|
|
1263
|
+
return null;
|
|
1264
|
+
const path = `hosting.entries.${entry.key}.uses.audit`;
|
|
1265
|
+
const at = `hosting.entries '${entry.key}' uses.audit`;
|
|
1266
|
+
if (value !== true)
|
|
1267
|
+
return refusal('audit_not_true', path, `${at} must be true`);
|
|
1268
|
+
if (!hosting.enabled)
|
|
1269
|
+
return refusal('audit_needs_hosting', path, `${at} requires hosting.enabled`);
|
|
1270
|
+
if (!entry.workerApp?.database) {
|
|
1271
|
+
return refusal('audit_needs_worker_database', path, `${at} requires a Worker app with a database: the ledger is kept in it`);
|
|
1272
|
+
}
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
892
1275
|
function urlHasPassword(value) {
|
|
893
1276
|
try {
|
|
894
1277
|
return new URL(value).password !== '';
|
|
@@ -925,6 +1308,8 @@ function urlHasPassword(value) {
|
|
|
925
1308
|
*/
|
|
926
1309
|
const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
927
1310
|
const SHARED_ZONE = 'oikos.host';
|
|
1311
|
+
/** The platform zone new app addresses (`<hostedName>.companyincluded.app`) and every workspace live in. */
|
|
1312
|
+
export const APPS_DOMAIN = 'companyincluded.app';
|
|
928
1313
|
/** The mail relay accepts only a letters-only top-level label (`plan/mail-relay.ts`); one domain it rejects would block the relay step for every company. */
|
|
929
1314
|
const RELAY_TLD = /^[a-z]{2,63}$/;
|
|
930
1315
|
export function validateDomain(domain) {
|
|
@@ -947,6 +1332,10 @@ export function validateDomain(domain) {
|
|
|
947
1332
|
if (domain === SHARED_ZONE || domain.endsWith(`.${SHARED_ZONE}`)) {
|
|
948
1333
|
return `domain '${domain}' is on ${SHARED_ZONE}, the shared zone for companies without a domain; a company cannot own it`;
|
|
949
1334
|
}
|
|
1335
|
+
// companyincluded.app holds the platform's hosted app addresses and every workspace.
|
|
1336
|
+
if (domain === APPS_DOMAIN || domain.endsWith(`.${APPS_DOMAIN}`)) {
|
|
1337
|
+
return `domain '${domain}' is on ${APPS_DOMAIN}, where the platform serves app addresses and workspaces; a company cannot own it`;
|
|
1338
|
+
}
|
|
950
1339
|
return null;
|
|
951
1340
|
}
|
|
952
1341
|
/**
|
|
@@ -1509,6 +1898,8 @@ function orgServiceSchema(service, schema, legacyKey, split = true) {
|
|
|
1509
1898
|
export const ORG_SERVICE_SCHEMAS = [
|
|
1510
1899
|
orgServiceSchema('Boule', 'boule', () => 'coolify_boule_database'),
|
|
1511
1900
|
orgServiceSchema('CMS', 'cms', () => 'coolify_cms_database'),
|
|
1901
|
+
// kosmos, like CMS, sits behind its own feature switch (`KosmosApp.enabled`); see `orgServiceSchemaEnabled`.
|
|
1902
|
+
orgServiceSchema('kosmos', 'kosmos', () => 'coolify_kosmos_database'),
|
|
1512
1903
|
orgServiceSchema("chat's Synapse homeserver", 'chat_synapse', () => 'coolify_chat_synapse_database', false),
|
|
1513
1904
|
orgServiceSchema("chat's matrix-authentication-service", 'chat_mas', () => 'coolify_chat_mas_database', false),
|
|
1514
1905
|
orgServiceSchema("chat's own service", 'chat_service', () => 'coolify_chat_service_database'),
|
|
@@ -1542,6 +1933,10 @@ export function orgServiceDatabase(org, home) {
|
|
|
1542
1933
|
database = underscored(`${org.slug}-cms-db`);
|
|
1543
1934
|
runtimeRole = 'cms_rt';
|
|
1544
1935
|
break;
|
|
1936
|
+
case 'kosmos':
|
|
1937
|
+
database = underscored(`${org.slug}-kosmos-db`);
|
|
1938
|
+
runtimeRole = 'kosmos_rt';
|
|
1939
|
+
break;
|
|
1545
1940
|
case 'chat_service':
|
|
1546
1941
|
database = 'chat';
|
|
1547
1942
|
break;
|