@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
@@ -0,0 +1,26 @@
1
+ import { type DeclaredOrg, type PrivatePage } from '../org.js';
2
+ /**
3
+ * Private pages (`HostingEntry.privatePages`): a path of an app that only the company's people may open. Each is
4
+ * a path-scoped Cloudflare Access application (`<host><path>`, a self-hosted app whose domain carries a path)
5
+ * made by `planGates` like any other gate, with the company sign-in claim, over the entry's live hosts (`pp-`)
6
+ * and over its workspace host (`wp-`). Cloudflare applies the most specific application, so the page's gate
7
+ * wins over the workspace's whole-host gate, and a tester who may open the workspace is refused here.
8
+ *
9
+ * Foundry cannot turn a role into people: `roles` are the company's role ids, and the host that declares the
10
+ * org puts the email addresses of the people holding them in `people`. The gate admits the owners and those.
11
+ */
12
+ export type PrivatePageScope = 'live' | 'workspace';
13
+ /** The gate key of one private page: a hash of the scope, entry key and path, so it fits a DNS label, never clashes with `ws-<key>` or a declared gate key, and needs no escaping. */
14
+ export declare function privatePageGateKey(scope: PrivatePageScope, entryKey: string, path: string): string;
15
+ /** Who passes a private page: the owners and the page's `people`, lowercased, distinct, sorted. */
16
+ export declare function privatePagePeople(org: Pick<DeclaredOrg, 'owners'>, page: PrivatePage): string[];
17
+ export interface PrivatePageGate {
18
+ entry: string;
19
+ hostnames: string[];
20
+ people: string[];
21
+ }
22
+ /**
23
+ * The private page gates by key: the live ones from `org` (an entry that is not live has no live host) and the
24
+ * workspace ones from `declared` (the org with its dark entries).
25
+ */
26
+ export declare function privatePageGates(org: DeclaredOrg, declared: DeclaredOrg): Map<string, PrivatePageGate>;
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { normalizeTesters } from '../org.js';
3
+ import { entryHostnames, workspaceHost } from './hostnames.js';
4
+ import { companyPeople, workspaceEntries } from './workspace.js';
5
+ /** The gate key of one private page: a hash of the scope, entry key and path, so it fits a DNS label, never clashes with `ws-<key>` or a declared gate key, and needs no escaping. */
6
+ export function privatePageGateKey(scope, entryKey, path) {
7
+ const hash = createHash('sha256').update(`${scope}\0${entryKey}\0${path}`).digest('hex');
8
+ return `${scope === 'live' ? 'pp' : 'wp'}-${hash.slice(0, 16)}`;
9
+ }
10
+ /** Who passes a private page: the owners and the page's `people`, lowercased, distinct, sorted. */
11
+ export function privatePagePeople(org, page) {
12
+ return normalizeTesters([...companyPeople(org), ...(page.people ?? [])]).sort();
13
+ }
14
+ /**
15
+ * The private page gates by key: the live ones from `org` (an entry that is not live has no live host) and the
16
+ * workspace ones from `declared` (the org with its dark entries).
17
+ */
18
+ export function privatePageGates(org, declared) {
19
+ const gates = new Map();
20
+ if (!org.hosting.enabled)
21
+ return gates;
22
+ const add = (scope, entry, hosts) => {
23
+ if (hosts.length === 0)
24
+ return;
25
+ for (const page of entry.privatePages ?? []) {
26
+ gates.set(privatePageGateKey(scope, entry.key, page.path), {
27
+ entry: entry.key,
28
+ hostnames: hosts.map((host) => `${host}${page.path}`).sort(),
29
+ people: privatePagePeople(declared, page),
30
+ });
31
+ }
32
+ };
33
+ for (const entry of org.hosting.entries)
34
+ add('live', entry, entryHostnames(org, entry));
35
+ for (const entry of workspaceEntries(declared)) {
36
+ add('workspace', entry, [workspaceHost(declared.slug, entry.key)]);
37
+ }
38
+ return gates;
39
+ }
@@ -2,6 +2,7 @@ import { ZitadelError } from '@wtfalch/providers/zitadel';
2
2
  import { APP_ENTRY_KEY } from '../org.js';
