@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.
Files changed (236) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +14 -5
  44. package/dist/index.js +7 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/migrations/0065_native_build_runs.sql +32 -0
  49. package/dist/migrations/0066_org_kosmos.sql +10 -0
  50. package/dist/modules/ai.d.ts +3 -0
  51. package/dist/modules/ai.js +64 -0
  52. package/dist/modules/assistants.d.ts +3 -0
  53. package/dist/modules/assistants.js +72 -0
  54. package/dist/modules/audit-ledger.d.ts +29 -0
  55. package/dist/modules/audit-ledger.js +49 -0
  56. package/dist/modules/audit.d.ts +7 -0
  57. package/dist/modules/audit.js +44 -0
  58. package/dist/modules/database.d.ts +23 -0
  59. package/dist/modules/database.js +189 -0
  60. package/dist/modules/email.d.ts +3 -0
  61. package/dist/modules/email.js +18 -0
  62. package/dist/modules/entry-parts.d.ts +23 -0
  63. package/dist/modules/entry-parts.js +63 -0
  64. package/dist/modules/files.d.ts +3 -0
  65. package/dist/modules/files.js +64 -0
  66. package/dist/modules/index.d.ts +74 -0
  67. package/dist/modules/index.js +774 -0
  68. package/dist/modules/jobs.d.ts +3 -0
  69. package/dist/modules/jobs.js +23 -0
  70. package/dist/modules/organisations.d.ts +3 -0
  71. package/dist/modules/organisations.js +20 -0
  72. package/dist/modules/outputs.d.ts +9 -0
  73. package/dist/modules/outputs.js +38 -0
  74. package/dist/modules/sign-in.d.ts +3 -0
  75. package/dist/modules/sign-in.js +197 -0
  76. package/dist/modules/signed-records.d.ts +3 -0
  77. package/dist/modules/signed-records.js +32 -0
  78. package/dist/modules/types.d.ts +194 -0
  79. package/dist/modules/types.js +1 -0
  80. package/dist/modules/workspaces.d.ts +3 -0
  81. package/dist/modules/workspaces.js +75 -0
  82. package/dist/native-build.d.ts +70 -0
  83. package/dist/native-build.js +92 -0
  84. package/dist/org.d.ts +184 -24
  85. package/dist/org.js +444 -49
  86. package/dist/plan/agora.d.ts +1 -1
  87. package/dist/plan/agora.js +4 -0
  88. package/dist/plan/apps-db-route.d.ts +90 -0
  89. package/dist/plan/apps-db-route.js +168 -0
  90. package/dist/plan/apps-zones.d.ts +56 -0
  91. package/dist/plan/apps-zones.js +118 -0
  92. package/dist/plan/archon-blue-green.js +11 -1
  93. package/dist/plan/blue-green-apply.d.ts +37 -1
  94. package/dist/plan/blue-green-apply.js +55 -15
  95. package/dist/plan/blue-green.d.ts +60 -8
  96. package/dist/plan/blue-green.js +115 -10
  97. package/dist/plan/boule.d.ts +23 -1
  98. package/dist/plan/boule.js +35 -2
  99. package/dist/plan/broker-entries.d.ts +28 -3
  100. package/dist/plan/broker-entries.js +74 -3
  101. package/dist/plan/broker-registrar.d.ts +168 -0
  102. package/dist/plan/broker-registrar.js +440 -0
  103. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  104. package/dist/plan/broker-workspaces-database.js +87 -0
  105. package/dist/plan/deploy-health.js +9 -2
  106. package/dist/plan/desktop-callback.js +15 -7
  107. package/dist/plan/diff.d.ts +8 -2
  108. package/dist/plan/diff.js +552 -104
  109. package/dist/plan/edge-access.d.ts +6 -0
  110. package/dist/plan/edge-access.js +1 -0
  111. package/dist/plan/edge-dns.d.ts +6 -0
  112. package/dist/plan/edge-dns.js +9 -7
  113. package/dist/plan/entry-ai-key.d.ts +1 -1
  114. package/dist/plan/entry-ai-key.js +2 -2
  115. package/dist/plan/entry-audit.d.ts +58 -0
  116. package/dist/plan/entry-audit.js +122 -0
  117. package/dist/plan/entry-customer-grants.d.ts +75 -0
  118. package/dist/plan/entry-customer-grants.js +154 -0
  119. package/dist/plan/entry-edge-key.d.ts +26 -1
  120. package/dist/plan/entry-edge-key.js +42 -3
  121. package/dist/plan/entry-jobs.d.ts +64 -0
  122. package/dist/plan/entry-jobs.js +92 -0
  123. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  124. package/dist/plan/entry-repo-key-apply.js +185 -0
  125. package/dist/plan/entry-repo-key.d.ts +92 -0
  126. package/dist/plan/entry-repo-key.js +235 -0
  127. package/dist/plan/entry-workspaces.d.ts +55 -0
  128. package/dist/plan/entry-workspaces.js +90 -0
  129. package/dist/plan/executor.d.ts +231 -19
  130. package/dist/plan/executor.js +1648 -129
  131. package/dist/plan/gates.d.ts +1 -1
  132. package/dist/plan/gates.js +62 -9
  133. package/dist/plan/github-deploy-keys.d.ts +78 -0
  134. package/dist/plan/github-deploy-keys.js +305 -0
  135. package/dist/plan/github-source.d.ts +53 -0
  136. package/dist/plan/github-source.js +140 -0
  137. package/dist/plan/held-key-refs.d.ts +12 -0
  138. package/dist/plan/held-key-refs.js +12 -0
  139. package/dist/plan/hostnames.d.ts +9 -0
  140. package/dist/plan/hostnames.js +28 -1
  141. package/dist/plan/index.d.ts +9 -0
  142. package/dist/plan/index.js +6 -0
  143. package/dist/plan/jobs-scheduler-http.js +3 -0
  144. package/dist/plan/keys-registrar-http.d.ts +32 -0
  145. package/dist/plan/keys-registrar-http.js +105 -0
  146. package/dist/plan/keys-registrar.d.ts +67 -0
  147. package/dist/plan/keys-registrar.js +103 -0
  148. package/dist/plan/kosmos.d.ts +56 -0
  149. package/dist/plan/kosmos.js +84 -0
  150. package/dist/plan/live.d.ts +1 -1
  151. package/dist/plan/locked-hostnames.d.ts +2 -2
  152. package/dist/plan/locked-hostnames.js +7 -4
  153. package/dist/plan/login-host.js +11 -5
  154. package/dist/plan/machine-paths.d.ts +36 -0
  155. package/dist/plan/machine-paths.js +137 -0
  156. package/dist/plan/mcp.d.ts +1 -1
  157. package/dist/plan/mcp.js +49 -17
  158. package/dist/plan/org-apps-admin.d.ts +108 -0
  159. package/dist/plan/org-apps-admin.js +434 -0
  160. package/dist/plan/org-names.d.ts +3 -3
  161. package/dist/plan/org-names.js +15 -2
  162. package/dist/plan/org-postgres-migrate.js +1 -0
  163. package/dist/plan/own-diff.d.ts +23 -0
  164. package/dist/plan/own-diff.js +53 -0
  165. package/dist/plan/own-hostnames.d.ts +2 -0
  166. package/dist/plan/own-hostnames.js +1 -0
  167. package/dist/plan/own-jobs-route.d.ts +47 -0
  168. package/dist/plan/own-jobs-route.js +91 -0
  169. package/dist/plan/own-limits.d.ts +6 -0
  170. package/dist/plan/own-limits.js +1 -0
  171. package/dist/plan/own-services.d.ts +4 -0
  172. package/dist/plan/own-services.js +4 -0
  173. package/dist/plan/plan-apply.d.ts +39 -0
  174. package/dist/plan/plan-apply.js +163 -9
  175. package/dist/plan/platform-diff.d.ts +2 -2
  176. package/dist/plan/platform-diff.js +7 -2
  177. package/dist/plan/platform-org.d.ts +30 -0
  178. package/dist/plan/platform-org.js +12 -1
  179. package/dist/plan/platform-providers.d.ts +2 -0
  180. package/dist/plan/platform-providers.js +13 -2
  181. package/dist/plan/post-logout-uris.d.ts +1 -1
  182. package/dist/plan/post-logout-uris.js +12 -3
  183. package/dist/plan/private-pages.d.ts +26 -0
  184. package/dist/plan/private-pages.js +39 -0
  185. package/dist/plan/providers-readers.js +20 -1
  186. package/dist/plan/proxy-labels.d.ts +12 -1
  187. package/dist/plan/proxy-labels.js +32 -2
  188. package/dist/plan/release-host.d.ts +6 -2
  189. package/dist/plan/release-host.js +36 -8
  190. package/dist/plan/stage-env.d.ts +19 -1
  191. package/dist/plan/stage-env.js +25 -1
  192. package/dist/plan/stage-guards.d.ts +20 -0
  193. package/dist/plan/stage-guards.js +24 -0
  194. package/dist/plan/stage-isolation.d.ts +24 -0
  195. package/dist/plan/stage-isolation.js +29 -1
  196. package/dist/plan/worker-app-database.d.ts +46 -42
  197. package/dist/plan/worker-app-database.js +150 -153
  198. package/dist/plan/worker-app.d.ts +5 -65
  199. package/dist/plan/worker-app.js +28 -105
  200. package/dist/plan/worker.d.ts +2 -2
  201. package/dist/plan/worker.js +2 -1
  202. package/dist/plan/workspace-client.d.ts +25 -0
  203. package/dist/plan/workspace-client.js +48 -0
  204. package/dist/plan/workspace-sign-in.d.ts +55 -0
  205. package/dist/plan/workspace-sign-in.js +273 -0
  206. package/dist/plan/workspace-worker.d.ts +4 -0
  207. package/dist/plan/workspace-worker.js +13 -0
  208. package/dist/plan/workspace.d.ts +101 -0
  209. package/dist/plan/workspace.js +305 -0
  210. package/dist/release/decide.d.ts +30 -1
  211. package/dist/release/decide.js +73 -11
  212. package/dist/release/health.d.ts +8 -3
  213. package/dist/release/health.js +58 -5
  214. package/dist/release/migration-compatibility.d.ts +66 -0
  215. package/dist/release/migration-compatibility.js +340 -0
  216. package/dist/release/run.d.ts +2 -5
  217. package/dist/release/run.js +4 -0
  218. package/dist/release/state.d.ts +3 -1
  219. package/dist/release/state.js +6 -0
  220. package/dist/release/types.d.ts +20 -0
  221. package/dist/requested-changes.d.ts +11 -2
  222. package/dist/requested-changes.js +13 -1
  223. package/dist/service-labels.d.ts +4 -0
  224. package/dist/service-labels.js +11 -0
  225. package/dist/slug.d.ts +2 -0
  226. package/dist/slug.js +20 -7
  227. package/dist/store/postgres.js +25 -2
  228. package/dist/store/schema.d.ts +73 -1
  229. package/dist/store/schema.js +10 -0
  230. package/dist/worker-app-config.d.ts +1 -11
  231. package/dist/worker-app-config.js +4 -17
  232. package/dist/worker-app-migrations.d.ts +105 -0
  233. package/dist/worker-app-migrations.js +166 -0
  234. package/dist/worker-app-upload.d.ts +103 -0
  235. package/dist/worker-app-upload.js +128 -0
  236. 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
- /** Longest value of each `uses.auth.room` text field. */
19
- export const ROOM_TEXT_MAX = {
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 || hosting.entries.some((e) => e.blueGreen !== undefined);
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
- function validateHostingEntry(entry, enabled, reserveKeys) {
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 && !REPO.test(entry.repo)) {
441
- return refusal('entry_repo_invalid', `hosting.entries.${entry.key}.repo`, `${at} repo must be a repo under wtfalch, or null`);
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
- /** {@link refuseBlueGreen} for the `blueGreen` of the company's Boule and Agora; the same codes. */
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
- !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,199}$/.test(ai.webhookPath) ||
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) => /^[A-Z][A-Z0-9_]*$/.test(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;