@wtfalch/foundry 0.60.0 → 0.61.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 +10 -5
- package/dist/index.js +5 -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/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/org.d.ts +163 -22
- package/dist/org.js +419 -47
- 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 +41 -1
- package/dist/plan/blue-green.js +80 -2
- 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/desktop-callback.js +12 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +373 -42
- package/dist/plan/edge-access.d.ts +6 -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 +191 -1
- package/dist/plan/executor.js +1505 -90
- 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/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +2 -2
- 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.js +12 -1
- 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-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 +7 -2
- package/dist/plan/post-logout-uris.js +7 -1
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- 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/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 +11 -0
- 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 +10 -6
- package/dist/store/postgres.js +12 -2
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +4 -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 +18 -1
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,27 @@ 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;
|
|
16
36
|
/** The most volumes one entry may declare. */
|
|
17
37
|
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'];
|
|
38
|
+
/** The env names `uses.auth.workspaces` writes; `env` may not name them. */
|
|
39
|
+
export const WORKSPACES_ENTRY_ENV_NAMES = ['WORKSPACES_URL', 'WORKSPACES_API_KEY'];
|
|
34
40
|
/** The env names `uses.ai` writes; `env` may not name them. */
|
|
35
41
|
export const AI_ENTRY_ENV_NAMES = [
|
|
36
42
|
'AI_URL',
|
|
@@ -39,15 +45,6 @@ export const AI_ENTRY_ENV_NAMES = [
|
|
|
39
45
|
'AI_WEBHOOK_SECRET',
|
|
40
46
|
'AI_OFFERINGS_JSON',
|
|
41
47
|
];
|
|
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
48
|
/** The env names `uses.files` always writes, besides one `FILES_COLLECTION_<NAME>_ID` per collection. */
|
|
52
49
|
export const FILES_ENTRY_ENV_NAMES = [
|
|
53
50
|
'FILES_URL',
|
|
@@ -57,6 +54,14 @@ export const FILES_ENTRY_ENV_NAMES = [
|
|
|
57
54
|
export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
58
55
|
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
59
56
|
export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
|
|
57
|
+
/** The env names `uses.mail` writes; `env` may not name them. */
|
|
58
|
+
export const MAIL_ENTRY_ENV_NAMES = ['LETTERMINT_API_TOKEN', 'MAIL_FROM'];
|
|
59
|
+
/** The env names `uses.jobs` writes; `env` may not name them. */
|
|
60
|
+
export const JOBS_ENTRY_ENV_NAMES = [
|
|
61
|
+
'JOBS_SCHEDULER_URL',
|
|
62
|
+
'JOBS_SIGNING_SECRET',
|
|
63
|
+
'JOBS_SYNC_KEY',
|
|
64
|
+
];
|
|
60
65
|
/** True for the entry marked `main`, and for an entry keyed {@link APP_ENTRY_KEY} that leaves `main` unset. */
|
|
61
66
|
export function isMainEntry(entry) {
|
|
62
67
|
return entry.main ?? entry.key === APP_ENTRY_KEY;
|
|
@@ -161,6 +166,9 @@ export function refuseHosting(hosting, names) {
|
|
|
161
166
|
const badRepos = refuseHostingRepos(hosting.repos);
|
|
162
167
|
if (badRepos)
|
|
163
168
|
return badRepos;
|
|
169
|
+
const badGithub = refuseHostingGithub(hosting.github);
|
|
170
|
+
if (badGithub)
|
|
171
|
+
return badGithub;
|
|
164
172
|
if (hosting.tunnel && !hosting.enabled) {
|
|
165
173
|
return refusal('hosting_tunnel_needs_hosting', 'hosting.tunnel', 'hosting.tunnel requires hosting.enabled');
|
|
166
174
|
}
|
|
@@ -192,16 +200,31 @@ export function refuseHosting(hosting, names) {
|
|
|
192
200
|
if (hosting.enabled && hosting.entries.length === 0) {
|
|
193
201
|
return refusal('hosting_needs_entry', 'hosting.entries', 'hosting.enabled requires at least one entry');
|
|
194
202
|
}
|
|
195
|
-
const reserveKeys = names?.builtinBlueGreen === true ||
|
|
203
|
+
const reserveKeys = names?.builtinBlueGreen === true ||
|
|
204
|
+
hosting.entries.some((e) => e.blueGreen !== undefined || e.workspace !== undefined);
|
|
196
205
|
const seenKeys = new Set();
|
|
206
|
+
const seenHostedNames = new Set();
|
|
197
207
|
for (const entry of hosting.entries) {
|
|
198
208
|
if (seenKeys.has(entry.key)) {
|
|
199
209
|
return refusal('entry_key_not_unique', `hosting.entries.${entry.key}.key`, `hosting.entries key '${entry.key}' is not unique`);
|
|
200
210
|
}
|
|
201
211
|
seenKeys.add(entry.key);
|
|
202
|
-
const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys);
|
|
212
|
+
const invalid = validateHostingEntry(entry, hosting.enabled, reserveKeys, hosting.github);
|
|
203
213
|
if (invalid)
|
|
204
214
|
return invalid;
|
|
215
|
+
if (entry.hostedName !== undefined) {
|
|
216
|
+
const path = `hosting.entries.${entry.key}.hostedName`;
|
|
217
|
+
const bad = hostedLabelRefusal(entry.hostedName);
|
|
218
|
+
if (bad)
|
|
219
|
+
return refusal('hosted_name_invalid', path, bad.message);
|
|
220
|
+
if (entry.workerApp) {
|
|
221
|
+
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.');
|
|
222
|
+
}
|
|
223
|
+
if (seenHostedNames.has(entry.hostedName)) {
|
|
224
|
+
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.');
|
|
225
|
+
}
|
|
226
|
+
seenHostedNames.add(entry.hostedName);
|
|
227
|
+
}
|
|
205
228
|
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
206
229
|
if (invalidAuth)
|
|
207
230
|
return invalidAuth;
|
|
@@ -221,6 +244,18 @@ export function refuseHosting(hosting, names) {
|
|
|
221
244
|
const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
|
|
222
245
|
if (invalidEdgeUse)
|
|
223
246
|
return invalidEdgeUse;
|
|
247
|
+
const invalidMailJobs = validateEntryMailJobs(entry, hosting);
|
|
248
|
+
if (invalidMailJobs)
|
|
249
|
+
return invalidMailJobs;
|
|
250
|
+
const invalidGrants = validateEntryCustomerGrants(entry);
|
|
251
|
+
if (invalidGrants)
|
|
252
|
+
return invalidGrants;
|
|
253
|
+
const invalidRepoUse = validateEntryRepoUse(entry);
|
|
254
|
+
if (invalidRepoUse)
|
|
255
|
+
return invalidRepoUse;
|
|
256
|
+
const invalidAuditUse = validateEntryAudit(entry, hosting);
|
|
257
|
+
if (invalidAuditUse)
|
|
258
|
+
return invalidAuditUse;
|
|
224
259
|
}
|
|
225
260
|
const mains = hosting.entries.filter(isMainEntry);
|
|
226
261
|
if (mains.length > 1) {
|
|
@@ -317,6 +352,14 @@ function validateGates(hosting, domain, locked) {
|
|
|
317
352
|
return refusal('gate_limit_invalid', 'hosting.gateLimit', 'hosting.gateLimit must be a positive whole number');
|
|
318
353
|
}
|
|
319
354
|
const seenKeys = new Set();
|
|
355
|
+
const workspaceGateKeys = new Set();
|
|
356
|
+
for (const entry of hosting.entries) {
|
|
357
|
+
if (entry.workspace === undefined)
|
|
358
|
+
continue;
|
|
359
|
+
workspaceGateKeys.add(`ws-${entry.key}`);
|
|
360
|
+
if (entry.workerApp === undefined)
|
|
361
|
+
workspaceGateKeys.add('ws--inbox');
|
|
362
|
+
}
|
|
320
363
|
for (const entry of hosting.entries) {
|
|
321
364
|
const taken = new Set();
|
|
322
365
|
for (const gate of entry.gates) {
|
|
@@ -327,6 +370,12 @@ function validateGates(hosting, domain, locked) {
|
|
|
327
370
|
if (!ENTRY_KEY.test(gate.key)) {
|
|
328
371
|
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
372
|
}
|
|
373
|
+
if (workspaceGateKeys.has(gate.key)) {
|
|
374
|
+
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.`);
|
|
375
|
+
}
|
|
376
|
+
if (PRIVATE_PAGE_GATE_KEY.test(gate.key)) {
|
|
377
|
+
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.`);
|
|
378
|
+
}
|
|
330
379
|
if (seenKeys.has(gate.key))
|
|
331
380
|
return refusal('gate_key_not_unique', `hosting.entries.${entry.key}.gates.${gate.key}.key`, `${at} key is used by two gates`);
|
|
332
381
|
seenKeys.add(gate.key);
|
|
@@ -400,7 +449,7 @@ function validateEntryHostnames(hosting, names) {
|
|
|
400
449
|
}
|
|
401
450
|
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
402
451
|
/** 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)$/;
|
|
452
|
+
const RESERVED_ENTRY_KEY = /^(stage|blue|green|lb|apps-db)$|-(stage|blue|green|lb)$/;
|
|
404
453
|
const VOLUME_MOUNT_PATH = /^(\/(?!\.\.?(\/|$))[A-Za-z0-9._-]+)+$/;
|
|
405
454
|
/** The log volume's mount path; `executor.ts`'s `LOG_DIR` is the same value. */
|
|
406
455
|
const LOG_DIR_PATH = '/app/logs';
|
|
@@ -422,6 +471,46 @@ const VOLUME_FORBIDDEN_ROOTS = [
|
|
|
422
471
|
const VOLUME_MOUNT_PATH_MAX = 255;
|
|
423
472
|
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
424
473
|
const HOSTING_REPO = /^wtfalch\/[A-Za-z0-9._-]{1,100}$/;
|
|
474
|
+
const GITHUB_ACCOUNT = /^[A-Za-z0-9-]{1,39}$/;
|
|
475
|
+
const REPO_NAME = /^[A-Za-z0-9._-]+$/;
|
|
476
|
+
/** The account whose repos the platform's own source builds; a company cannot claim it as its own. */
|
|
477
|
+
const PLATFORM_GITHUB_ACCOUNT = 'wtfalch';
|
|
478
|
+
/** 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. */
|
|
479
|
+
function refuseHostingGithub(github) {
|
|
480
|
+
const invalid = (message) => refusal('hosting_github_invalid', 'hosting.github', message);
|
|
481
|
+
if (github === undefined)
|
|
482
|
+
return null;
|
|
483
|
+
if (typeof github !== 'object' || github === null)
|
|
484
|
+
return invalid('hosting.github must be an object');
|
|
485
|
+
const { installationId, account } = github;
|
|
486
|
+
if (typeof installationId !== 'number' ||
|
|
487
|
+
!Number.isSafeInteger(installationId) ||
|
|
488
|
+
installationId <= 0) {
|
|
489
|
+
return invalid('hosting.github.installationId must be a positive integer');
|
|
490
|
+
}
|
|
491
|
+
if (typeof account !== 'string' || !GITHUB_ACCOUNT.test(account)) {
|
|
492
|
+
return invalid('hosting.github.account must be a GitHub login: 1-39 letters, digits or hyphens');
|
|
493
|
+
}
|
|
494
|
+
if (account.toLowerCase() === PLATFORM_GITHUB_ACCOUNT) {
|
|
495
|
+
return invalid(`hosting.github.account cannot be ${PLATFORM_GITHUB_ACCOUNT}, the platform's own account`);
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
/** True when `repo` is `<account>/<name>` with the account compared ignoring case. */
|
|
500
|
+
function repoUnderAccount(repo, account) {
|
|
501
|
+
const slash = repo.indexOf('/');
|
|
502
|
+
return (slash > 0 &&
|
|
503
|
+
repo.slice(0, slash).toLowerCase() === account.toLowerCase() &&
|
|
504
|
+
REPO_NAME.test(repo.slice(slash + 1)));
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The company's GitHub config when `repo` is under its account, else `undefined`: such an entry is built
|
|
508
|
+
* through the company's own Coolify source, any other through the platform's. Read it only through this.
|
|
509
|
+
*/
|
|
510
|
+
export function companyGithubFor(hosting, repo) {
|
|
511
|
+
const github = hosting.github;
|
|
512
|
+
return github && repo !== null && repoUnderAccount(repo, github.account) ? github : undefined;
|
|
513
|
+
}
|
|
425
514
|
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
426
515
|
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
427
516
|
// Set only on the colors of a blueGreen entry; any other entry may declare them.
|
|
@@ -429,16 +518,126 @@ const BLUE_GREEN_ENV_NAME = /^(REPORTING_(COLOR|RELEASE|HEALTH_TOKEN)|NEXT_SERVE
|
|
|
429
518
|
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
430
519
|
const HELD_SECRETS = "declare it as { from: 'heldSecret' }, not a plain value";
|
|
431
520
|
/** The rules for one {@link HostingEntry}; `null` when it is valid. */
|
|
432
|
-
|
|
521
|
+
/** Why an entry's `workspace` and `live` are refused, or `null`. */
|
|
522
|
+
function validateEntryWorkspace(entry, at, hostingEnabled) {
|
|
523
|
+
const path = `hosting.entries.${entry.key}`;
|
|
524
|
+
if (entry.live !== undefined && typeof entry.live !== 'boolean') {
|
|
525
|
+
return refusal('entry_workspace_invalid', `${path}.live`, `${at} live must be true or false`);
|
|
526
|
+
}
|
|
527
|
+
if (entry.liveCommit !== undefined) {
|
|
528
|
+
const commitAt = `${path}.liveCommit`;
|
|
529
|
+
if (typeof entry.liveCommit !== 'string' || !FULL_COMMIT_SHA.test(entry.liveCommit)) {
|
|
530
|
+
return refusal('entry_live_commit_invalid', commitAt, `${at} liveCommit must be a full commit: 40 lowercase letters a-f and digits`);
|
|
531
|
+
}
|
|
532
|
+
if (entry.workspace === undefined || entry.workerApp !== undefined) {
|
|
533
|
+
return refusal('entry_live_commit_invalid', commitAt, `${at} liveCommit needs a workspace and an app that runs as a server`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (entry.workspace === undefined) {
|
|
537
|
+
return entry.live === false
|
|
538
|
+
? refusal('entry_live_needs_workspace', `${path}.live`, `${at} live false needs a workspace: with neither, the app would exist nowhere`)
|
|
539
|
+
: null;
|
|
540
|
+
}
|
|
541
|
+
const unsupported = (message) => refusal('entry_workspace_unsupported', `${path}.workspace`, `${at} ${message}`);
|
|
542
|
+
if (!hostingEnabled)
|
|
543
|
+
return unsupported('workspace requires hosting.enabled');
|
|
544
|
+
if (entry.blueGreen !== undefined) {
|
|
545
|
+
return unsupported('workspace cannot be combined with blueGreen: the workspace is the stage, and an entry has one');
|
|
546
|
+
}
|
|
547
|
+
if (entry.worker !== undefined) {
|
|
548
|
+
return unsupported('workspace cannot be combined with a plain Worker');
|
|
549
|
+
}
|
|
550
|
+
if (entry.workerApp?.database !== undefined) {
|
|
551
|
+
return unsupported('workspace cannot be combined with a Worker app that has a database: its workspace would have to share the live one');
|
|
552
|
+
}
|
|
553
|
+
if (entry.repo === null && entry.workerApp === undefined) {
|
|
554
|
+
return unsupported('workspace needs a repo or a Worker app to run');
|
|
555
|
+
}
|
|
556
|
+
const { testers } = entry.workspace;
|
|
557
|
+
if (!Array.isArray(testers)) {
|
|
558
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace testers must be a list of email addresses`);
|
|
559
|
+
}
|
|
560
|
+
if (testers.length > MAX_WORKSPACE_TESTERS) {
|
|
561
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace can have at most ${MAX_WORKSPACE_TESTERS} testers`);
|
|
562
|
+
}
|
|
563
|
+
const seen = new Set();
|
|
564
|
+
for (const email of testers) {
|
|
565
|
+
if (typeof email !== 'string' || email.length > 254 || !OWNER_EMAIL_PATTERN.test(email)) {
|
|
566
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace tester '${String(email).slice(0, 80)}' is not a valid email address`);
|
|
567
|
+
}
|
|
568
|
+
const key = email.toLowerCase();
|
|
569
|
+
if (seen.has(key)) {
|
|
570
|
+
return refusal('entry_workspace_invalid', `${path}.workspace.testers`, `${at} workspace tester '${email}' is listed more than once`);
|
|
571
|
+
}
|
|
572
|
+
seen.add(key);
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
/** Why an entry's `privatePages` are refused, or `null`. */
|
|
577
|
+
function validateEntryPrivatePages(entry, at, hostingEnabled) {
|
|
578
|
+
const pages = entry.privatePages;
|
|
579
|
+
if (pages === undefined)
|
|
580
|
+
return null;
|
|
581
|
+
const path = `hosting.entries.${entry.key}.privatePages`;
|
|
582
|
+
if (!Array.isArray(pages)) {
|
|
583
|
+
return refusal('private_page_invalid', path, `${at} privatePages must be a list`);
|
|
584
|
+
}
|
|
585
|
+
if (pages.length === 0)
|
|
586
|
+
return null;
|
|
587
|
+
if (!hostingEnabled) {
|
|
588
|
+
return refusal('private_page_needs_hosting', path, `${at} private pages need hosting.enabled: there is no address to protect`);
|
|
589
|
+
}
|
|
590
|
+
if (pages.length > MAX_PRIVATE_PAGES) {
|
|
591
|
+
return refusal('private_page_limit', path, `${at} can have at most ${MAX_PRIVATE_PAGES} private pages`);
|
|
592
|
+
}
|
|
593
|
+
const seen = new Set();
|
|
594
|
+
for (const [index, page] of pages.entries()) {
|
|
595
|
+
const pageAt = `${path}.${index}`;
|
|
596
|
+
if (page === null || typeof page !== 'object') {
|
|
597
|
+
return refusal('private_page_invalid', pageAt, `${at} private page ${index + 1} must be an object`);
|
|
598
|
+
}
|
|
599
|
+
const pagePath = page.path;
|
|
600
|
+
if (typeof pagePath !== 'string' ||
|
|
601
|
+
pagePath.length > 200 ||
|
|
602
|
+
!PRIVATE_PAGE_PATH.test(pagePath) ||
|
|
603
|
+
pagePath.split('/').some((part) => part === '.' || part === '..')) {
|
|
604
|
+
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)`);
|
|
605
|
+
}
|
|
606
|
+
if (seen.has(pagePath)) {
|
|
607
|
+
return refusal('private_page_duplicate', `${pageAt}.path`, `${at} private page '${pagePath}' is listed more than once`);
|
|
608
|
+
}
|
|
609
|
+
seen.add(pagePath);
|
|
610
|
+
const { roles } = page;
|
|
611
|
+
if (!Array.isArray(roles) ||
|
|
612
|
+
roles.length > MAX_PRIVATE_PAGE_ROLES ||
|
|
613
|
+
roles.some((role) => typeof role !== 'string' || role === '' || role.length > 100) ||
|
|
614
|
+
new Set(roles).size !== roles.length) {
|
|
615
|
+
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`);
|
|
616
|
+
}
|
|
617
|
+
const { people } = page;
|
|
618
|
+
if (people !== undefined &&
|
|
619
|
+
(!Array.isArray(people) ||
|
|
620
|
+
people.length > MAX_PRIVATE_PAGE_PEOPLE ||
|
|
621
|
+
people.some((email) => typeof email !== 'string' || email.length > 254 || !OWNER_EMAIL_PATTERN.test(email)))) {
|
|
622
|
+
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`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
function validateHostingEntry(entry, enabled, reserveKeys, github) {
|
|
433
628
|
const at = `hosting.entries '${entry.key}'`;
|
|
434
629
|
if (!ENTRY_KEY.test(entry.key)) {
|
|
435
630
|
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
631
|
}
|
|
437
632
|
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`);
|
|
633
|
+
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
634
|
}
|
|
440
|
-
if (entry.repo !== null &&
|
|
441
|
-
|
|
635
|
+
if (entry.repo !== null &&
|
|
636
|
+
!REPO.test(entry.repo) &&
|
|
637
|
+
!(github && repoUnderAccount(entry.repo, github.account))) {
|
|
638
|
+
return refusal('entry_repo_invalid', `hosting.entries.${entry.key}.repo`, github
|
|
639
|
+
? `${at} repo must be a repo under wtfalch or under ${github.account}, or null`
|
|
640
|
+
: `${at} repo must be a repo under wtfalch, or null`);
|
|
442
641
|
}
|
|
443
642
|
if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
|
|
444
643
|
return refusal('entry_folder_invalid', `hosting.entries.${entry.key}.folder`, `${at} folder must be a relative path inside the repo`);
|
|
@@ -473,6 +672,12 @@ function validateHostingEntry(entry, enabled, reserveKeys) {
|
|
|
473
672
|
const invalidBlueGreen = refuseBlueGreen(entry.blueGreen, at, `hosting.entries.${entry.key}.blueGreen`);
|
|
474
673
|
if (invalidBlueGreen)
|
|
475
674
|
return invalidBlueGreen;
|
|
675
|
+
const invalidWorkspace = validateEntryWorkspace(entry, at, enabled);
|
|
676
|
+
if (invalidWorkspace)
|
|
677
|
+
return invalidWorkspace;
|
|
678
|
+
const invalidPrivatePages = validateEntryPrivatePages(entry, at, enabled);
|
|
679
|
+
if (invalidPrivatePages)
|
|
680
|
+
return invalidPrivatePages;
|
|
476
681
|
if (entry.blueGreen !== undefined &&
|
|
477
682
|
!isMainEntry(entry) &&
|
|
478
683
|
!entry.hostnames.some((name) => name !== '*')) {
|
|
@@ -521,7 +726,7 @@ function refuseBlueGreen(value, at, path) {
|
|
|
521
726
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
522
727
|
return refusal('blue_green_not_object', path, `${at} blueGreen must be an object`);
|
|
523
728
|
}
|
|
524
|
-
const { steps, stepHoldMinutes } = value;
|
|
729
|
+
const { steps, stepHoldMinutes, advance, minRequests, errorMargin, staffFirst } = value;
|
|
525
730
|
if (steps !== undefined) {
|
|
526
731
|
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 20) {
|
|
527
732
|
return refusal('blue_green_steps_length', `${path}.steps`, `${at} blueGreen steps must be a list of 1 to 20 percentages`);
|
|
@@ -539,6 +744,23 @@ function refuseBlueGreen(value, at, path) {
|
|
|
539
744
|
!(Number.isInteger(stepHoldMinutes) && stepHoldMinutes >= 1 && stepHoldMinutes <= 1440)) {
|
|
540
745
|
return refusal('blue_green_hold_invalid', `${path}.stepHoldMinutes`, `${at} blueGreen stepHoldMinutes must be a whole number from 1 to 1440`);
|
|
541
746
|
}
|
|
747
|
+
if (advance !== undefined &&
|
|
748
|
+
advance !== 'time' &&
|
|
749
|
+
advance !== 'signal' &&
|
|
750
|
+
advance !== 'approval') {
|
|
751
|
+
return refusal('blue_green_advance_invalid', `${path}.advance`, `${at} blueGreen advance must be time, signal or approval`);
|
|
752
|
+
}
|
|
753
|
+
if (minRequests !== undefined &&
|
|
754
|
+
!(Number.isInteger(minRequests) && minRequests >= 1 && minRequests <= 100000)) {
|
|
755
|
+
return refusal('blue_green_min_requests_invalid', `${path}.minRequests`, `${at} blueGreen minRequests must be a whole number from 1 to 100000`);
|
|
756
|
+
}
|
|
757
|
+
if (errorMargin !== undefined &&
|
|
758
|
+
!(typeof errorMargin === 'number' && errorMargin >= 0 && errorMargin <= 1)) {
|
|
759
|
+
return refusal('blue_green_error_margin_invalid', `${path}.errorMargin`, `${at} blueGreen errorMargin must be a number from 0 to 1`);
|
|
760
|
+
}
|
|
761
|
+
if (staffFirst !== undefined && typeof staffFirst !== 'boolean') {
|
|
762
|
+
return refusal('blue_green_staff_first_invalid', `${path}.staffFirst`, `${at} blueGreen staffFirst must be true or false`);
|
|
763
|
+
}
|
|
542
764
|
return null;
|
|
543
765
|
}
|
|
544
766
|
/** {@link refuseBlueGreen} for the `blueGreen` of the company's Boule and Agora; the same codes. */
|
|
@@ -553,6 +775,12 @@ export function refuseBuiltinBlueGreen(org) {
|
|
|
553
775
|
const refused = refuseBlueGreen(value.blueGreen, app, `${app}.blueGreen`);
|
|
554
776
|
if (refused)
|
|
555
777
|
return refused;
|
|
778
|
+
const apps = value.apps;
|
|
779
|
+
if (apps !== undefined && (app !== 'boule' || typeof apps !== 'boolean')) {
|
|
780
|
+
return refusal('builtin_app_invalid', `${app}.apps`, app === 'boule'
|
|
781
|
+
? 'boule.apps must be true or false'
|
|
782
|
+
: `${app} has no apps switch: only the company's Boule runs apps`);
|
|
783
|
+
}
|
|
556
784
|
}
|
|
557
785
|
return null;
|
|
558
786
|
}
|
|
@@ -643,19 +871,15 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
643
871
|
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
872
|
}
|
|
645
873
|
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)) {
|
|
874
|
+
if (onboarding !== undefined && !isPagePath(onboarding)) {
|
|
656
875
|
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
876
|
}
|
|
658
877
|
}
|
|
878
|
+
if (auth.workspaces !== undefined) {
|
|
879
|
+
const refused = validateEntryWorkspaces(entry, auth.workspaces, at);
|
|
880
|
+
if (refused)
|
|
881
|
+
return refused;
|
|
882
|
+
}
|
|
659
883
|
const signIn = auth.signIn;
|
|
660
884
|
if (signIn !== undefined && signIn !== 'password' && signIn !== 'email-code') {
|
|
661
885
|
return refusal('auth_sign_in_invalid', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn must be 'password' or 'email-code'`);
|
|
@@ -678,11 +902,58 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
678
902
|
hosting.issuerApps.some((app) => app.name === entry.key)) {
|
|
679
903
|
return refusal('client_name_taken', `hosting.entries.${entry.key}.key`, `${at} client name '${entry.key}' is already held by another ZITADEL client`);
|
|
680
904
|
}
|
|
905
|
+
// A server workspace signs in with its own client `<key>-work`.
|
|
906
|
+
const workspaceClient = `${entry.key}-work`;
|
|
907
|
+
if (entry.workspace !== undefined &&
|
|
908
|
+
entry.workerApp === undefined &&
|
|
909
|
+
(hosting.entries.some((e) => e.key === workspaceClient) ||
|
|
910
|
+
hosting.issuerApps.some((app) => app.name === workspaceClient))) {
|
|
911
|
+
return refusal('client_name_taken', `hosting.entries.${entry.key}.key`, `${at} workspace client name '${workspaceClient}' is already held by another entry or ZITADEL client`);
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* `uses.auth.workspaces`: customers only, a server entry (a Worker app has nowhere to keep the key), roles the
|
|
917
|
+
* broker accepts (1 to 20 distinct names, no `c_` prefix, `owner` only as the first, never `self_service`),
|
|
918
|
+
* a plain invite path, and no `env` entry that names a value it writes.
|
|
919
|
+
*/
|
|
920
|
+
function validateEntryWorkspaces(entry, workspaces, at) {
|
|
921
|
+
const path = `hosting.entries.${entry.key}.uses.auth.workspaces`;
|
|
922
|
+
if (entry.uses?.auth?.users !== 'customers') {
|
|
923
|
+
return refusal('workspaces_needs_customers', path, `${at} workspaces needs users 'customers': workspaces belong to an app's own customers`);
|
|
924
|
+
}
|
|
925
|
+
if (entry.workerApp !== undefined) {
|
|
926
|
+
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`);
|
|
927
|
+
}
|
|
928
|
+
const config = workspaces;
|
|
929
|
+
const roles = config?.roles;
|
|
930
|
+
if (!Array.isArray(roles) || roles.length < 1 || roles.length > WORKSPACE_ROLES_MAX) {
|
|
931
|
+
return refusal('workspaces_roles_invalid', `${path}.roles`, `${at} workspaces needs between 1 and ${WORKSPACE_ROLES_MAX} roles; the first is the owner role`);
|
|
932
|
+
}
|
|
933
|
+
const seen = new Set();
|
|
934
|
+
for (const [index, role] of roles.entries()) {
|
|
935
|
+
if (typeof role !== 'string' || !WORKSPACE_ROLE.test(role) || role.startsWith('c_')) {
|
|
936
|
+
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_'`);
|
|
937
|
+
}
|
|
938
|
+
if (seen.has(role)) {
|
|
939
|
+
return refusal('workspaces_roles_invalid', `${path}.roles`, `${at} workspaces names the role '${role}' twice`);
|
|
940
|
+
}
|
|
941
|
+
if (role === 'self_service' || (role === 'owner' && index > 0)) {
|
|
942
|
+
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`);
|
|
943
|
+
}
|
|
944
|
+
seen.add(role);
|
|
945
|
+
}
|
|
946
|
+
const invitePath = config?.invitePath;
|
|
947
|
+
if (!isPagePath(invitePath)) {
|
|
948
|
+
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`);
|
|
949
|
+
}
|
|
950
|
+
const named = Object.keys(entry.env ?? {}).find((name) => WORKSPACES_ENTRY_ENV_NAMES.includes(name));
|
|
951
|
+
if (named) {
|
|
952
|
+
return refusal('workspaces_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} workspaces writes ${named}; remove it from env`);
|
|
953
|
+
}
|
|
681
954
|
return null;
|
|
682
955
|
}
|
|
683
956
|
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
957
|
/** A fixed sentence when `uses.auth.room` is not usable, `null` when it is. The value is never in it. */
|
|
687
958
|
function roomProblem(room) {
|
|
688
959
|
if (typeof room !== 'object' || room === null || Array.isArray(room)) {
|
|
@@ -747,7 +1018,7 @@ function validateEntryAi(entry, hosting, domain) {
|
|
|
747
1018
|
}
|
|
748
1019
|
if (ai.webhookPath !== undefined) {
|
|
749
1020
|
if (typeof ai.webhookPath !== 'string' ||
|
|
750
|
-
|
|
1021
|
+
!AI_WEBHOOK_PATH.test(ai.webhookPath) ||
|
|
751
1022
|
ai.webhookPath.startsWith('//')) {
|
|
752
1023
|
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
1024
|
}
|
|
@@ -775,7 +1046,7 @@ function validateEntryFiles(entry, hosting, domain) {
|
|
|
775
1046
|
const names = (value) => Array.isArray(value) &&
|
|
776
1047
|
value.every((v) => typeof v === 'string' && v !== '') &&
|
|
777
1048
|
new Set(value).size === value.length;
|
|
778
|
-
if (!names(files.collections) || !files.collections.every((n) =>
|
|
1049
|
+
if (!names(files.collections) || !files.collections.every((n) => FILES_COLLECTION_NAME.test(n))) {
|
|
779
1050
|
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
1051
|
}
|
|
781
1052
|
if (files.permissions !== undefined) {
|
|
@@ -837,6 +1108,9 @@ function validateEntryWorker(entry, hosting, domain) {
|
|
|
837
1108
|
if (entry.repo === null) {
|
|
838
1109
|
return refusal('worker_needs_repo', `hosting.entries.${entry.key}.repo`, `${at} needs its own repo (repo: null is not a Worker source)`);
|
|
839
1110
|
}
|
|
1111
|
+
if (!REPO.test(entry.repo)) {
|
|
1112
|
+
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`);
|
|
1113
|
+
}
|
|
840
1114
|
if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
|
|
841
1115
|
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
1116
|
}
|
|
@@ -889,6 +1163,98 @@ function validateEntryEdge(entry, hosting, domain) {
|
|
|
889
1163
|
return refusal('edge_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
890
1164
|
return null;
|
|
891
1165
|
}
|
|
1166
|
+
/** The env names `uses.repo` writes; `env` may not name them. */
|
|
1167
|
+
export const REPO_ENTRY_ENV_NAMES = ['GIT_REPO_URL', 'GIT_SSH_KEY'];
|
|
1168
|
+
/**
|
|
1169
|
+
* `uses.repo` (F12): `{ access: 'read' | 'write' }` and nothing else, on an entry that has its own `repo` (a
|
|
1170
|
+
* Worker app has none to clone and is already refused for any use but `uses.auth`), and no `env` entry that
|
|
1171
|
+
* names a value it writes.
|
|
1172
|
+
*/
|
|
1173
|
+
function validateEntryRepoUse(entry) {
|
|
1174
|
+
const value = entry.uses?.repo;
|
|
1175
|
+
if (value === undefined)
|
|
1176
|
+
return null;
|
|
1177
|
+
const path = `hosting.entries.${entry.key}.uses.repo`;
|
|
1178
|
+
const at = `hosting.entries '${entry.key}' uses.repo`;
|
|
1179
|
+
const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1180
|
+
const access = isObject ? value.access : undefined;
|
|
1181
|
+
if (!isObject ||
|
|
1182
|
+
(access !== 'read' && access !== 'write') ||
|
|
1183
|
+
Object.keys(value).some((k) => k !== 'access')) {
|
|
1184
|
+
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`);
|
|
1185
|
+
}
|
|
1186
|
+
if (entry.repo === null) {
|
|
1187
|
+
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`);
|
|
1188
|
+
}
|
|
1189
|
+
const named = Object.keys(entry.env ?? {}).find((name) => REPO_ENTRY_ENV_NAMES.includes(name));
|
|
1190
|
+
if (named) {
|
|
1191
|
+
return refusal('repo_use_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
1192
|
+
}
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
/** The env name `uses.customerGrants` writes; `env` may not name it. */
|
|
1196
|
+
export const CUSTOMER_GRANTS_ENTRY_ENV_NAMES = ['CUSTOMER_GRANTS_CREDENTIAL'];
|
|
1197
|
+
/**
|
|
1198
|
+
* `uses.customerGrants`: `true`, customers signing in (`uses.auth` with `users: 'customers'`, which already
|
|
1199
|
+
* needs hosting on and an exact hostname for the app), and no `env` entry that names the credential. A Worker
|
|
1200
|
+
* app is already refused for any use but `uses.auth` (`validateEntryWorkerApp`).
|
|
1201
|
+
*/
|
|
1202
|
+
function validateEntryCustomerGrants(entry) {
|
|
1203
|
+
const value = entry.uses?.customerGrants;
|
|
1204
|
+
if (value === undefined)
|
|
1205
|
+
return null;
|
|
1206
|
+
const path = `hosting.entries.${entry.key}.uses.customerGrants`;
|
|
1207
|
+
const at = `hosting.entries '${entry.key}' uses.customerGrants`;
|
|
1208
|
+
if (value !== true)
|
|
1209
|
+
return refusal('customer_grants_not_true', path, `${at} must be true`);
|
|
1210
|
+
if (entry.uses?.auth?.users !== 'customers') {
|
|
1211
|
+
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`);
|
|
1212
|
+
}
|
|
1213
|
+
const named = Object.keys(entry.env ?? {}).find((name) => CUSTOMER_GRANTS_ENTRY_ENV_NAMES.includes(name));
|
|
1214
|
+
if (named) {
|
|
1215
|
+
return refusal('customer_grants_env_clash', `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
1216
|
+
}
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
/** `uses.mail` and `uses.jobs`: `true`, `hosting.enabled`, and no `env` entry that names a value they write. */
|
|
1220
|
+
function validateEntryMailJobs(entry, hosting) {
|
|
1221
|
+
for (const [use, written, code] of [
|
|
1222
|
+
['mail', MAIL_ENTRY_ENV_NAMES, 'mail'],
|
|
1223
|
+
['jobs', JOBS_ENTRY_ENV_NAMES, 'jobs'],
|
|
1224
|
+
]) {
|
|
1225
|
+
const value = entry.uses?.[use];
|
|
1226
|
+
if (value === undefined)
|
|
1227
|
+
continue;
|
|
1228
|
+
const path = `hosting.entries.${entry.key}.uses.${use}`;
|
|
1229
|
+
const at = `hosting.entries '${entry.key}' uses.${use}`;
|
|
1230
|
+
if (value !== true)
|
|
1231
|
+
return refusal(`${code}_not_true`, path, `${at} must be true`);
|
|
1232
|
+
if (!hosting.enabled) {
|
|
1233
|
+
return refusal(`${code}_needs_hosting`, path, `${at} requires hosting.enabled`);
|
|
1234
|
+
}
|
|
1235
|
+
const named = Object.keys(entry.env ?? {}).find((name) => written.includes(name));
|
|
1236
|
+
if (named) {
|
|
1237
|
+
return refusal(`${code}_env_clash`, `hosting.entries.${entry.key}.env.${named}`, `${at} writes ${named}; remove it from env`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
/** `uses.audit`: `true`, `hosting.enabled`, and a Worker app with a database (the ledger lives in it). */
|
|
1243
|
+
function validateEntryAudit(entry, hosting) {
|
|
1244
|
+
const value = entry.uses?.audit;
|
|
1245
|
+
if (value === undefined)
|
|
1246
|
+
return null;
|
|
1247
|
+
const path = `hosting.entries.${entry.key}.uses.audit`;
|
|
1248
|
+
const at = `hosting.entries '${entry.key}' uses.audit`;
|
|
1249
|
+
if (value !== true)
|
|
1250
|
+
return refusal('audit_not_true', path, `${at} must be true`);
|
|
1251
|
+
if (!hosting.enabled)
|
|
1252
|
+
return refusal('audit_needs_hosting', path, `${at} requires hosting.enabled`);
|
|
1253
|
+
if (!entry.workerApp?.database) {
|
|
1254
|
+
return refusal('audit_needs_worker_database', path, `${at} requires a Worker app with a database: the ledger is kept in it`);
|
|
1255
|
+
}
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
892
1258
|
function urlHasPassword(value) {
|
|
893
1259
|
try {
|
|
894
1260
|
return new URL(value).password !== '';
|
|
@@ -925,6 +1291,8 @@ function urlHasPassword(value) {
|
|
|
925
1291
|
*/
|
|
926
1292
|
const DOMAIN_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
927
1293
|
const SHARED_ZONE = 'oikos.host';
|
|
1294
|
+
/** The platform zone new app addresses (`<hostedName>.companyincluded.app`) and every workspace live in. */
|
|
1295
|
+
export const APPS_DOMAIN = 'companyincluded.app';
|
|
928
1296
|
/** 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
1297
|
const RELAY_TLD = /^[a-z]{2,63}$/;
|
|
930
1298
|
export function validateDomain(domain) {
|
|
@@ -947,6 +1315,10 @@ export function validateDomain(domain) {
|
|
|
947
1315
|
if (domain === SHARED_ZONE || domain.endsWith(`.${SHARED_ZONE}`)) {
|
|
948
1316
|
return `domain '${domain}' is on ${SHARED_ZONE}, the shared zone for companies without a domain; a company cannot own it`;
|
|
949
1317
|
}
|
|
1318
|
+
// companyincluded.app holds the platform's hosted app addresses and every workspace.
|
|
1319
|
+
if (domain === APPS_DOMAIN || domain.endsWith(`.${APPS_DOMAIN}`)) {
|
|
1320
|
+
return `domain '${domain}' is on ${APPS_DOMAIN}, where the platform serves app addresses and workspaces; a company cannot own it`;
|
|
1321
|
+
}
|
|
950
1322
|
return null;
|
|
951
1323
|
}
|
|
952
1324
|
/**
|