3
3
  import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
4
4
  import { previewsOn } from './coolify-previews.js';
5
+ import { KOSMOS_MANAGED_ENV_KEYS } from './kosmos.js';
5
6
  import { APP_GONE_KIND, } from './live.js';
6
7
  import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
7
8
  /**
@@ -302,6 +303,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
302
303
  config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
303
304
  };
304
305
  }
306
+ /**
307
+ * The org's own recorded kosmos app's live sign-in env vars, as `config.env`, so `diff.ts`'s `planKosmos`
308
+ * can compare them with what `executor.ts` would write. Gated and failing exactly like
309
+ * {@link withAgoraEnvConfig}.
310
+ */
311
+ async function withKosmosEnvConfig(client, resource, recorded) {
312
+ if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('kosmos:')) {
313
+ return resource;
314
+ }
315
+ const managed = KOSMOS_MANAGED_ENV_KEYS;
316
+ const env = {};
317
+ for (const e of await client.listEnvs(resource.key)) {
318
+ if (!e.is_preview && managed.includes(e.key))
319
+ env[e.key] = e.value;
320
+ }
321
+ return { ...resource, config: { ...resource.config, env } };
322
+ }
305
323
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
306
324
  function toFoundryResources(system, resources) {
307
325
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -335,7 +353,8 @@ export class ProvidersCoolifyReader {
335
353
  const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
336
354
  const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
337
355
  const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
338
- return applyRecordedIds(withBouleConfig, recorded);
356
+ const withKosmosConfig = await Promise.all(withBouleConfig.map((r) => withKosmosEnvConfig(this.client, r, recorded)));
357
+ return applyRecordedIds(withKosmosConfig, recorded);
339
358
  }
340
359
  async liveEnvEquals(orgId, appKey, name, value) {
341
360
  const recorded = await this.resolveRecordedIds(orgId);
@@ -26,6 +26,8 @@ export declare const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = "cloudflare_tunn
26
26
  * last written for, comma-joined. Recorded only once every record is in place.
27
27
  */
28
28
  export declare const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_oikos_hostnames";
29
+ /** The same for the entry names in the platform's companyincluded.app zone ({@link entryAppsHostnames}). */
30
+ export declare const CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY = "cloudflare_tunnel_apps_hostnames";
29
31
  /**
30
32
  * The run label every customer hosting-entry container gets. A run label overrides the image's own
31
33
  * `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
@@ -92,9 +94,18 @@ export declare function sameLabelSets(a: string, b: string): boolean;
92
94
  * The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
93
95
  * `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
94
96
  */
95
- export declare function entryOikosHostnames(org: DeclaredOrg): string[];
97
+ export declare function entryOikosHostnames(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string[];
96
98
  /**
97
99
  * Whether `name` is one the company may write in the platform's `oikos.host` zone: exactly its own
98
100
  * `<slug>.oikos.host`. A company writes no other company's name, whatever an entry declares.
99
101
  */
100
102
  export declare function ownsOikosName(org: DeclaredOrg, name: string): boolean;
103
+ /**
104
+ * The entry names that live in the platform's `companyincluded.app` zone: an entry's `hostedName`, and the
105
+ * workspace host of each server workspace that is applied. Sorted.
106
+ */
107
+ export declare function entryAppsHostnames(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): string[];
108
+ /** Whether `name` is one this org may write in the `companyincluded.app` zone: a hosted name or its workspace host. */
109
+ export declare const ownsAppsZoneName: (org: DeclaredOrg, name: string) => boolean;
110
+ /** Whether `name` is one label directly under `companyincluded.app`. */
111
+ export declare function isAppsEntryName(name: string): boolean;
@@ -1,8 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { APPS_DOMAIN } from '../org.js';
2
3
  import { BUILTIN_APPS, PRIMARY_SERVER_SCOPE_KEY, balancerApplied, builtinSubject, lbAppName, lbOrigin, liveAppNameFor, } from './blue-green.js';
3
4
  import { entryHostnames, wildcardHostname } from './hostnames.js';
4
5
  import { NO_DOMAIN_ZONE } from './own-hostnames.js';
5
6
  import { appNameFor } from './platform-diff.js';
7
+ import { ownsWorkspaceName, workspaceRoutes } from './workspace.js';
6
8
  /**
7
9
  * The label every routed container carries, with the install's token as its value. Traefik is told
8
10
  * (`--providers.docker.constraints=Label(`foundry.edge`,`<token>`)`) to route only containers that carry
@@ -29,6 +31,8 @@ export const CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY = 'cloudflare_tunnel_entry
29
31
  * last written for, comma-joined. Recorded only once every record is in place.
30
32
  */
31
33
  export const CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_oikos_hostnames';
34
+ /** The same for the entry names in the platform's companyincluded.app zone ({@link entryAppsHostnames}). */
35
+ export const CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY = 'cloudflare_tunnel_apps_hostnames';
32
36
  /**
33
37
  * The run label every customer hosting-entry container gets. A run label overrides the image's own
34
38
  * `LABEL` of the same key, so Traefik ignores the container whatever its image says: a customer image
@@ -65,6 +69,8 @@ export function entryRoutes(org, recorded = {}) {
65
69
  for (const entry of org.hosting.entries) {
66
70
  if (entry.workerApp)
67
71
  continue; // Served by its Worker route, not the tunnel.
72
+ if (entry.live === false)
73
+ continue; // Only its workspace exists (below).
68
74
  // A blue/green entry is reached through its balancer, which each server's connector finds on its own
69
75
  // network, once the balancer exists on the primary. Until then it keeps the origin it had.
70
76
  const target = !entry.blueGreen
@@ -81,6 +87,10 @@ export function entryRoutes(org, recorded = {}) {
81
87
  if (wildcardName && !wildcard)
82
88
  wildcard = { hostname: wildcardName, service };
83
89
  }
90
+ for (const route of workspaceRoutes(org, recorded)) {
91
+ if (!routes.some((r) => r.hostname === route.hostname))
92
+ routes.push(route);
93
+ }
84
94
  if (wildcard)
85
95
  routes.push(wildcard);
86
96
  return routes;
@@ -171,8 +181,8 @@ export function sameLabelSets(a, b) {
171
181
  * The entry names that live in the platform's `oikos.host` zone (a company with no domain of its own, or with
172
182
  * `usesDomain` off): the names the company's tunnel gets a proxied CNAME for. Sorted.
173
183
  */
174
- export function entryOikosHostnames(org) {
175
- return entryRoutes(org)
184
+ export function entryOikosHostnames(org, recorded = {}) {
185
+ return entryRoutes(org, recorded)
176
186
  .map((r) => r.hostname)
177
187
  .filter((h) => h.endsWith(`.${NO_DOMAIN_ZONE}`))
178
188
  .sort();
@@ -184,3 +194,23 @@ export function entryOikosHostnames(org) {
184
194
  export function ownsOikosName(org, name) {
185
195
  return name.toLowerCase() === `${org.slug}.${NO_DOMAIN_ZONE}`;
186
196
  }
197
+ /**
198
+ * The entry names that live in the platform's `companyincluded.app` zone: an entry's `hostedName`, and the
199
+ * workspace host of each server workspace that is applied. Sorted.
200
+ */
201
+ export function entryAppsHostnames(org, recorded = {}) {
202
+ return entryRoutes(org, recorded)
203
+ .map((r) => r.hostname)
204
+ .filter((name) => isAppsEntryName(name) || ownsWorkspaceName(org, name))
205
+ .sort();
206
+ }
207
+ /** Whether `name` is one this org may write in the `companyincluded.app` zone: a hosted name or its workspace host. */
208
+ export const ownsAppsZoneName = (org, name) => isAppsEntryName(name) || ownsWorkspaceName(org, name);
209
+ /** Whether `name` is one label directly under `companyincluded.app`. */
210
+ export function isAppsEntryName(name) {
211
+ const suffix = `.${APPS_DOMAIN}`;
212
+ const lower = name.toLowerCase();
213
+ return (lower.endsWith(suffix) &&
214
+ lower.length > suffix.length &&
215
+ !lower.slice(0, -suffix.length).includes('.'));
216
+ }
@@ -1,6 +1,6 @@
1
1
  import { type ReleaseDeps } from '../release/run.js';
2
2
  import type { ReleaseStore } from '../release/state.js';
3
- import { type BlueGreenSubject } from './blue-green.js';
3
+ import { type BlueGreenSubject, type PinHeldKeys } from './blue-green.js';
4
4
  import { type DeployHealthRuntime } from './deploy-health.js';
5
5
  import type { CoolifyPort } from './executor.js';
6
6
  /** The recorded ids a release run reads and writes: an org's apply context, or the platform's. */
@@ -21,13 +21,17 @@ export interface ReleaseHost {
21
21
  export interface ReleaseHostInput {
22
22
  subject: BlueGreenSubject;
23
23
  context: ReleaseContext;
24
- coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
24
+ coolify: Pick<CoolifyPort, 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'deployAndWait' | 'raw' | 'listStorages' | 'createFileStorage' | 'updateFileStorage'> & {
25
25
  listApplicationDeployments?: CoolifyPort['listApplicationDeployments'];
26
26
  };
27
27
  /** Reads the entry's health token (`sharedSecretRef(org.id, healthTokenName(id))`). Never mints. */
28
28
  healthToken: () => Promise<string | null>;
29
29
  /** Registers a value for redaction before it is written anywhere. */
30
30
  secret: (value: string) => void;
31
+ /** The held ref of the balancer's pin key (`sharedSecretRef(owner, pinKeyName(id))`). */
32
+ pinRef: string;
33
+ /** The held store. Without it the pin key cannot be read, so a check through the balancer is inconclusive. */
34
+ heldKeys?: PinHeldKeys;
31
35
  primaryUuid: string;
32
36
  /** The Coolify app of the blue copy on the primary (the entry's `coolify:app`, or the running archon app). */
33
37
  primaryBlueAppId: string;
@@ -1,7 +1,8 @@
1
+ import { randomBytes } from 'node:crypto';
1
2
  import { checkColorAddress, readReleaseHealth } from '../release/health.js';
2
3
  import { runRelease } from '../release/run.js';
3
4
  import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
4
- import { DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, parseWeights, reportingEnv, } from './blue-green.js';
5
+ import { ACTIONS_KEY_ENV, DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, mintPinKey, parseWeights, readPinKey, reportingEnv, } from './blue-green.js';
5
6
  import { waitForNewDeployment } from './deploy-health.js';
6
7
  import { isNotFoundError } from './executor.js';
7
8
  const COMMIT = /^[0-9a-f]{40}$/;
@@ -41,13 +42,20 @@ export function createReleaseHost(input) {
41
42
  * Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
42
43
  * header router. The balancer routes the pinned request to that color whatever its weight.
43
44
  */
44
- const addressOf = (color) => {
45
+ const pinInput = () => ({
46
+ id,
47
+ ref: input.pinRef,
48
+ recorded,
49
+ secret: input.secret,
50
+ heldKeys: input.heldKeys ?? { resolve: async () => null },
51
+ });
52
+ const addressOf = async (color) => {
45
53
  const hostname = subject.hostnames.find((name) => !name.startsWith('*'));
46
54
  if (!hostname)
47
55
  throw new Error(`${input.label}: the app has no public hostname to check through`);
48
- const pinKey = recorded()[bgKey.pinKey(id)];
56
+ const pinKey = await readPinKey(pinInput());
49
57
  if (!pinKey)
50
- throw new Error(`${input.label}: the balancer's pin key is not recorded`);
58
+ throw new Error(`${input.label}: the balancer's pin key is not held`);
51
59
  return {
52
60
  address: `https://${hostname}`,
53
61
  headers: { [LB_COLOR_HEADER]: color, [LB_PIN_HEADER]: pinKey },
@@ -101,6 +109,18 @@ export function createReleaseHost(input) {
101
109
  if (envs.length > 0) {
102
110
  await coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
103
111
  }
112
+ // A key removed from blue (a rotated-out credential) must not stay on the slot.
113
+ if (appId !== input.primaryBlueAppId) {
114
+ const keep = new Set([...source.map((e) => e.key), ...envs.map((e) => e.key)]);
115
+ for (const env of await coolify.listEnvs(appId)) {
116
+ if (env.uuid &&
117
+ !keep.has(env.key) &&
118
+ !env.key.startsWith('REPORTING_') &&
119
+ env.key !== ACTIONS_KEY_ENV) {
120
+ await coolify.deleteEnv(appId, env.uuid);
121
+ }
122
+ }
123
+ }
104
124
  }
105
125
  catch {
106
126
  // A Coolify error can echo the request body, which holds the token.
@@ -129,7 +149,7 @@ export function createReleaseHost(input) {
129
149
  return null;
130
150
  input.secret(token);
131
151
  return readReleaseHealth({
132
- ...addressOf(healthColor()),
152
+ ...(await addressOf(healthColor())),
133
153
  token,
134
154
  ...range,
135
155
  ...(input.fetch ? { fetchImpl: input.fetch } : {}),
@@ -146,9 +166,10 @@ export function createReleaseHost(input) {
146
166
  return {
147
167
  status,
148
168
  address: await checkColorAddress({
149
- ...addressOf(color),
169
+ ...(await addressOf(color)),
150
170
  healthPath: subject.healthPath,
151
- expectJson: subject.reporting && subject.healthPath === '/api/health',
171
+ // A server answers JSON on its health path; a static app answers HTML, which must not be an Access page.
172
+ expectJson: subject.reporting,
152
173
  ...(input.fetch ? { fetchImpl: input.fetch } : {}),
153
174
  }),
154
175
  };
@@ -156,8 +177,9 @@ export function createReleaseHost(input) {
156
177
  async effect(effect) {
157
178
  if (effect.kind === 'deploy')
158
179
  await deployTo(effect.color, effect.digest);
159
- else
180
+ else if (effect.kind === 'stop')
160
181
  await stop(effect.color);
182
+ // deploy_stage is executed by the stage deploy task (F4); until then it is passed on and ignored here.
161
183
  },
162
184
  };
163
185
  return {
@@ -187,6 +209,12 @@ export function createReleaseHost(input) {
187
209
  for (const uuid of servers) {
188
210
  await input.renew();
189
211
  await applyBlueGreenWeights({
212
+ pinKey: () => mintPinKey({
213
+ ...pinInput(),
214
+ record: (key, value) => context.recordScopeId(key, value),
215
+ persist: () => context.persist(),
216
+ generate: () => randomBytes(32).toString('hex'),
217
+ }),
190
218
  coolify,
191
219
  recorded,
192
220
  record: (key, value) => context.recordScopeId(key, value),
@@ -1,4 +1,20 @@
1
- import { type StageKind } from './stage-isolation.js';
1
+ import { type LiveSignIn, type StageKind } from './stage-isolation.js';
2
+ /** A stage with its own sign-in: a workspace. Its values are the stage's, never the live app's. */
3
+ export interface StageSignIn {
4
+ /** Issuer, organisation id, client id and, with workspaces, the broker URL and the stage's own key. */
5
+ env: ReadonlyArray<{
6
+ key: string;
7
+ value: string;
8
+ }>;
9
+ /** Stage signs in the company's test customers organisation. */
10
+ customers: boolean;
11
+ /** The redirect URI registered for the stage client. */
12
+ redirectUri: string;
13
+ /** What stage must never hold. */
14
+ live: LiveSignIn;
15
+ }
16
+ /** The names a stage's own sign-in sets; a change to one of them redeploys stage. */
17
+ export declare const STAGE_SIGN_IN_NAMES: readonly string[];
2
18
  export interface StageEnvInput {
3
19
  kind: StageKind;
4
20
  /** Where the stage app is reached: its own container address, since stage has no hostname. */
@@ -22,6 +38,8 @@ export interface StageEnvInput {
22
38
  is_preview?: boolean;
23
39
  }>;
24
40
  mint: () => string;
41
+ /** Stage's own sign-in. Its values override any copied from the live app; its cookie secret and app key are minted. */
42
+ signIn?: StageSignIn;
25
43
  }
26
44
  export interface StageEnv {
27
45
  env: Array<{
@@ -5,9 +5,26 @@ const WAITS_FOR_STAGE_SERVICE = new Set([
5
5
  'AI_URL',
6
6
  'JOBS_SCHEDULER_URL',
7
7
  'ANALYTICS_URL',
8
+ 'WORKSPACES_URL',
9
+ 'MCP_ISSUER',
8
10
  ]);
9
11
  /** Secrets stage mints for itself, when the app uses them. A copy of prod's value would be a prod credential. */
10
12
  const MINTED = ['AUTH_COOKIE_SECRET', 'KEYSTORE_KEK'];
13
+ /** The names a stage's own sign-in sets; a change to one of them redeploys stage. */
14
+ export const STAGE_SIGN_IN_NAMES = [
15
+ 'AUTH_ISSUER',
16
+ 'AUTH_ORGANIZATION_ID',
17
+ 'AUTH_CLIENT_ID',
18
+ 'AUTH_APP_KEY',
19
+ 'AUTH_COOKIE_SECRET',
20
+ 'WORKSPACES_URL',
21
+ 'WORKSPACES_API_KEY',
22
+ 'MCP_AUDIENCE',
23
+ 'MCP_RESOURCE',
24
+ 'MCP_ISSUER',
25
+ ];
26
+ /** The shortest `AUTH_APP_KEY` the broker accepts. */
27
+ const MIN_APP_KEY_LENGTH = 32;
11
28
  /**
12
29
  * The env of a stage app, built from the kind's allowlist and never from a copy of the live app's env. Every
13
30
  * name is allowed and none is denied, or this throws a fixed text with the name.
@@ -41,10 +58,17 @@ export function buildStageEnv(input) {
41
58
  set(key, value);
42
59
  }
43
60
  }
61
+ for (const { key, value } of input.signIn?.env ?? [])
62
+ set(key, value);
44
63
  for (const key of MINTED) {
45
- if (prod.has(key) && isAllowed(kind, key))
64
+ const wanted = prod.has(key) || (input.signIn !== undefined && key === 'AUTH_COOKIE_SECRET');
65
+ if (wanted && isAllowed(kind, key))
46
66
  set(key, existing.get(key) ?? input.mint());
47
67
  }
68
+ if (input.signIn) {
69
+ const kept = existing.get('AUTH_APP_KEY');
70
+ set('AUTH_APP_KEY', kept && kept.length >= MIN_APP_KEY_LENGTH ? kept : input.mint());
71
+ }
48
72
  const absent = STAGE_ALLOWLIST[kind]
49
73
  .filter((name) => !name.endsWith('*') && prod.has(name) && !env.has(name))
50
74
  .sort();
@@ -37,6 +37,22 @@ export interface StageProbePort {
37
37
  syncKey: string;
38
38
  projectId: string;
39
39
  }): Promise<boolean>;
40
+ /** Uses the stage workspaces key on the live customers organisation's workspaces in the broker. */
41
+ workspacesRejects?(input: {
42
+ url: string;
43
+ apiKey: string;
44
+ organisationId: string;
45
+ }): Promise<boolean>;
46
+ /**
47
+ * Starts a real authorization request at the issuer for the live customers organisation with the stage client
48
+ * id and the redirect URI registered for that client, and answers `true` when the live sign-in refuses it.
49
+ */
50
+ signInRejects?(input: {
51
+ issuer: string;
52
+ clientId: string;
53
+ organisationId: string;
54
+ redirectUri: string;
55
+ }): Promise<boolean>;
40
56
  }
41
57
  /** One probe. `run` is `null` when the host has no port for it: the probe is then reported as not run. */
42
58
  export interface StageProbe {
@@ -52,6 +68,10 @@ export interface StageProbeInput {
52
68
  prod: ReadonlyMap<string, string>;
53
69
  /** Whether stage has a database: if no database probe can be built, one is kept as not run. */
54
70
  database?: boolean;
71
+ /** The live customers organisation's id, where the company has one: the sign-in probes aim at it. */
72
+ liveCustomersOrgId?: string;
73
+ /** The redirect URI registered for the stage client: the sign-in probe sends it. */
74
+ signInRedirectUri?: string;
55
75
  }
56
76
  /**
57
77
  * The probes for what stage holds: its database role against production's database host, and its files, ai and
@@ -77,6 +77,30 @@ export function buildStageProbes(input) {
77
77
  run: call ? () => call({ url: jobsUrl, syncKey, projectId }) : null,
78
78
  });
79
79
  }
80
+ const customersOrgId = input.liveCustomersOrgId;
81
+ const workspacesUrl = stage.get('WORKSPACES_URL');
82
+ const workspacesKey = stage.get('WORKSPACES_API_KEY');
83
+ if (customersOrgId && workspacesUrl && workspacesKey) {
84
+ const call = port?.workspacesRejects?.bind(port);
85
+ probes.push({
86
+ what: 'workspaces:stage-key-on-live-customers',
87
+ run: call
88
+ ? () => call({ url: workspacesUrl, apiKey: workspacesKey, organisationId: customersOrgId })
89
+ : null,
90
+ });
91
+ }
92
+ const issuer = stage.get('AUTH_ISSUER');
93
+ const clientId = stage.get('AUTH_CLIENT_ID');
94
+ const redirectUri = input.signInRedirectUri;
95
+ if (customersOrgId && issuer && clientId && redirectUri) {
96
+ const call = port?.signInRejects?.bind(port);
97
+ probes.push({
98
+ what: 'sign-in:stage-client-on-live-customers',
99
+ run: call
100
+ ? () => call({ issuer, clientId, organisationId: customersOrgId, redirectUri })
101
+ : null,
102
+ });
103
+ }
80
104
  return probes;
81
105
  }
82
106
  /** Runs every probe once, each under a timeout. A throw and a timeout count as failed, never as rejected. */
@@ -36,10 +36,31 @@ export declare function isAllowed(kind: StageKind, name: string): boolean;
36
36
  export declare const SHARED_WITH_PROD: Readonly<Record<string, string>>;
37
37
  /** Whether `name` may equal production's value. */
38
38
  export declare const isSharedWithProd: (name: string) => boolean;
39
+ /** The live app's own sign-in values. Stage must hold none of them. */
40
+ export interface LiveSignIn {
41
+ /** The live customers organisation's id. */
42
+ customersOrgId?: string;
43
+ /** The live app's client ids. */
44
+ clientIds: readonly string[];
45
+ /** The live workspaces keys. */
46
+ workspacesKeys: readonly string[];
47
+ /** The live apps' MCP audiences (the ZITADEL project ids). */
48
+ mcpAudiences?: readonly string[];
49
+ /** The live apps' MCP resources (their public MCP addresses). */
50
+ mcpResources?: readonly string[];
51
+ }
52
+ /** What a stage with its own sign-in says about it, for the isolation check. */
53
+ export interface StageSignInGuard {
54
+ /** Stage signs in its own test customers organisation, so its organisation id may differ from production's. */
55
+ customers: boolean;
56
+ live: LiveSignIn;
57
+ }
39
58
  export interface StageIsolationInput {
40
59
  kind: StageKind;
41
60
  prod: Surface;
42
61
  stage: Surface;
62
+ /** Present for a stage with its own sign-in (a workspace). */
63
+ signIn?: StageSignInGuard;
43
64
  /** The mail env stage is allowed to hold: the sink's wiring. Empty: stage holds no mail env. */
44
65
  sinkMail?: Readonly<Record<string, string>>;
45
66
  }
@@ -51,6 +72,9 @@ export interface StageIsolationInput {
51
72
  * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
52
73
  * - A4 names: `resource_shared`, `host_is_prod_internal`.
53
74
  * - A5 mail: `mail_not_sink`, `mail_token`.
75
+ * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
76
+ * `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
77
+ * id, the live workspaces key, or a live MCP audience or resource.
54
78
  */
55
79
  export declare function assertStageIsolated(input: StageIsolationInput): string[];
56
80
  /** The sha256 of the stage env name set plus the resource set: what `bg.<id>.stageisolation` records. */
@@ -87,6 +87,11 @@ export const STAGE_ALLOWLIST = {
87
87
  'JOBS_SYNC_KEY',
88
88
  'ANALYTICS_URL',
89
89
  'ANALYTICS_SITE_KEY',
90
+ 'WORKSPACES_URL',
91
+ 'WORKSPACES_API_KEY',
92
+ 'MCP_AUDIENCE',
93
+ 'MCP_RESOURCE',
94
+ 'MCP_ISSUER',
90
95
  ],
91
96
  };
92
97
  /** Names no stage holds, whatever the app. */
@@ -159,6 +164,8 @@ export const SHARED_WITH_PROD = {
159
164
  AI_URL: 'the ai service is shared; stage reaches it only with its own organisation and key',
160
165
  JOBS_SCHEDULER_URL: 'the jobs service is shared; stage reaches it only with its own project and signing secret',
161
166
  ANALYTICS_URL: 'the analytics collector is shared; stage reports under its own site key',
167
+ MCP_ISSUER: 'the sign-in service is shared by decision; the issuer is a public address, not a credential',
168
+ WORKSPACES_URL: "the broker is shared; stage's own workspaces key opens its own product tenant, which holds no live customers' workspaces",
162
169
  };
163
170
  /** Whether `name` may equal production's value. */
164
171
  export const isSharedWithProd = (name) => Object.keys(SHARED_WITH_PROD).some((p) => globMatches(p, name));
@@ -174,6 +181,9 @@ const hostOf = (value) => URL_HOST.exec(value)?.[1]?.toLowerCase() ?? null;
174
181
  * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
175
182
  * - A4 names: `resource_shared`, `host_is_prod_internal`.
176
183
  * - A5 mail: `mail_not_sink`, `mail_token`.
184
+ * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
185
+ * `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
186
+ * id, the live workspaces key, or a live MCP audience or resource.
177
187
  */
178
188
  export function assertStageIsolated(input) {
179
189
  const { kind, prod, stage } = input;
@@ -211,9 +221,27 @@ export function assertStageIsolated(input) {
211
221
  out.add(`env_not_allowed:${name}`);
212
222
  if (item.value !== undefined && LIVE_MODE_KEY.test(item.value))
213
223
  out.add(`live_mode_key:${name}`);
224
+ if (input.signIn && item.value !== undefined) {
225
+ const { live } = input.signIn;
226
+ if (name === 'AUTH_ORGANIZATION_ID' && item.value === live.customersOrgId) {
227
+ out.add(`live_customers_org:${name}`);
228
+ }
229
+ if (name === 'AUTH_CLIENT_ID' && live.clientIds.includes(item.value)) {
230
+ out.add(`live_client_id:${name}`);
231
+ }
232
+ if (name === 'WORKSPACES_API_KEY' && live.workspacesKeys.includes(item.value)) {
233
+ out.add(`live_workspaces_key:${name}`);
234
+ }
235
+ if (name === 'MCP_AUDIENCE' && live.mcpAudiences?.includes(item.value)) {
236
+ out.add(`live_mcp_audience:${name}`);
237
+ }
238
+ if (name === 'MCP_RESOURCE' && live.mcpResources?.includes(item.value)) {
239
+ out.add(`live_mcp_resource:${name}`);
240
+ }
241
+ }
214
242
  if (item.class === 'literal')
215
243
  continue;
216
- const shared = isSharedWithProd(name);
244
+ const shared = isSharedWithProd(name) && !(input.signIn?.customers && name === 'AUTH_ORGANIZATION_ID');
217
245
  const counterpart = prodByName.get(name);
218
246
  if (shared) {
219
247
  const same = item.value !== undefined && counterpart?.value !== undefined