@wtfalch/foundry 0.61.0 → 0.63.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 (97) hide show
  1. package/dist/bootstrap/declare.js +4 -1
  2. package/dist/bootstrap/versions.d.ts +2 -0
  3. package/dist/index.d.ts +9 -1
  4. package/dist/index.js +3 -0
  5. package/dist/migrations/0065_native_build_runs.sql +32 -0
  6. package/dist/migrations/0066_org_kosmos.sql +10 -0
  7. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  8. package/dist/migrations/0068_platform_registry.sql +17 -0
  9. package/dist/modules/sign-in.js +1 -1
  10. package/dist/native-build.d.ts +50 -0
  11. package/dist/native-build.js +72 -0
  12. package/dist/org.d.ts +22 -3
  13. package/dist/org.js +25 -2
  14. package/dist/plan/agora.d.ts +1 -1
  15. package/dist/plan/agora.js +4 -0
  16. package/dist/plan/archon-blue-green.d.ts +5 -0
  17. package/dist/plan/archon-blue-green.js +2 -0
  18. package/dist/plan/blue-green-apply.d.ts +5 -0
  19. package/dist/plan/blue-green-apply.js +197 -22
  20. package/dist/plan/blue-green.d.ts +80 -9
  21. package/dist/plan/blue-green.js +115 -14
  22. package/dist/plan/boule.d.ts +1 -1
  23. package/dist/plan/boule.js +2 -0
  24. package/dist/plan/broker-entries.d.ts +9 -0
  25. package/dist/plan/broker-entries.js +29 -0
  26. package/dist/plan/deploy-health.js +9 -2
  27. package/dist/plan/desktop-callback.js +4 -1
  28. package/dist/plan/diff.d.ts +5 -0
  29. package/dist/plan/diff.js +184 -63
  30. package/dist/plan/edge-access.d.ts +2 -3
  31. package/dist/plan/edge-access.js +8 -0
  32. package/dist/plan/executor.d.ts +111 -19
  33. package/dist/plan/executor.js +578 -72
  34. package/dist/plan/index.d.ts +2 -0
  35. package/dist/plan/index.js +1 -0
  36. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  37. package/dist/plan/jobs-scheduler-http.js +11 -0
  38. package/dist/plan/kosmos.d.ts +56 -0
  39. package/dist/plan/kosmos.js +84 -0
  40. package/dist/plan/locked-hostnames.js +5 -2
  41. package/dist/plan/org-names.d.ts +3 -3
  42. package/dist/plan/org-names.js +3 -1
  43. package/dist/plan/org-postgres-migrate.js +1 -0
  44. package/dist/plan/own-limits.d.ts +12 -0
  45. package/dist/plan/own-limits.js +2 -0
  46. package/dist/plan/plan-apply.d.ts +51 -1
  47. package/dist/plan/plan-apply.js +298 -25
  48. package/dist/plan/platform-diff.d.ts +6 -0
  49. package/dist/plan/platform-diff.js +74 -0
  50. package/dist/plan/platform-org.d.ts +2 -0
  51. package/dist/plan/platform-providers.d.ts +46 -1
  52. package/dist/plan/platform-providers.js +162 -1
  53. package/dist/plan/platform-registry-keys.d.ts +58 -0
  54. package/dist/plan/platform-registry-keys.js +69 -0
  55. package/dist/plan/platform-registry.d.ts +67 -0
  56. package/dist/plan/platform-registry.js +298 -0
  57. package/dist/plan/post-logout-uris.d.ts +1 -1
  58. package/dist/plan/post-logout-uris.js +5 -2
  59. package/dist/plan/providers-readers.js +20 -1
  60. package/dist/plan/release-host.d.ts +31 -2
  61. package/dist/plan/release-host.js +114 -6
  62. package/dist/plan/stage-copy-host.d.ts +104 -0
  63. package/dist/plan/stage-copy-host.js +259 -0
  64. package/dist/plan/stage-copy.js +44 -0
  65. package/dist/plan/stage-env.d.ts +9 -0
  66. package/dist/plan/stage-env.js +38 -0
  67. package/dist/plan/stage-guards.d.ts +57 -1
  68. package/dist/plan/stage-guards.js +106 -0
  69. package/dist/plan/stage-isolation.js +56 -0
  70. package/dist/plan/stage-services.d.ts +93 -0
  71. package/dist/plan/stage-services.js +153 -0
  72. package/dist/plan/stage-signin.d.ts +152 -0
  73. package/dist/plan/stage-signin.js +133 -0
  74. package/dist/plan/stage-sink.d.ts +2 -0
  75. package/dist/plan/stage-sink.js +6 -1
  76. package/dist/platform.d.ts +7 -1
  77. package/dist/platform.js +1 -0
  78. package/dist/release/decide.d.ts +12 -0
  79. package/dist/release/decide.js +37 -2
  80. package/dist/release/migration-compatibility.d.ts +66 -0
  81. package/dist/release/migration-compatibility.js +340 -0
  82. package/dist/release/run.d.ts +71 -1
  83. package/dist/release/run.js +92 -8
  84. package/dist/release/state.d.ts +14 -1
  85. package/dist/release/state.js +43 -0
  86. package/dist/release/types.d.ts +20 -0
  87. package/dist/release/types.js +2 -0
  88. package/dist/requested-changes.d.ts +11 -2
  89. package/dist/requested-changes.js +13 -1
  90. package/dist/slug.d.ts +1 -1
  91. package/dist/slug.js +11 -2
  92. package/dist/store/postgres.js +21 -0
  93. package/dist/store/schema.d.ts +87 -0
  94. package/dist/store/schema.js +10 -0
  95. package/package.json +3 -2
  96. package/versions.digests.json +1 -0
  97. package/versions.json +1 -0
