@wtfalch/foundry 0.41.2 → 0.43.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 (71) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +7 -3
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0054_org_signin_in_app.sql +6 -0
  5. package/dist/migrations/0055_release_requests.sql +20 -0
  6. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  7. package/dist/org.d.ts +30 -0
  8. package/dist/org.js +52 -1
  9. package/dist/plan/agora.d.ts +4 -1
  10. package/dist/plan/agora.js +16 -2
  11. package/dist/plan/archon-blue-green.d.ts +54 -0
  12. package/dist/plan/archon-blue-green.js +72 -0
  13. package/dist/plan/blue-green-apply.d.ts +82 -0
  14. package/dist/plan/blue-green-apply.js +360 -0
  15. package/dist/plan/blue-green.d.ts +223 -0
  16. package/dist/plan/blue-green.js +444 -0
  17. package/dist/plan/boule.d.ts +5 -2
  18. package/dist/plan/boule.js +14 -2
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/coolify-previews.d.ts +10 -0
  22. package/dist/plan/coolify-previews.js +14 -0
  23. package/dist/plan/diff.d.ts +6 -0
  24. package/dist/plan/diff.js +123 -11
  25. package/dist/plan/edge-ratelimits.d.ts +5 -0
  26. package/dist/plan/edge-ratelimits.js +16 -6
  27. package/dist/plan/executor.d.ts +79 -0
  28. package/dist/plan/executor.js +527 -31
  29. package/dist/plan/login-host.d.ts +23 -2
  30. package/dist/plan/login-host.js +30 -5
  31. package/dist/plan/own-diff.js +6 -0
  32. package/dist/plan/plan-apply.d.ts +51 -0
  33. package/dist/plan/plan-apply.js +222 -0
  34. package/dist/plan/platform-diff.d.ts +9 -0
  35. package/dist/plan/platform-diff.js +28 -1
  36. package/dist/plan/platform-org.d.ts +6 -0
  37. package/dist/plan/platform-org.js +5 -0
  38. package/dist/plan/platform-providers.d.ts +31 -2
  39. package/dist/plan/platform-providers.js +345 -13
  40. package/dist/plan/providers-readers.js +6 -2
  41. package/dist/plan/proxy-labels.d.ts +2 -2
  42. package/dist/plan/proxy-labels.js +12 -4
  43. package/dist/plan/release-host.d.ts +59 -0
  44. package/dist/plan/release-host.js +239 -0
  45. package/dist/plan/remote-database.d.ts +124 -0
  46. package/dist/plan/remote-database.js +373 -0
  47. package/dist/plan/sign-in-archon.d.ts +46 -0
  48. package/dist/plan/sign-in-archon.js +71 -0
  49. package/dist/plan/sign-in-bindings.d.ts +16 -0
  50. package/dist/plan/sign-in-bindings.js +44 -0
  51. package/dist/plan/sign-in.d.ts +135 -0
  52. package/dist/plan/sign-in.js +172 -0
  53. package/dist/platform.d.ts +11 -1
  54. package/dist/platform.js +4 -1
  55. package/dist/release/decide.d.ts +95 -0
  56. package/dist/release/decide.js +242 -0
  57. package/dist/release/health.d.ts +48 -0
  58. package/dist/release/health.js +61 -0
  59. package/dist/release/run.d.ts +40 -0
  60. package/dist/release/run.js +56 -0
  61. package/dist/release/state.d.ts +64 -0
  62. package/dist/release/state.js +126 -0
  63. package/dist/release/types.d.ts +54 -0
  64. package/dist/release/types.js +4 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +62 -3
  67. package/dist/store/schema.d.ts +171 -0
  68. package/dist/store/schema.js +14 -0
  69. package/package.json +1 -1
  70. package/versions.digests.json +2 -1
  71. package/versions.json +2 -1
package/dist/plan/diff.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
3
3
  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, withRecordedMessagingProject, } from './agora.js';
4
+ import { planBlueGreen } from './blue-green.js';
4
5
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
6
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
7
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
@@ -12,7 +13,7 @@ import { planFilesKeys } from './entry-files-key.js';
12
13
  import { hasWildcardGate, planGates } from './gates.js';
