@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.
Files changed (218) 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 +10 -5
  44. package/dist/index.js +5 -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/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +46 -42
  181. package/dist/plan/worker-app-database.js +150 -153
  182. package/dist/plan/worker-app.d.ts +5 -65
  183. package/dist/plan/worker-app.js +28 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +1 -11
  213. package/dist/worker-app-config.js +4 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. 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
- /** 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'];
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 || hosting.entries.some((e) => e.blueGreen !== undefined);
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
- function validateHostingEntry(entry, enabled, reserveKeys) {
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 && !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`);
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
- !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,199}$/.test(ai.webhookPath) ||
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) => /^[A-Z][A-Z0-9_]*$/.test(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
  /**