package/dist/plan/diff.js CHANGED
@@ -1,5 +1,6 @@
1
- import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
1
+ import { VERSIONS } from '../bootstrap/versions.js';
2
+ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
3
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
3
4
  import { entryHasDatabase } from '../worker-app-config.js';
4
5
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, entryClientIdScopeKey, withRecordedMessagingProject, } from './agora.js';
5
6
  import { planAppsDbRoute } from './apps-db-route.js';
@@ -26,6 +27,7 @@ import { gateIdRecordKey, hasWildcardGate, planGates } from './gates.js';
26
27
  import { githubSourceResourceKey, planGithubSource } from './github-source.js';
27
28
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
28
29
  import { planKeysRegistrar } from './keys-registrar.js';
30
+ import { desiredKosmosEnv, kosmosEnvDrift } from './kosmos.js';
29
31
  import { APP_GONE_KIND } from './live.js';
30
32
  import { lockedHostnames } from './locked-hostnames.js';
31
33
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, ownCustomersAppState, pendingRemovalHosts, } from './login-host.js';
@@ -351,7 +353,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
351
353
  const chat = planChat(org, byKey, claimed, recordedIds, agoraHost, issuer.step !== null);
352
354
  if (chat.step)
353
355
  plannedSteps.push(chat.step);
354
- const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady, versionPin?.calls);
356
+ const calls = planCalls(org, byKey, claimed);
355
357
  if (calls.step)
356
358
  plannedSteps.push(calls.step);
357
359
  // An own org's media server, before the calls service that reads its addresses.
@@ -567,6 +569,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
567
569
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
568
570
  accessProviderId: platformReadiness.accessProviderId ?? null,
569
571
  ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
572
+ ...(options.stageGuardPorts ? { stageGuardPorts: options.stageGuardPorts } : {}),
570
573
  appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
571
574
  p.action.kind === 'app' &&
572
575
  p.action.key === appStepKey(org, entryKey)),
@@ -711,7 +714,16 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
711
714
  }, customerGrantsShape);
712
715
  if (boule.step)
713
716
  plannedSteps.push(boule.step);
714
- // Boule's and Agora's blue/green: after their own steps, which make the app it copies.
717
+ // foundry#949: the company's kosmos, only once it turns it on. Its fixed ZITADEL client first, the same
718
+ // reason Boule's and Agora's do: `coolifyKosmos` reads this step's recorded client id.
719
+ const kosmosClient = planKosmosClient(org, byKey, claimed);
720
+ const kosmosGuarded = guardApp(kosmosClient);
721
+ if (kosmosGuarded)
722
+ plannedSteps.push(kosmosGuarded);
723
+ const kosmos = planKosmos(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
724
+ if (kosmos.step)
725
+ plannedSteps.push(kosmos.step);
726
+ // Boule's, Agora's and kosmos's blue/green: after their own steps, which make the app it copies.
715
727
  plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'last').map(blueGreenDraft()));
716
728
  // Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
717
729
  const bouleOwners = planBouleOwners(org, recordedIds, (byKey.get(resourceKey('coolify', 'boule', org.id))?.config.env ?? null));
@@ -977,6 +989,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
977
989
  if (boule.mailBlocked) {
978
990
  unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
979
991
  }
992
+ if (kosmos.blocked)
993
+ unmanaged.push({ description: kosmos.blocked, key: 'coolify:kosmos' });
980
994
  // Same for an ai key minted but never delivered: planning a new mint would
981
995
  // orphan a second live key.
982
996
  if (aiKey.blocked)
@@ -1769,8 +1783,8 @@ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned)
1769
1783
  * actually need it -- CMS's, chat's and calls' entries follow their own
1770
1784
  * on/off switch, the same ones this file already gates their own