13
14
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
14
15
  import { lockedHostnames } from './locked-hostnames.js';
15
- import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
16
+ import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
16
17
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
17
18
  import { mailStoreSwitchChange } from './mail-store.js';
18
19
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
@@ -23,7 +24,9 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
23
24
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
24
25
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
25
26
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
27
+ import { planRemoteDatabase } from './remote-database.js';
26
28
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
29
+ import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
27
30
  import { withStepSystem } from './step-system.js';
28
31
  import { planWorkers } from './worker.js';
29
32
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
@@ -228,7 +231,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
228
231
  // #267: after the ZITADEL org step, before any app's own issuer
229
232
  // application. Apps created later in this run are not in the binding yet;
230
233
  // the next run replans this step and repoints them.
231
- const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
234
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false, platformReadiness.signInLoginReady ?? false);
232
235
  if (loginHost.step)
233
236
  plannedSteps.push(loginHost.step);
234
237
  // #444a: the org's one shared Postgres server and its fixed schema
@@ -455,6 +458,18 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
455
458
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
456
459
  if (edgeReservedLabels.step)
457
460
  plannedSteps.push(edgeReservedLabels.step);
461
+ // Blue/green (`blue-green.ts`): the apps and balancers before the tunnel routes to them, a second
462
+ // server's connector after the tunnel exists.
463
+ const blueGreen = planBlueGreen({
464
+ org,
465
+ recordedIds,
466
+ servers: options.blueGreenServers,
467
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
468
+ appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
469
+ p.action.kind === 'app' &&
470
+ p.action.key === appStepKey(org, entryKey)),
471
+ });
472
+ plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'before').map(blueGreenDraft()));
458
473
  const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
459
474
  // #238: a tunnel that is being provisioned waits for an active zone; parking
460
475
  // one never does.
@@ -481,6 +496,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
481
496
  else if (tunnel) {
482
497
  plannedSteps.push(tunnel);
483
498
  }
499
+ plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'after').map(blueGreenDraft()));
484
500
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
485
501
  // apex domain, so the zone step above must already have run (or the zone
486
502
  // must already exist live) by the time this one's action executes.
@@ -548,6 +564,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
548
564
  (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
549
565
  if (jobsRegistration.step)
550
566
  plannedSteps.push(jobsRegistration.step);
567
+ const remoteDatabase = planRemoteDatabase(org, options.remoteDatabase, recordedIds);
568
+ if (remoteDatabase.step)
569
+ plannedSteps.push(remoteDatabase.step);
551
570
  // F8 (#318), after Agora and Boule, not right after the zone: a route
552
571
  // already bound by a hand-made entry, another entry's route, a taken
553
572
  // script name, or no Workers token all throw at apply, and `runSteps`
@@ -614,6 +633,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
614
633
  // orphan a second live key.
615
634
  if (aiKey.blocked)
616
635
  unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
636
+ unmanaged.push(...blueGreen.unmanaged);
617
637
  unmanaged.push(...filesKeys.blocked);
618
638
  unmanaged.push(...entryAiKeys.blocked);
619
639
  unmanaged.push(...edgeWorkers.blocked);
@@ -643,6 +663,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
643
663
  if (jobsRegistration.blocked) {
644
664
  unmanaged.push({ description: jobsRegistration.blocked, key: 'jobs:registration' });
645
665
  }
666
+ if (remoteDatabase.blocked)
667
+ unmanaged.push(remoteDatabase.blocked);
646
668
  // #177: an org step whose own shared platform service is off or not yet
647
669
  // applied is blocked here, not planned -- the same rule, now driven by
648
670
  // `platformReadiness` instead of a host-supplied port. Each is reported at
@@ -739,6 +761,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
739
761
  orgPostgres,
740
762
  };
741
763
  }