1771
1785
  * `coolify:*` steps on (`org.ts`'s `SiteConfig.enabled`, `ChatConfig.enabled`,
1772
- * `CallsConfig.enabled`); Boule's is unconditional, matching `org.ts`'s own
1773
- * "every org gets one" rule; the app schema follows whether any
1786
+ * `CallsConfig.enabled`); kosmos follows its own switch too (`KosmosApp.enabled`, foundry#949); Boule's is
1787
+ * unconditional, matching `org.ts`'s own "every org gets one" rule; the app schema follows whether any
1774
1788
  * `hosting.entries` entry declares `database: true` (`coolifyDatabase`'s own
1775
1789
  * gate) rather than `hosting.enabled` alone, since a hosting org with no
1776
1790
  * database-bearing entry has nothing for this schema to hold.
@@ -1778,6 +1792,8 @@ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned)
1778
1792
  function orgServiceSchemaEnabled(org, schema) {
1779
1793
  if (schema.schema === 'cms')
1780
1794
  return org.site.enabled;
1795
+ if (schema.schema === 'kosmos')
1796
+ return kosmosOn(org);
1781
1797
  if (schema.schema.startsWith('chat_'))
1782
1798
  return org.chat.enabled;
1783
1799
  if (schema.schema.startsWith('calls_'))
@@ -2197,75 +2213,47 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
2197
2213
  * matrix-authentication-service, entirely separate from chat's
2198
2214
  * (`plan/calls-matrix.ts`'s own top doc comment: a call guest must never gain
2199
2215
  * chat membership). Unlike chat, off by default: `org.ts`'s
2200
- * `CallsConfig.enabled` doc comment. Same on/off, park-not-delete shape as
2201
- * {@link planApp}/{@link planDatabase}/{@link planTunnel} below
2202
- * (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
2203
- * MAS, never either database (`executor.ts`'s `coolifyCalls`).
2216
+ * `CallsConfig.enabled` doc comment.
2204
2217
  *
2205
- * #177: a first-ever provision (no live resource yet) is blocked, not
2206
- * planned, while `platformReadiness.callsReady` is false -- an org's own
2207
- * Matrix stack is wired to the shared calls SFU (#122/#127), so there is
2208
- * nothing for it to authenticate calls against until the platform's own
2209
- * `sfu:livekit` step is declared. Parking, and an already-provisioned stack,
2210
- * are unaffected.
2218
+ * #430: calls run transit-only now (calls#93's `CALLS_ENCRYPTION=transit`),
2219
+ * so the calls service no longer talks to a per-org Matrix stack and this
2220
+ * never provisions a new one, nor re-provisions a parked one -- the live
2221
+ * resource is still claimed, so it is never reported unmanaged. An org
2222
+ * whose stack is already live and enabled is left exactly as it is;
2223
+ * removing it is a separate, human-confirmed step. Turning `calls.enabled`
2224
+ * off still parks an already-live stack unchanged (`executor.ts`'s
2225
+ * `coolifyCalls`, #7 corrections 1): data is retained, not deleted.
2211
2226
  */
2212
- function planCalls(org, byKey, claimed, callsReady, synapseTag) {
2227
+ function planCalls(org, byKey, claimed) {
2213
2228
  const key = resourceKey('coolify', 'calls', org.id);
2214
- if (org.calls.enabled && !byKey.get(key) && !callsReady) {
2215
- return {
2216
- step: null,
2217
- blocked: ownsServices(org)
2218
- ? `${org.slug}'s calls Matrix stack is blocked, not planned: its own media server (coolify:own-sfu) is not applied yet`
2219
- : `${org.slug}'s calls Matrix stack is blocked, not planned: the platform owner's calls SFU (the platform's) is off`,
2220
- };
2221
- }
2222
- // #248: the two images this stack's own apply would run, checked the same
2223
- // way the platform's SFU checks LiveKit/cloudflared and ZITADEL checks its
2224
- // own tag -- a re-apply of the same declaration must not silently run a
2225
- // different version with no diff shown anywhere.
2226
- if (org.calls.enabled && !byKey.get(key)) {
2227
- const synapseBlocked = floatingTagBlocked('Synapse', synapseTag ?? VERSIONS.synapse, 'v1.161.0');
2228
- if (synapseBlocked) {
2229
- return {
2230
- step: null,
2231
- blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${synapseBlocked}`,
2232
- };
2233
- }
2234
- const masBlocked = floatingTagBlocked('matrix-authentication-service', VERSIONS.mas, '1.24.0');
2235
- if (masBlocked) {
2236
- return {
2237
- step: null,
2238
- blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${masBlocked}`,
2239
- };
2240
- }
2241
- }
2229
+ const live = byKey.get(key);
2230
+ if (live)
2231
+ claimed.add(key);
2232
+ const liveEnabled = live !== undefined && live.config.enabled !== false;
2233
+ if (org.calls.enabled && !liveEnabled)
2234
+ return { step: null, blocked: null };
2242
2235
  const toggle = planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`);
2243
- // A held Synapse tag rides in the provisioning step; a parking step carries none.
2244
- if (toggle && synapseTag && toggle.action.desired.enabled === true) {
2245
- toggle.action.desired = { ...toggle.action.desired, synapseTag };
2246
- }
2247
2236
  return { step: toggle, blocked: null };
2248
2237
  }
2249
2238
  /**
2250
2239
  * The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
2251
- * `packages/service`, deployed alongside the calls Matrix stack {@link planCalls}
2252
- * provisions and wired to it and to the shared LiveKit SFU (foundry#122/#127).
2253
- * Gated on the same `org.calls.enabled` flag as the Matrix stack, not a flag
2254
- * of its own: a calls service with no Matrix stack behind it has nothing to
2255
- * authenticate a guest against, and a Matrix stack alone answers no browser,
2256
- * so the two are declared as one on/off switch even though they are two
2257
- * planner steps. Same on/off, park-not-delete shape as {@link planCalls}
2258
- * (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
2259
- * never its database (`executor.ts`'s `coolifyCallsService`).
2240
+ * `packages/service`, wired to the shared LiveKit SFU (foundry#122/#127).
2241
+ * Same on/off shape as {@link planCalls} (`planHostingToggle`, #7
2242
+ * corrections 1) -- turning it off parks the app, never its database
2243
+ * (`executor.ts`'s `coolifyCallsService`).
2260
2244
  *
2261
- * #177: silently plans nothing (no second blocked message) while the Matrix
2262
- * stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
2263
- * already reports the platform's shared SFU as missing once.
2245
+ * #430: calls run transit-only (calls#93), so this no longer rides behind
2246
+ * the Matrix stack {@link planCalls} used to provision -- there is none to
2247
+ * ride behind any more -- and reports the shared SFU as missing itself
2248
+ * (#177) rather than relying on `planCalls`'s now-removed report.
2264
2249
  */
2265
2250
  function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlocked, tokenHost) {
2266
2251
  const key = resourceKey('coolify', 'calls-service', org.id);
2267
- if (org.calls.enabled && !byKey.get(key) && !callsReady) {
2268
- return { step: null, blocked: null };
2252
+ if (org.calls.enabled && !byKey.get(key) && !callsReady && !ownsServices(org)) {
2253
+ return {
2254
+ step: null,
2255
+ blocked: `${org.slug}'s calls service is blocked, not planned: the platform owner's calls SFU (the platform's) is off`,
2256
+ };
2269
2257
  }
2270
2258
  // An own org's service names its own media server, so it waits for it even when already provisioned.
2271
2259
  if (org.calls.enabled && ownsServices(org) && !callsReady) {
@@ -3081,6 +3069,137 @@ function planBouleClient(org, byKey, claimed) {
3081
3069
  },
3082
3070
  };
3083
3071
  }
3072
+ /**
3073
+ * foundry#949: the fixed ZITADEL web client `kosmos`, once the company turns kosmos on. The same shape as
3074
+ * {@link planBouleClient} and {@link planAgoraClient}; `kosmosOn(org)` is false before any client is planned.
3075
+ */
3076
+ function planKosmosClient(org, byKey, claimed) {
3077
+ if (!kosmosOn(org))
3078
+ return null;
3079
+ const key = resourceKey('zitadel', 'app', 'kosmos');
3080
+ const live = byKey.get(key);
3081
+ if (live)
3082
+ claimed.add(key);
3083
+ const desired = { appType: 'web' };
3084
+ if (!live) {
3085
+ return {
3086
+ action: { system: 'zitadel', kind: 'app', key: 'kosmos', desired },
3087
+ step: {
3088
+ description: `provision the web ZITADEL app 'kosmos' for ${org.slug}'s kosmos sign-in`,
3089
+ destroys: false,
3090
+ migrates: false,
3091
+ },
3092
+ };
3093
+ }
3094
+ if (live.config.appType === 'web')
3095
+ return null;
3096
+ return {
3097
+ action: {
3098
+ system: 'zitadel',
3099
+ kind: 'app',
3100
+ key: 'kosmos',
3101
+ desired: { ...desired, recreate: true },
3102
+ },
3103
+ step: {
3104
+ description: `recreate ZITADEL app 'kosmos' for ${org.slug} as web (was ${String(live.config.appType)})`,
3105
+ destroys: true,
3106
+ migrates: false,
3107
+ },
3108
+ };
3109
+ }
3110
+ /**
3111
+ * foundry#949: the org's own kosmos, a per-org singleton keyed by the org id under its own `kosmos` kind, built
3112
+ * from https://github.com/wtfalch/kosmos's root Dockerfile (`executor.ts`'s `coolifyKosmos`), with a database of
3113
+ * its own. Unlike Boule and Agora, kosmos is set up only once the company turns it on (`kosmosOn(org)`); while
3114
+ * it is off, an org that never had it plans nothing, and a live app is parked (stopped, database kept), the
3115
+ * same rule a switched-off service always follows here.
3116
+ *
3117
+ * Once live, a step is planned again whenever the live app's sign-in env lacks a desired key or holds a
3118
+ * different value (`kosmos.ts`'s `desiredKosmosEnv`, the function the executor writes from). The only host
3119
+ * value is the issuer address (the one Agora and Boule sign in at); without it kosmos is blocked, not planned,
3120
+ * so every other step still applies.
3121
+ */
3122
+ function planKosmos(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
3123
+ if (!kosmosOn(org)) {
3124
+ // Switched off: park a live app (stop it, keep the database), like CMS and calls. A stopped app is
3125
+ // already parked, so the plan reaches empty.
3126
+ const parked = planHostingToggle('coolify', 'kosmos', false, org, byKey, claimed, () => `provision ${org.slug}'s kosmos`, () => `park ${org.slug}'s kosmos (stops serving; its database is retained, not deleted)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
3127
+ return { step: parked, blocked: null };
3128
+ }
3129
+ const key = resourceKey('coolify', 'kosmos', org.id);
3130
+ const live = byKey.get(key);
3131
+ if (live)
3132
+ claimed.add(key);
3133
+ // Never truncated: Postgres cuts an identifier at 63 bytes, and a DNS label
3134
+ // over 63 is refused by Coolify and Cloudflare -- the same check `planBoule` makes.
3135
+ const tooLong = [
3136
+ [`${org.slug}-kosmos`, 'the kosmos host label and app name'],
3137
+ [`${org.slug}_kosmos_db_rt`, 'the kosmos database role'],
3138
+ ].filter(([name]) => name.length > 63);
3139
+ if (tooLong.length > 0) {
3140
+ return {
3141
+ step: null,
3142
+ blocked: `${org.slug}'s kosmos is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
3143
+ };
3144
+ }
3145
+ if (otherSlugs.includes(`${org.slug}-kosmos`)) {
3146
+ return {
3147
+ step: null,
3148
+ blocked: `${org.slug}'s kosmos is blocked, not planned: another declared org has the slug '${org.slug}-kosmos', which is this kosmos's hostname`,
3149
+ };
3150
+ }
3151
+ if (!issuer) {
3152
+ return {
3153
+ step: null,
3154
+ blocked: `${org.slug}'s kosmos is blocked, not planned: the host supplies no issuer`,
3155
+ };
3156
+ }
3157
+ const action = {
3158
+ system: 'coolify',
3159
+ kind: 'kosmos',
3160
+ key: org.id,
3161
+ desired: { issuer },
3162
+ };
3163
+ if (!live) {
3164
+ return {
3165
+ step: {
3166
+ action,
3167
+ step: { description: `provision ${org.slug}'s kosmos`, destroys: false, migrates: false },
3168
+ },
3169
+ blocked: null,
3170
+ };
3171
+ }
3172
+ const liveEnv = (live.config.env ?? {});
3173
+ const { env } = desiredKosmosEnv(org, recordedIds, issuer);
3174
+ const drift = kosmosEnvDrift(env, liveEnv);
3175
+ // Switched back on while the app is still stopped: the step starts it (`coolifyKosmos` starts a stopped app).
3176
+ if (/^(exited|stopped)/.test(String(live.config.status ?? ''))) {
3177
+ return {
3178
+ step: {
3179
+ action,
3180
+ step: {
3181
+ description: `start ${org.slug}'s kosmos again (it was parked)`,
3182
+ destroys: false,
3183
+ migrates: false,
3184
+ },
3185
+ },
3186
+ blocked: null,
3187
+ };
3188
+ }
3189
+ if (drift.length === 0)
3190
+ return { step: null, blocked: null };
3191
+ return {
3192
+ step: {
3193
+ action,
3194
+ step: {
3195
+ description: `update ${org.slug}'s kosmos config (${drift.join(', ')} missing or different)`,
3196
+ destroys: false,
3197
+ migrates: false,
3198
+ },
3199
+ },
3200
+ blocked: null,
3201
+ };
3202
+ }
3084
3203
  /**
3085
3204
  * C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
3086
3205
  * Boule the host can mint it for; the customers directory credential only while `customers.enabled`. Each
@@ -3269,6 +3388,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3269
3388
  drift.push(BOULE_BRIDGE_SECRET_ENV);
3270
3389
  if (!liveEnv[BOULE_OWNER_SEED_SECRET_ENV])
3271
3390
  drift.push(BOULE_OWNER_SEED_SECRET_ENV);
3391
+ if (!liveEnv[NATIVE_BUILD_KEY_ENV])
3392
+ drift.push(NATIVE_BUILD_KEY_ENV);
3272
3393
  // Secrets written but Boule not restarted since: plan the step so it restarts, minting nothing.
3273
3394
  if (platformOwner && !recordedIds[BOULE_SECRETS_RESTARTED_SCOPE_KEY]) {
3274
3395
  drift.push('restart after the peer password and bridge secret');
@@ -87,9 +87,8 @@ export interface AccessPort {
87
87
  }>;
88
88
  replaceGateHostnames(gateId: string, hostnames: string[]): Promise<void>;
89
89
  /**
90
- * Replaces the whole list of people who pass a gate; nothing else about the gate changes. Edge's platform
91
- * client has no such call yet, so {@link createEdgeAccess} does not implement it: a host that wants a
92
- * workspace's testers to change supplies a port that does (edge's company `gates.setPeople`).
90
+ * Replaces the whole list of people who pass a gate; nothing else about the gate changes. The edge adapter
91
+ * implements it with edge's platform `setGatePeople`; a host may supply its own port instead.
93
92
  */
94
93
  setGatePeople?(gateId: string, emails: string[]): Promise<void>;
95
94
  /** Deletes the gate with its application, policy and list; a gate already gone is done. */
@@ -35,6 +35,7 @@ export function createEdgeAccess(client, options = {}) {
35
35
  createGate: (input) => retrying(() => client.createGate(input)),
36
36
  replaceGateHostnames: (id, input) => retrying(() => client.replaceGateHostnames(id, input)),
37
37
  deleteGate: (id) => retrying(() => client.deleteGate(id)),
38
+ setGatePeople: (id, input) => retrying(() => client.setGatePeople(id, input)),
38
39
  };
39
40
  const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
40
41
  /** app id -> a seen live app with no edge row yet. */
@@ -166,6 +167,13 @@ export function createEdgeAccess(client, options = {}) {
166
167
  async replaceGateHostnames(gateId, hostnames) {
167
168
  await writeSlot(() => access.replaceGateHostnames(gateId, { hostnames }));
168
169
  },
170
+ ...(typeof client.setGatePeople === 'function'
171
+ ? {
172
+ async setGatePeople(gateId, emails) {
173
+ await writeSlot(() => access.setGatePeople(gateId, { emails }));
174
+ },
175
+ }
176
+ : {}),
169
177
  async readiness() {
170
178
  const { authDomain } = await access.readiness();
171
179
  return { authDomain };
@@ -21,7 +21,8 @@ import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor }
21
21
  import type { EdgePortName } from './platform-org.js';
22
22
  import type { RequestHeaderPort } from './platform-providers.js';
23
23
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
24
- import { type StageProbePort } from './stage-guards.js';
24
+ import { type StageCopyHostConfig } from './stage-copy-host.js';
25
+ import { type ReleasePreviewMembers, type StageProbePort } from './stage-guards.js';
25
26
  import type { WorkerAppDatabasePort } from './worker-app-database.js';
26
27
  import { type KeysCustodyPort, type WorkerAppPort } from './worker-app.js';
27
28
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
@@ -1411,6 +1412,11 @@ export interface FilesOrganisationPort {
1411
1412
  * the list is a no-op.
1412
1413
  */
1413
1414
  ensureAllowedOrigin(organisationId: string, origin: string): Promise<void>;
1415
+ /**
1416
+ * Deletes an organisation with its keys. Optional: used only by org destroy for stage's own organisation
1417
+ * (`<org>-stage`); a port without it leaves that organisation, which destroy then reports as blocked.
1418
+ */
1419
+ deleteOrganisation?(organisationId: string): Promise<void>;
1414
1420
  }
1415
1421
  /**
1416
1422
  * Provisions one org's organisation on AI and switches its AI use on (#80),
@@ -1519,6 +1525,11 @@ export interface AiOrganisationPort {
1519
1525
  keyId: string | null;
1520
1526
  limitMicros: string;
1521
1527
  }): Promise<unknown>;
1528
+ /**
1529
+ * Deletes an organisation with its keys and budget. Optional: used only by org destroy for stage's own
1530
+ * organisation (`<org>-stage`); a port without it leaves that organisation, which destroy then reports as blocked.
1531
+ */
1532
+ deleteOrganisation?(organisationId: string): Promise<void>;
1522
1533
  }
1523
1534
  /**
1524
1535
  * #317 (F7): the edge service's own management API
@@ -1634,6 +1645,11 @@ export interface JobsSchedulerPort {
1634
1645
  secret?: string;
1635
1646
  syncKey?: string;
1636
1647
  }>;
1648
+ /**
1649
+ * Removes a project and its keys (`DELETE /admin/projects/<name>`). Optional: used only by org destroy for
1650
+ * stage's own project (`<org>-stage`); a port without it leaves that project, which destroy then reports as blocked.
1651
+ */
1652
+ deleteProject?(name: string): Promise<void>;
1637
1653
  }
1638
1654
  /**
1639
1655
  * #177: where the org steps' files, AI and jobs ports come from when the host
@@ -1746,6 +1762,18 @@ export interface FoundryStepExecutorPorts {
1746
1762
  * one the probes are reported as not run, and the stage copy stays blocked.
1747
1763
  */
1748
1764
  stageProbes?: StageProbePort;
1765
+ /**
1766
+ * The stage copy (`copy_prod_to_stage`, run by the release tick). The host supplies the dump download and the
1767
+ * backup storage; foundry builds the restore port itself and reads production's address live from Coolify.
1768
+ * Without it a copy request is refused with `no-copy-port`. The host needs `pg_restore` (Postgres 16) on PATH.
1769
+ */
1770
+ stageCopy?: StageCopyHostConfig;
1771
+ /**
1772
+ * Who may open an org's stage gate besides its owners: the people holding `releases:preview` in `orgId`. Read on
1773
+ * each release tick (10 s limit) and when the gate is made; a failed read leaves the gate as it is. Without it
1774
+ * the gate keeps the owners it was made with.
1775
+ */
1776
+ releasePreviewMembers?: ReleasePreviewMembers;
1749
1777
  /**
1750
1778
  * F4.ratelimits: overrides the rate limiting rule calls, which otherwise go through
1751
1779
  * `edge.ratelimits` (see {@link EdgePort.ratelimits}). Kept for a host or a test that supplies its own.
@@ -1966,6 +1994,15 @@ export declare const BOULE_REPO = "wtfalch/boule";
1966
1994
  export declare const BOULE_APP_DOCKERFILE = "/Dockerfile";
1967
1995
  /** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
1968
1996
  export declare const BOULE_PORT = 3000;
1997
+ /**
1998
+ * foundry#949: kosmos (https://github.com/wtfalch/kosmos), bootstrapped from the same `wtfalch/package-template`
1999
+ * service shape Boule is, with its own Dockerfile at the repository root, serving on 3000 (`EXPOSE 3000`) and
2000
+ * running its migrations through `apps/host/scripts/migrate.mjs` before it serves, the same shape Boule's
2001
+ * `migrate-and-start.sh` follows.
2002
+ */
2003
+ export declare const KOSMOS_REPO = "wtfalch/kosmos";
2004
+ export declare const KOSMOS_APP_DOCKERFILE = "/Dockerfile";
2005
+ export declare const KOSMOS_PORT = 3000;
1969
2006
  export declare const LOG_DIR = "/app/logs";
1970
2007
  /** The company's Coolify destination (its Docker network `co-<slug>`), recorded when its first resource is created. */
1971
2008
  export declare const COOLIFY_DESTINATION_SCOPE_KEY = "coolify_destination";
@@ -2427,8 +2464,18 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2427
2464
  * blue copy is not recorded. The health token is only read here, never minted.
2428
2465
  */
2429
2466
  releaseHost(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
2467
+ /**
2468
+ * The copy's ports for one subject. Production's database is the org's (`coolify:database:<org id>`) for an
2469
+ * entry and Boule's own for Boule; Agora has none, so its copy is refused with `no-production-database`.
2470
+ */
2471
+ private stageCopyFor;
2430
2472
  /** Whether the stage mail sink app is stopped or gone, read live. A failed read throws; the caller reads that as up. */
2431
2473
  stageSinkDown(appId: string): Promise<boolean>;
2474
+ /** Which stage-guard ports this executor has, for the planner (they decide the guard names in the applied hash). */
2475
+ stageGuardPorts(): {
2476
+ probes: boolean;
2477
+ access: boolean;
2478
+ };
2432
2479
  /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2433
2480
  blueGreenServers(): Promise<BlueGreenServer[]>;
2434
2481
  /**
@@ -2496,6 +2543,25 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2496
2543
  * bypass or service-token rule there would open stage, so no gate is made and the guard stays unrecorded.
2497
2544
  */
2498
2545
  private stageGuards;
2546
+ /**
2547
+ * One release tick for one subject: its recorded stage gate's list follows the people holding
2548
+ * `releases:preview`, owners always staying (`syncStageGateMembers`). Never throws.
2549
+ */
2550
+ syncStageGateMembers(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext): Promise<void>;
2551
+ /**
2552
+ * Stage's own files organisation, ai organisation and jobs project (`plan/stage-services.ts`), each named
2553
+ * for its subject (`<org id>-stage-<subject id>`, `<slug>-stage-<subject id>`: one per subject, as the recorded keys are) and built from the same ports production's steps use. A service whose port is
2554
+ * missing or not ready answers `available() === false`, and stage then records it absent and mints nothing.
2555
+ */
2556
+ private stageServicesPort;
2557
+ /**
2558
+ * Stage's own sign-in (`plan/stage-signin.ts`): a client named `<production client>-stage` on production's
2559
+ * sign-in organisation and project with redirect URIs on the stage host only, a broker entry
2560
+ * `<production entry>-stage` with a key of its own, and that key's held ref. `{ required: false }` for an entry
2561
+ * that does not sign in. `undefined`, with one fixed log line saying why, when something it needs is missing:
2562
+ * the guard then stays unrecorded and the copy waits. Platform-archon is not covered yet.
2563
+ */
2564
+ private stageSignInPort;
2499
2565
  private coolifyApp;
2500
2566
  /**
2501
2567
  * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
@@ -2702,7 +2768,20 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2702
2768
  private ensurePeerPassword;
2703
2769
  /** Same rule for the secret Archon's invite form sends to Boule. */
2704
2770
  private ensureBridgeSecret;
2771
+ /**
2772
+ * `minLength`, when given, also counts a present-but-empty value as not set -- the same guard
2773
+ * {@link ensureAuthAppKey} already applies for `AUTH_APP_KEY`, now reusable here instead of a
2774
+ * third, bare copy of this check (foundry#970, thermo note 1 in #957). Omitted, the three existing
2775
+ * callers below keep their original behaviour.
2776
+ */
2705
2777
  private mintIfAbsent;
2778
+ /**
2779
+ * Every company's Boule, F3/#970: `NATIVE_BUILD_KEY` is what Boule presents to Archon's
2780
+ * native-build route (estate#262) to ask it to dispatch `build-app.yml`. Minted once, like
2781
+ * {@link ensureAuthAppKey}, and never rotated; a present-but-empty value (the bug
2782
+ * `mintNativeBuildKey`'s own read-then-generate had, see #957) is treated as not set.
2783
+ */
2784
+ private ensureNativeBuildKey;
2706
2785
  /**
2707
2786
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
2708
2787
  * only the first time (task brief), never regenerated or overwritten, so a
@@ -3145,16 +3224,18 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3145
3224
  private coolifyChatRekey;
3146
3225
  /**
3147
3226
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
3148
- * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
3149
- * {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
3150
- * Static config checks run first, before any Coolify call (#128's own
3151
- * carried-over PR 344 review fix, restated in #145's own issue body): the
3152
- * calls Matrix stack must already be recorded, its MAS app must already
3153
- * carry its client id/secret, and the shared SFU's LiveKit credentials must
3154
- * already be resolvable -- none of the three is minted or guessed here.
3155
- * Park stops the app only, the same park-not-delete rule
3156
- * {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
3157
- * 1); its own database is never touched from that branch.
3227
+ * `wtfalch/calls`'s `packages/service`, wired to the shared LiveKit SFU
3228
+ * (#122/#127). #430: calls run transit-only (calls#93's
3229
+ * `CALLS_ENCRYPTION=transit`), so this no longer waits on or wires a
3230
+ * per-org Matrix stack at all -- an org whose stack is already live from
3231
+ * before this keeps running it, unused, until a human removes it
3232
+ * separately. Static config checks run first, before any Coolify call
3233
+ * (#128's own carried-over PR 344 review fix, restated in #145's own
3234
+ * issue body): the shared SFU's LiveKit credentials must already be
3235
+ * resolvable -- not minted or guessed here. Park stops the app only, the
3236
+ * same park-not-delete rule {@link coolifyCalls}/{@link coolifyApp}
3237
+ * already follow (#7 corrections 1); its own database is never touched
3238
+ * from that branch.
3158
3239
  */
3159
3240
  private coolifyCallsService;
3160
3241
  /**
@@ -3187,14 +3268,6 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3187
3268
  * env and mints nothing.
3188
3269
  */
3189
3270
  private callsTokenEnvs;
3190
- /**
3191
- * Reads back the calls Matrix stack's MAS client id/secret from the MAS
3192
- * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
3193
- * either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
3194
- * here means that stack has not finished provisioning yet, not something
3195
- * this step can fix on its own.
3196
- */
3197
- private readCallsMasClientCredentials;
3198
3271
  /**
3199
3272
  * `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
3200
3273
  * never rotated, the same rule {@link ensureAuthCookieSecret} follows for
@@ -3327,6 +3400,25 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3327
3400
  * shape {@link coolifyAgora}'s own doc comment explains.
3328
3401
  */
3329
3402
  private coolifyBoule;
3403
+ /**
3404
+ * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
3405
+ * (https://github.com/wtfalch/kosmos) as one more Coolify application, from
3406
+ * that repo's root Dockerfile ({@link KOSMOS_REPO}), with a database of its
3407
+ * own, and pushes its sign-in env. The same shape as {@link coolifyBoule},
3408
+ * trimmed: kosmos is opt-in (`kosmosOn(org)` guards every caller, so this is
3409
+ * never reached while it is off), has no mail, no owner seeding and no
3410
+ * Archon credential.
3411
+ *
3412
+ * The sign-in env comes from `plan/kosmos.ts`'s `desiredKosmosEnv`, the same
3413
+ * function the plan compares the live app against; it never writes a
3414
+ * partial set. The database is `<slug>-kosmos-db`, split the same way
3415
+ * Boule's is: the superuser URL as `DATABASE_URL_OWNER` and a `<dbName>_rt`
3416
+ * role's URL as `DATABASE_URL`, both through `pushDatabaseUrl`.
3417
+ *
3418
+ * #504 (same reason {@link coolifyBoule}'s own comment gives): a brand new
3419
+ * app is never restarted, but it is deployed once ({@link KOSMOS_DEPLOYED_SCOPE_KEY}).
3420
+ */
3421
+ private coolifyKosmos;
3330
3422
  /**
3331
3423
  * C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
3332
3424
  * when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is