764
+ /** A blue/green plan step as the draft step `computePlanDetail` collects. */
765
+ function blueGreenDraft() {
766
+ return (step) => ({
767
+ action: { system: 'coolify', kind: step.kind, key: step.key, desired: step.desired },
768
+ step: { description: step.description, destroys: false, migrates: false },
769
+ });
770
+ }
742
771
  /** Steps that run against the org's own ZITADEL instance (its projects, apps and broker entries). */
743
772
  const NEEDS_OWN_ZITADEL = new Set([
744
773
  'zitadel:issuer',
@@ -1085,13 +1114,21 @@ export function isPlatformOwnOrg(org, platformOwnerOrgId) {
1085
1114
  * platform's own domain, never plans the shared ZITADEL instance's own login
1086
1115
  * host as its own -- `auth.companyincluded.com` colliding with itself.
1087
1116
  */
1088
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
1117
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, signInLoginReady = false) {
1089
1118
  const none = { step: null, blocked: null };
1090
1119
  // An own org's instance has no shared login host to bind: its issuer is the sign-in service's own.
1120
+ // Only in-app sign-in plans a step for it: the app hosts, through its own in-app login app.
1091
1121
  if (ownsServices(org))
1092
- return none;
1122
+ return planOwnLoginHost(org, recordedIds);
1093
1123
  const key = resourceKey('zitadel', 'login-host', org.id);
1094
1124
  const host = loginHostnameFor(org);
1125
+ // In-app sign-in: each app host gets its own binding, trusted domain and base URI. Until the second
1126
+ // login app that serves `/sign-in` is applied, no app host is planned (a blocked line says so).
1127
+ const wantedApps = inAppApps(org, recordedIds);
1128
+ const apps = signInLoginReady ? wantedApps : [];
1129
+ const inAppWaiting = wantedApps.length > 0 && !signInLoginReady
1130
+ ? `${org.slug}'s in-app sign-in waits for the login app that serves ${SIGN_IN_BASE_PATH} (${SIGN_IN_LOGIN_INTERNAL_NAME}), which the platform has not applied`
1131
+ : null;
1095
1132
  function stepFor() {
1096
1133
  return {
1097
1134
  step: {
@@ -1099,7 +1136,11 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1099
1136
  system: 'zitadel',
1100
1137
  kind: 'login-host',
1101
1138
  key: org.id,
1102
- desired: { host },
1139
+ desired: apps.length > 0 ||
1140
+ recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ||
1141
+ recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY]
1142
+ ? { host, apps }
1143
+ : { host },
1103
1144
  },
1104
1145
  step: {
1105
1146
  description: `set ${org.slug}'s login host to ${host}`,
@@ -1107,7 +1148,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1107
1148
  migrates: false,
1108
1149
  },
1109
1150
  },
1110
- blocked: null,
1151
+ blocked: inAppWaiting,
1111
1152
  };
1112
1153
  }
1113
1154
  // Already applied -- a re-run plans nothing unless the host changed, an app
@@ -1116,11 +1157,14 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1116
1157
  // `zitadelLoginHost` always advances the recorded host to the current one,
1117
1158
  // so a pending removal is the only way to know one is still owed, and
1118
1159
  // nothing else here would replan the step to retry it.
1160
+ const targets = inAppTargets(apps, recordedIds);
1119
1161
  if (recordedIds[key] === host &&
1120
1162
  recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
1121
- loginBindingState(host, loginHostClientIds(org, recordedIds)) &&
1163
+ loginBindingState(host, loginHostClientIds(org, recordedIds, inAppStatePairs(targets.active).map((pair) => pair.clientId))) &&
1164
+ (recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1165
+ (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1122
1166
  pendingRemovalHosts(recordedIds).length === 0) {
1123
- return none;
1167
+ return { step: null, blocked: inAppWaiting };
1124
1168
  }
1125
1169
  // #177: nothing to add a trusted domain to yet -- `planIssuer` already
1126
1170
  // reports the platform's sign-in as blocked, so this step stays silent
@@ -1132,6 +1176,55 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
1132
1176
  // repoint nothing until the port that writes the binding is wired.
1133
1177
  return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor();
1134
1178
  }
1179
+ /**
1180
+ * In-app sign-in for a company that runs its own services: each app host is trusted in the company's
1181
+ * own ZITADEL, bound in its in-app login app (`<slug>-auth-login-sign-in`) and given
1182
+ * `https://<app host>/sign-in/` as its Login V2 base URI. Planned once the company's own sign-in is
1183
+ * applied and that login app is recorded; until then one blocked line says what it waits for.
1184
+ * Archon's host is not here: the platform's Archon step owns it.
1185
+ */
1186
+ function planOwnLoginHost(org, recordedIds) {
1187
+ const none = { step: null, blocked: null };
1188
+ // Nothing wanted and nothing owed.
1189
+ if (!inAppSignIn(org) && !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY])
1190
+ return none;
1191
+ // The company's own sign-in is reported on its own line (`coolify:own-auth`).
1192
+ if (!ownAuthApplied(org, recordedIds))
1193
+ return none;
1194
+ const host = ownHostnames(org).auth;
1195
+ const wantedApps = inAppApps(org, recordedIds);
1196
+ const ready = Boolean(recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY]);
1197
+ const apps = ready ? wantedApps : [];
1198
+ const waiting = wantedApps.length > 0 && !ready
1199
+ ? `${org.slug}'s in-app sign-in waits for the login app that serves ${SIGN_IN_BASE_PATH} (${org.slug}-auth-login${SIGN_IN_IMAGE_TAG_SUFFIX}), which the company's own sign-in has not applied`
1200
+ : null;
1201
+ // The company's own login host needs nothing here, so only the app hosts decide whether work is owed.
1202
+ const targets = inAppTargets(apps, recordedIds);
1203
+ if ((recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1204
+ (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1205
+ pendingRemovalHosts(recordedIds).length === 0) {
1206
+ return { step: null, blocked: waiting };
1207
+ }
1208
+ // Nothing to trust a domain in, or to bind in, before the org exists in the company's ZITADEL.
1209
+ if (!recordedIds.zitadel_org)
1210
+ return { step: null, blocked: waiting };
1211
+ return {
1212
+ step: {
1213
+ action: {
1214
+ system: 'zitadel',
1215
+ kind: 'login-host',
1216
+ key: org.id,
1217
+ desired: { host, apps },
1218
+ },
1219
+ step: {
1220
+ description: `set ${org.slug}'s app hosts to sign in on their own addresses`,
1221
+ destroys: false,
1222
+ migrates: false,
1223
+ },
1224
+ },
1225
+ blocked: waiting,
1226
+ };
1227
+ }
1135
1228
  /**
1136
1229
  * Every sign-in app this org already has live (web or native, not api) gets
1137
1230
  * "user info inside the ID token" switched on once: apps made before the
@@ -2905,6 +2998,21 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2905
2998
  blocked,
2906
2999
  };
2907
3000
  }
3001
+ // #788: pull-request previews on a live app. `coolifyApp` patches them off
3002
+ // on every run, so replanning this one step is the whole repair.
3003
+ if (live.config.previewsEnabled === true) {
3004
+ return {
3005
+ step: {
3006
+ action,
3007
+ step: {
3008
+ description: `turn Coolify's preview deployments off for ${org.slug}'s ${label} (#788)`,
3009
+ destroys: false,
3010
+ migrates: false,
3011
+ },
3012
+ },
3013
+ blocked,
3014
+ };
3015
+ }
2908
3016
  // #239 (rule 4), from live state: an app with a database but no
2909
3017
  // `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
2910
3018
  // got the push. Names only, never values. A reader that did not list env
@@ -3256,7 +3364,7 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3256
3364
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
3257
3365
  Boolean(org.domain) ||
3258
3366
  // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
3259
- entryRoutes(org).length > 0;
3367
+ entryRoutes(org, recordedIds).length > 0;
3260
3368
  const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
3261
3369
  // Cloudflare's status, read against what is declared: a park cannot change
3262
3370
  // it, so judging on/off by status alone would never let the plan empty.
@@ -3313,7 +3421,8 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3313
3421
  // An entry's hostname goes straight to its container, so a changed port, key or name, or an ingress
3314
3422
  // written before entries were routed that way, needs the ingress written again. Nothing recorded
3315
3423
  // equals no routes.
3316
- const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !== entryRoutesMarker(org);
3424
+ const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !==
3425
+ entryRoutesMarker(org, recordedIds);
3317
3426
  if (!connectorMissing && !dnsStale && !routesStale)
3318
3427
  return null;
3319
3428
  return {
@@ -3738,8 +3847,11 @@ function accessRuleDescription(org, rule) {
3738
3847
  function planRateLimit(org, recordedIds) {
3739
3848
  if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
3740
3849
  return null;
3741
- if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)])
3850
+ // Recorded, and the rule is the variant the switch asks for (the in-app one also covers POSTs under `/sign-in/`).
3851
+ if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)] &&
3852
+ (recordedIds[SIGN_IN_RATE_LIMIT_SCOPE_KEY] === '1') === inAppSignIn(org)) {
3742
3853
  return null;
3854
+ }
3743
3855
  return {
3744
3856
  action: { system: 'cloudflare', kind: 'ratelimit', key: org.id, desired: {} },
3745
3857
  step: {
@@ -29,6 +29,11 @@ export interface RateLimitPort {
29
29
  createRateLimitRule(zoneId: string, rule: RateLimitRuleInput): Promise<{
30
30
  id: string;
31
31
  }>;
32
+ /**
33
+ * Deletes that one rule. Absent on a port that cannot; a step that needs it then fails by name.
34
+ * Rejects with a not-found error when the rule is already gone.
35
+ */
36
+ deleteRateLimitRule?(zoneId: string, id: string): Promise<void>;
32
37
  }
33
38
  /** Edge answers a create another create of the same rule is making `conflict` + `busy`. */
34
39
  export declare const EDGE_RATELIMITS_BUSY_ATTEMPTS = 5;
@@ -45,14 +45,16 @@ export function createEdgeRateLimits(client, options = {}) {
45
45
  return;
46
46
  const { rules } = await retrying(() => client.list({ zoneId, description }));
47
47
  const view = rules.find((r) => r.description === description && r.state !== 'removed');
48
- if (view && view.owner === null) {
49
- const result = await writeSlot(() => retrying(() => client.adopt({ rules: [{ zoneId, description, id: view.id }] })));
50
- if (result.skipped.length > 0) {
51
- throw new Error(`edge rate limits: rule '${description}' in zone ${zoneId} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
52
- }
53
- }
48
+ if (view && view.owner === null)
49
+ await adoptView(zoneId, view);
54
50
  adopted.add(key);
55
51
  };
52
+ const adoptView = async (zoneId, view) => {
53
+ const result = await writeSlot(() => retrying(() => client.adopt({ rules: [{ zoneId, description: view.description ?? '', id: view.id }] })));
54
+ if (result.skipped.length > 0) {
55
+ throw new Error(`edge rate limits: rule '${view.description}' in zone ${zoneId} cannot be adopted (${result.skipped[0]?.reason}); remove or fix it by hand`);
56
+ }
57
+ };
56
58
  return {
57
59
  async listRateLimitRules(zoneId) {
58
60
  const { rules } = await retrying(() => client.list({ zoneId }));
@@ -60,6 +62,14 @@ export function createEdgeRateLimits(client, options = {}) {
60
62
  .filter((r) => r.state !== 'removed')
61
63
  .map((r) => ({ id: r.id, description: r.description }));
62
64
  },
65
+ async deleteRateLimitRule(zoneId, id) {
66
+ // Edge removes only a rule it owns: a live rule it has no row for is adopted first, as a create does.
67
+ const { rules } = await retrying(() => client.list({ zoneId }));
68
+ const view = rules.find((r) => r.id === id && r.state !== 'removed');
69
+ if (view && view.owner === null)
70
+ await adoptView(zoneId, view);
71
+ await writeSlot(() => retrying(() => client.remove(zoneId, id)));
72
+ },
63
73
  async createRateLimitRule(zoneId, rule) {
64
74
  await adoptLive(zoneId, rule.description);
65
75
  const create = () => writeSlot(() => retrying(() => client.create({ zoneId, ...rule })));
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import { type CallsSfuUrls } from '../calls-sfu.js';
3
3
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
4
4
  import type { DeclaredOrgStore } from '../store.js';
5
+ import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
5
6
  import { type BrokerPort } from './broker-entries.js';
6
7
  import { type DeployHealthRuntime } from './deploy-health.js';
7
8
  import { type StepAction } from './diff.js';
@@ -16,6 +17,7 @@ import { type OwnServicesPort } from './own-services.js';
16
17
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
17
18
  import type { EdgePortName } from './platform-org.js';
18
19
  import type { RequestHeaderPort } from './platform-providers.js';
20
+ import { type ReleaseContext, type ReleaseHost } from './release-host.js';
19
21
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
20
22
  /**
21
23
  * The Coolify calls this executor makes, shaped to match
@@ -103,11 +105,16 @@ export interface CoolifyPort {
103
105
  */
104
106
  getApplication(uuid: string): Promise<{
105
107
  uuid: string;
108
+ /** Coolify's name for the app; a blue/green entry's recorded app is its blue copy only when this is the blue name. */
109
+ name?: string;
106
110
  status?: string;
107
111
  custom_labels?: string | null;
108
112
  settings?: {
109
113
  is_consistent_container_name_enabled?: boolean;
114
+ is_preview_deployments_enabled?: boolean;
110
115
  };
116
+ /** #788: where Coolify reports previews is not documented, so `previewsOn` reads both places. */
117
+ is_preview_deployments_enabled?: boolean;
111
118
  custom_network_aliases?: string | null;
112
119
  health_check_enabled?: boolean;
113
120
  ports_exposes?: string;
@@ -120,6 +127,8 @@ export interface CoolifyPort {
120
127
  custom_docker_run_options?: string | null;
121
128
  }>;
122
129
  createGithubAppApplication(input: {
130
+ /** #788: previews off; every git app create sends `false`. */
131
+ is_preview_deployments_enabled?: boolean;
123
132
  project_uuid: string;
124
133
  server_uuid: string;
125
134
  /** The Docker network the app joins (`plan/destinations.ts`); absent: the server's default. */
@@ -1140,6 +1149,14 @@ export interface ZitadelPort {
1140
1149
  * the binding holds; `zitadelApp` never sets it at creation.
1141
1150
  */
1142
1151
  updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
1152
+ /**
1153
+ * Lets each app's own Login V2 base URI count: foundry starts ZITADEL with `LOGINV2_REQUIRED=true`,
1154
+ * which makes the instance ignore it. The host sets the instance feature (`PUT /v2/features/instance`
1155
+ * with `loginV2.required` false and the instance default as `baseUri`). Called before an app is
1156
+ * pointed at its own `/sign-in/` on a company that runs its own ZITADEL; the shared instance's
1157
+ * bindings port does the same for the platform's. Optional: absent, nothing is called.
1158
+ */
1159
+ allowPerAppLoginBaseUri?(instanceBaseUri: string): Promise<void>;
1143
1160
  /**
1144
1161
  * Occupies `userName` in `orgId` with a machine user that has no role and
1145
1162
  * no token, so nobody can register under that name. Idempotent: a user
@@ -2006,6 +2023,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2006
2023
  * client id that binding holds. Apps created later are not in it yet:
2007
2024
  * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
2008
2025
  *
2026
+ * In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
2027
+ * bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
2028
+ * records that). The bind moves the app's client id off the company's binding in the same write (the
2029
+ * login refuses a client id in two bindings); a company binding left with no client is removed.
2030
+ * Switched off, the client id goes back and the base URI returns to the login host, and a trusted
2031
+ * domain is given up only after the app dropped the env.
2032
+ *
2009
2033
  * #274: when the org's host has moved (`recorded[key]`, the *previous*
2010
2034
  * run's resourceId, differs from this run's `host`), also removes every
2011
2035
  * host that is no longer the org's current one from both places --
@@ -2067,6 +2091,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2067
2091
  * the snapshot taken before the repoint still named it.
2068
2092
  */
2069
2093
  private zitadelLoginHost;
2094
+ /**
2095
+ * The host table of a company's own in-app login app as a bindings port that collects its edits and
2096
+ * writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
2097
+ * its own services, which has no host-supplied bindings port.
2098
+ */
2099
+ private ownSignInBindings;
2070
2100
  /**
2071
2101
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
2072
2102
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -2115,6 +2145,34 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2115
2145
  * actually be created on.
2116
2146
  */
2117
2147
  private resolveServer;
2148
+ /**
2149
+ * Contract C3: every usable, reachable Coolify server, the org's primary first. The primary is
2150
+ * {@link resolveServer}'s (databases and single-copy services stay on it, unchanged). A server that is
2151
+ * down is named in `down` and left out of `servers`; it is not an error.
2152
+ */
2153
+ resolveServers(org: DeclaredOrg, context: StepExecutionContext): Promise<{
2154
+ primary: {
2155
+ uuid: string;
2156
+ };
2157
+ servers: Array<{
2158
+ uuid: string;
2159
+ }>;
2160
+ down: string[];
2161
+ }>;
2162
+ /**
2163
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`), or `null` while its
2164
+ * blue copy is not recorded. The health token is only read here, never minted.
2165
+ */
2166
+ releaseHost(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
2167
+ /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2168
+ blueGreenServers(): Promise<BlueGreenServer[]>;
2169
+ /**
2170
+ * The blue/green steps (`plan/blue-green.ts`): `bluegreen` makes the apps and the balancer on one server,
2171
+ * `bluegreen-lb` only writes the balancer's weights, `bluegreen-connector` runs the tunnel's connector on a
2172
+ * server. A server that went down since the plan is skipped by `bluegreen-connector`'s caller, not here:
2173
+ * a step for it fails, which the next plan reports instead.
2174
+ */
2175
+ private coolifyBlueGreen;
2118
2176
  private coolifyApp;
2119
2177
  /**
2120
2178
  * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
@@ -2825,6 +2883,18 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2825
2883
  * behind it.
2826
2884
  */
2827
2885
  private coolifyAgora;
2886
+ /**
2887
+ * Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
2888
+ * does not), so the login-host step knows when to point the base URI at the app and when it may give the
2889
+ * host up. Written only when the value changes.
2890
+ */
2891
+ private recordSignInEnvLive;
2892
+ /**
2893
+ * In-app sign-in switched off (or its host given up): the app's live `SIGNIN_UPSTREAM` is deleted, because
2894
+ * `bulkUpsertEnvs` never removes a key. Left in place, the app would keep proxying `/sign-in/*` to the login.
2895
+ * Nothing happens while the env is desired or the app does not hold it.
2896
+ */
2897
+ private dropStaleSignInUpstream;
2828
2898
  /**
2829
2899
  * `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
2830
2900
  * (https://github.com/wtfalch/boule) as one more Coolify application, from
@@ -2971,6 +3041,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2971
3041
  */
2972
3042
  private ownSfu;
2973
3043
  private jobsRegistration;
3044
+ /** The blue/green database route (`plan/remote-database.ts`). */
3045
+ private remoteDatabaseRoute;
2974
3046
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
2975
3047
  private deliverWithRetry;
2976
3048
  private cloudflareTunnel;
@@ -3433,3 +3505,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3433
3505
  * `:`. It never scans the path or the response body.
3434
3506
  */
3435
3507
  export declare function isNotFoundError(error: unknown): boolean;
3508
+ /** True when ZITADEL refused an update because the value already is the wanted one. Matches the error code, never the free text. */
3509
+ export declare function isNoChangesError(error: unknown): boolean;
3510
+ /**
3511
+ * {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
3512
+ * done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
3513
+ */
3514
+ export declare function setOidcAppLoginBaseUri(zitadel: Pick<ZitadelPort, 'updateOidcAppLoginBaseUri'>, orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;