@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
@@ -3,17 +3,21 @@ import { isIPv4 } from 'node:net';
3
3
  import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
4
4
  import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
- import { ownerMailboxes } from '../org.js';
6
+ import { ownerMailboxes, sharedSecretRef, } from '../org.js';
7
+ import { applyArchonBlueGreen } from './archon-blue-green.js';
8
+ import { ARCHON_REPO, archonSubject, healthTokenName, tunnelOriginFor, } from './blue-green.js';
7
9
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
8
10
  import { recordBuiltCommit } from './build-record.js';
9
11
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
12
+ import { previewsOn } from './coolify-previews.js';
10
13
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
11
14
  import { placedOnPlatform } from './destinations.js';
12
- import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
15
+ import { ACCESS_POLICY_NAME, LOG_DIR, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
13
16
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
14
17
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
15
18
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
16
19
  import { LIMITS, limited } from './own-limits.js';
20
+ import { createReleaseHost } from './release-host.js';
17
21
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
18
22
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
19
23
  import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
@@ -22,7 +26,10 @@ import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied,
22
26
  import { recordOwnDnsHostname } from './own-dns.js';
23
27
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
24
28
  import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_ACCESS_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
29
+ import { PLATFORM_ARCHON_IN_APP_SCOPE_KEY, archonInAppBack, archonInAppBind, archonInAppHost, archonInAppPoint, archonInAppUnbind, } from './sign-in-archon.js';
30
+ import { LOGIN_HOST_BINDINGS_ENV } from './sign-in-bindings.js';
25
31
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
32
+ import { SIGN_IN_BASE_PATH, SIGN_IN_BUILD_ARG_ENV, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppOnSharedLogin, signInLoginNames, signInUpstream, } from './sign-in.js';
26
33
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
27
34
  import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
28
35
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
@@ -169,6 +176,86 @@ export class FoundryPlatformStepExecutor {
169
176
  limitedOnPlatform(deps, profile) {
170
177
  return { ...deps, coolify: limited(this.onPlatform(deps.coolify), profile) };
171
178
  }
179
+ /** Every Coolify server and why platform-archon's blue/green cannot run here, for the planner. */
180
+ async archonBlueGreen() {
181
+ const archon = this.archon;
182
+ if (!archon)
183
+ return { blocked: 'the host built the platform executor without archon dependencies' };
184
+ if (typeof archon.heldKeys?.put !== 'function') {
185
+ return { blocked: 'the host cannot hold the health token (its held keys cannot put)' };
186
+ }
187
+ const servers = await archon.coolify.listServers();
188
+ return {
189
+ servers: servers.map((s) => ({
190
+ uuid: s.uuid,
191
+ usable: s.is_usable,
192
+ reachable: s.is_reachable,
193
+ })),
194
+ };
195
+ }
196
+ /** The release runner's outside calls for platform-archon (`plan/release-host.ts`). The token is only read. */
197
+ releaseHost(platform, subject, context, renew) {
198
+ const archon = this.archon;
199
+ const recorded = context.recordedIds();
200
+ const primaryBlueAppId = recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`];
201
+ const primaryUuid = recorded.coolify_server;
202
+ if (!archon || !primaryBlueAppId || !primaryUuid)
203
+ return null;
204
+ return createReleaseHost({
205
+ subject,
206
+ context,
207
+ coolify: archon.coolify,
208
+ healthToken: async () => (await archon.heldKeys?.resolve(sharedSecretRef(platform.ownerOrgId, healthTokenName(subject.id)))) ?? null,
209
+ secret: (value) => archon.runtime?.onSecret?.(value),
210
+ primaryUuid,
211
+ primaryBlueAppId,
212
+ runtime: this.runtime,
213
+ ...(archon.runtime?.fetch ? { fetch: archon.runtime.fetch } : {}),
214
+ renew,
215
+ label: `coolify:release:${subject.id}`,
216
+ });
217
+ }
218
+ async archonBlueGreenStep(action, context) {
219
+ if (!this.archon) {
220
+ throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
221
+ }
222
+ const deps = this.limitedOnPlatform(this.archon, LIMITS.platformArchon);
223
+ const platform = context.platform;
224
+ const subject = archonSubject(platform, ARCHON_REPO);
225
+ const recorded = context.recordedIds();
226
+ const blueAppId = recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`];
227
+ const projectUuid = recorded[PLATFORM_ARCHON_PROJECT_SCOPE_KEY];
228
+ const tunnelId = recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY];
229
+ if (!subject || !blueAppId || !projectUuid || !tunnelId) {
230
+ throw new Error('coolify:archon-bluegreen: platform-archon is not applied yet');
231
+ }
232
+ if (typeof deps.heldKeys?.put !== 'function') {
233
+ throw new Error('coolify:archon-bluegreen: the host cannot hold the health token');
234
+ }
235
+ await applyArchonBlueGreen({
236
+ subject,
237
+ ownerOrgId: platform.ownerOrgId,
238
+ desired: { primary: String(action.desired.primary ?? '') },
239
+ recorded: () => context.recordedIds(),
240
+ record: (key, value) => context.recordScopeId(key, value),
241
+ persist: async () => {
242
+ await context.persist?.();
243
+ },
244
+ coolify: deps.coolify,
245
+ heldKeys: { resolve: deps.heldKeys.resolve, put: deps.heldKeys.put },
246
+ secret: (value) => deps.runtime?.onSecret?.(value),
247
+ projectUuid,
248
+ blueAppId,
249
+ tunnelId,
250
+ hostname: platform.archon.hostname,
251
+ tunnels: await platformTunnels(deps, context.recordedIds(), 'coolify:archon-bluegreen'),
252
+ runtime: deps.runtime ?? {},
253
+ sleep: deps.runtime?.sleep ?? defaultSleep,
254
+ logDir: LOG_DIR,
255
+ isRunning: isRunningOrStarting,
256
+ });
257
+ return undefined;
258
+ }
172
259
  async blockedSteps(platform, recordedIds = {}, owner = {}) {
173
260
  const blocked = {};
174
261
  if (platform.auth.enabled && !this.auth) {
@@ -594,8 +681,9 @@ export class FoundryPlatformStepExecutor {
594
681
  }
595
682
  if (platform.auth.enabled && this.auth) {
596
683
  const loginClientDrifted = await authLoginClientDrifted(platform, this.auth, recordedIds);
597
- if (loginClientDrifted)
684
+ if (loginClientDrifted || (await this.signInLoginDrifted(this.auth, recordedIds))) {
598
685
  result.auth = true;
686
+ }
599
687
  }
600
688
  // #380: once the runner app is recorded, re-check its live env against
601
689
  // every setting `ensureStarted()` requires -- the gap that left the
@@ -653,13 +741,45 @@ export class FoundryPlatformStepExecutor {
653
741
  await coolify.deploy(runnerAppId);
654
742
  return { [PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY]: normalHosts(wanted).join(',') };
655
743
  }
744
+ /** True while a company signs in on its apps' own addresses through the platform's login. */
745
+ async signInLoginWanted() {
746
+ return (await this.declaredOrgStore.list()).some(inAppOnSharedLogin);
747
+ }
748
+ /**
749
+ * In-app sign-in: the login app that serves `/sign-in` is wanted and either not recorded, gone from
750
+ * Coolify, or holding a host table other than the login's. The step then plans again and repairs it.
751
+ * A Coolify read that fails counts as not drifted, with one fixed log line (the error can carry values).
752
+ */
753
+ async signInLoginDrifted(auth, recordedIds) {
754
+ if (!(await this.signInLoginWanted()))
755
+ return false;
756
+ const signInId = recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY];
757
+ const loginId = recordedIds[PLATFORM_AUTH_LOGIN_SCOPE_KEY];
758
+ if (!signInId)
759
+ return true;
760
+ if (!loginId)
761
+ return false;
762
+ try {
763
+ const [login, signIn] = await Promise.all([
764
+ currentEnvValue(auth.coolify, loginId, LOGIN_HOST_BINDINGS_ENV),
765
+ currentEnvValue(auth.coolify, signInId, LOGIN_HOST_BINDINGS_ENV),
766
+ ]);
767
+ return (login ?? '') !== (signIn ?? '');
768
+ }
769
+ catch (error) {
770
+ if (isNotFoundError(error))
771
+ return true;
772
+ console.error('platform:auth: could not read the login apps to compare their host tables; treating the in-app login as in step');
773
+ return false;
774
+ }
775
+ }
656
776
  async execute(action, context) {
657
777
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AUTH_KEY) {
658
778
  if (!this.auth) {
659
779
  throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
660
780
  }
661
781
  const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
662
- return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), company);
782
+ return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), company, await this.signInLoginWanted());
663
783
  }
664
784
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
665
785
  if (!this.files) {
@@ -687,6 +807,9 @@ export class FoundryPlatformStepExecutor {
687
807
  }
688
808
  return provisionPlatformEdge(context, this.limitedOnPlatform(this.edge, LIMITS.platformEdge));
689
809
  }
810
+ if (action.system === 'coolify' && action.kind === 'archon-bluegreen') {
811
+ return this.archonBlueGreenStep(action, context);
812
+ }
690
813
  if (action.system === 'coolify' &&
691
814
  action.kind === 'app' &&
692
815
  action.key === PLATFORM_ARCHON_KEY) {
@@ -919,6 +1042,7 @@ export async function provisionJobsHost(scope, deps, target, input) {
919
1042
  const server = await resolvePlatformServer(coolify);
920
1043
  const githubApp = await coolify.requireGithubApp();
921
1044
  const created = await coolify.createGithubAppApplication({
1045
+ is_preview_deployments_enabled: false,
922
1046
  project_uuid: projectUuid,
923
1047
  server_uuid: server.uuid,
924
1048
  environment_name: environment.name,
@@ -1552,6 +1676,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
1552
1676
  const server = await resolvePlatformServer(deps.coolify);
1553
1677
  const githubApp = await deps.coolify.requireGithubApp();
1554
1678
  return deps.coolify.createGithubAppApplication({
1679
+ is_preview_deployments_enabled: false,
1555
1680
  project_uuid: projectUuid,
1556
1681
  server_uuid: server.uuid,
1557
1682
  environment_name: environment.name,
@@ -1571,11 +1696,46 @@ export async function provisionAuthStack(scope, deps, target, input) {
1571
1696
  custom_internal_name: target.internal.authLogin,
1572
1697
  ...IMAGE_HEALTHCHECK_SETTINGS,
1573
1698
  });
1699
+ // ---- the in-app login app (`/sign-in`) ------------------------------------
1700
+ // A Next.js base path is fixed at build, so the login that serves `/sign-in` is a second app built from
1701
+ // the same Dockerfile with the base path as a build-time env. It is recorded as applied only at the end,
1702
+ // once it is deployed and holds the login token.
1703
+ const signInNames = input.signInLogin ? signInLoginNames(target) : null;
1704
+ const signInApp = signInNames
1705
+ ? await findOrCreateSignInLoginApp(deps.coolify, {
1706
+ name: signInNames.name,
1707
+ environment,
1708
+ label,
1709
+ create: async () => {
1710
+ const server = await resolvePlatformServer(deps.coolify);
1711
+ const githubApp = await deps.coolify.requireGithubApp();
1712
+ return deps.coolify.createGithubAppApplication({
1713
+ project_uuid: projectUuid,
1714
+ server_uuid: server.uuid,
1715
+ environment_name: environment.name,
1716
+ environment_uuid: environment.uuid,
1717
+ github_app_uuid: githubApp.uuid,
1718
+ git_repository: AUTH_REPO,
1719
+ git_branch: gitRef,
1720
+ build_pack: 'dockerfile',
1721
+ name: signInNames.name,
1722
+ dockerfile_location: AUTH_LOGIN_DOCKERFILE,
1723
+ });
1724
+ },
1725
+ })
1726
+ : null;
1727
+ if (signInApp && signInNames) {
1728
+ await deps.coolify.updateApplication(signInApp.appId, {
1729
+ custom_internal_name: signInNames.internal,
1730
+ ...IMAGE_HEALTHCHECK_SETTINGS,
1731
+ });
1732
+ }
1574
1733
  // ---- the broker app (#204) -------------------------------------------------
1575
1734
  const brokerApp = await findOrCreatePlatformApp(deps.coolify, target.names.authBroker, environment, async () => {
1576
1735
  const server = await resolvePlatformServer(deps.coolify);
1577
1736
  const githubApp = await deps.coolify.requireGithubApp();
1578
1737
  return deps.coolify.createGithubAppApplication({
1738
+ is_preview_deployments_enabled: false,
1579
1739
  project_uuid: projectUuid,
1580
1740
  server_uuid: server.uuid,
1581
1741
  environment_name: environment.name,
@@ -1709,6 +1869,19 @@ export async function provisionAuthStack(scope, deps, target, input) {
1709
1869
  await deps.coolify.bulkUpsertEnvs(zitadelAppId, zitadelEnvs);
1710
1870
  await deps.coolify.bulkUpsertEnvs(loginAppId, loginEnvs);
1711
1871
  await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
1872
+ // The in-app login: the login's env, the base path as a build argument, and the host table.
1873
+ let signInEnvsChanged = false;
1874
+ if (signInApp && input.signInLogin) {
1875
+ const bindings = input.signInLogin.bindings ??
1876
+ (await currentEnvValue(deps.coolify, loginAppId, LOGIN_HOST_BINDINGS_ENV));
1877
+ const signInEnvs = [
1878
+ ...loginEnvs,
1879
+ { key: SIGN_IN_BUILD_ARG_ENV, value: SIGN_IN_BASE_PATH, buildTime: true },
1880
+ ...(bindings ? [{ key: LOGIN_HOST_BINDINGS_ENV, value: bindings }] : []),
1881
+ ];
1882
+ signInEnvsChanged = await envsDiffer(deps.coolify, signInApp.appId, signInEnvs);
1883
+ await deps.coolify.bulkUpsertEnvs(signInApp.appId, signInEnvs);
1884
+ }
1712
1885
  // Deploy an app that was just created, is not running (a throw after the
1713
1886
  // create left it never deployed, or it stopped), or whose envs changed.
1714
1887
  // A move that started ZITADEL on the TLS database and never saw it healthy there (#746): start it
@@ -1732,6 +1905,9 @@ export async function provisionAuthStack(scope, deps, target, input) {
1732
1905
  if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
1733
1906
  await deps.coolify.deploy(loginAppId);
1734
1907
  }
1908
+ if (signInApp && (signInApp.justCreated || !signInApp.running || signInEnvsChanged)) {
1909
+ await deps.coolify.deploy(signInApp.appId);
1910
+ }
1735
1911
  // #211: the broker's own `server.ts` refuses to start without
1736
1912
  // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `LETTERMINT_API_TOKEN` --
1737
1913
  // deploying it without them only produces a container that crash-loops
@@ -2010,8 +2186,33 @@ export async function provisionAuthStack(scope, deps, target, input) {
2010
2186
  const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
2011
2187
  const freshRecordedIds = scope.recordedIds();
2012
2188
  const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
2013
- const loginClientNeedsMint = !freshLoginClientRecord ||
2189
+ let loginClientNeedsMint = !freshLoginClientRecord ||
2014
2190
  (await authLoginClientDriftedAt({ hostname, bearerRef: target.refs.instanceBearer }, deps, freshRecordedIds));
2191
+ // The in-app login app needs the same token. When the login already holds a live one, it is copied
2192
+ // (no new mint, so the login is not restarted) if the in-app login holds none or another one -- a re-mint
2193
+ // while the app was not delivered to leaves it a revoked token. When the login's cannot be read, the mint
2194
+ // below delivers it. The tokens are compared, never logged.
2195
+ if (signInApp && !loginClientNeedsMint) {
2196
+ const signInHeld = await currentEnvValue(deps.coolify, signInApp.appId, SERVICE_USER_TOKEN_ENV);
2197
+ const held = await currentEnvValue(deps.coolify, loginAppId, SERVICE_USER_TOKEN_ENV);
2198
+ if (held && held !== signInHeld) {
2199
+ runtime.onSecret?.(held);
2200
+ const attempts = runtime.deliverAttempts ?? 3;
2201
+ try {
2202
+ await retryPlatformAuthCall(attempts, sleep, () => deps.coolify.bulkUpsertEnvs(signInApp.appId, [
2203
+ { key: SERVICE_USER_TOKEN_ENV, value: held },
2204
+ ]));
2205
+ await retryPlatformAuthCall(attempts, sleep, () => deps.coolify.deploy(signInApp.appId, true));
2206
+ }
2207
+ catch {
2208
+ // Never echo the cause: Coolify's validation error can include the rejected value (the token).
2209
+ throw new Error(`${label}: the login token could not be copied to ${signInNames?.name} after ${attempts} tries`);
2210
+ }
2211
+ }
2212
+ else if (!held && !signInHeld) {
2213
+ loginClientNeedsMint = true;
2214
+ }
2215
+ }
2015
2216
  if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
2016
2217
  // An own org never mints a second time on top of a token that may be live:
2017
2218
  // the platform's re-mint revokes the old one, which would take the org's
@@ -2040,6 +2241,9 @@ export async function provisionAuthStack(scope, deps, target, input) {
2040
2241
  // comment above gives.
2041
2242
  await record(PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, loginClientUserId);
2042
2243
  const deliverAttempts = runtime.deliverAttempts ?? 3;
2244
+ const receivers = signInApp
2245
+ ? `the login app, the in-app login app (${signInNames?.name}) and broker`
2246
+ : 'the login app and broker';
2043
2247
  try {
2044
2248
  await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(loginAppId, [
2045
2249
  { key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
@@ -2047,6 +2251,11 @@ export async function provisionAuthStack(scope, deps, target, input) {
2047
2251
  await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(brokerAppId, [
2048
2252
  { key: 'ZITADEL_SERVICE_USER_TOKEN', value: loginClientToken },
2049
2253
  ]));
2254
+ if (signInApp) {
2255
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.bulkUpsertEnvs(signInApp.appId, [
2256
+ { key: SERVICE_USER_TOKEN_ENV, value: loginClientToken },
2257
+ ]));
2258
+ }
2050
2259
  // Forced: an unforced deploy answers 200 "skipped" when one for the
2051
2260
  // same commit is already queued -- likely here, since this function's
2052
2261
  // own deploys above may just have queued one for either app. An
@@ -2055,6 +2264,9 @@ export async function provisionAuthStack(scope, deps, target, input) {
2055
2264
  // (#216 follow-up review, item 2).
2056
2265
  await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(loginAppId, true));
2057
2266
  await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(brokerAppId, true));
2267
+ if (signInApp) {
2268
+ await retryPlatformAuthCall(deliverAttempts, sleep, () => deps.coolify.deploy(signInApp.appId, true));
2269
+ }
2058
2270
  }
2059
2271
  catch {
2060
2272
  // Never echo the underlying error: Coolify's validation error can
@@ -2074,8 +2286,8 @@ export async function provisionAuthStack(scope, deps, target, input) {
2074
2286
  // saying "its prior token was already revoked" there would describe a
2075
2287
  // revoke that never happened, so the message only says so when it did.
2076
2288
  throw new Error(revokedPriorPat
2077
- ? `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
2078
- : `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
2289
+ ? `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so ${receivers} are down until a later apply mints and delivers a fresh one`
2290
+ : `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so ${receivers} still have none until a later apply mints and delivers one`);
2079
2291
  }
2080
2292
  // #216 round 4/5: records what was actually delivered -- the user id,
2081
2293
  // the exact PAT id, and (round 5) a hash of the exact token -- so the
@@ -2085,6 +2297,11 @@ export async function provisionAuthStack(scope, deps, target, input) {
2085
2297
  // own doc comment, and `authLoginClientDrifted`'s, for why.
2086
2298
  await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
2087
2299
  }
2300
+ // The in-app login app is ready once it is created, deployed (above) and holds the token: only then is
2301
+ // it recorded, so the login-host step plans no app host for a login that cannot serve it yet.
2302
+ if (signInApp && (await currentEnvValue(deps.coolify, signInApp.appId, SERVICE_USER_TOKEN_ENV))) {
2303
+ await record(SIGN_IN_LOGIN_APP_SCOPE_KEY, signInApp.appId);
2304
+ }
2088
2305
  // ---- the owner company's own sign-in org ----------------------------------
2089
2306
  // Archon signs people in from this org; the `foundry` org holds none. The
2090
2307
  // company executor finds the same org by name (its slug) and makes no second.
@@ -2206,7 +2423,7 @@ const platformAuthProject = {
2206
2423
  },
2207
2424
  };
2208
2425
  /** The platform's sign-in: {@link provisionAuthStack} with {@link platformTarget} and the platform's own inputs. */
2209
- async function provisionPlatformAuth(context, deps, ownerCompany = null) {
2426
+ async function provisionPlatformAuth(context, deps, ownerCompany = null, signInLogin = false) {
2210
2427
  const { platform } = context;
2211
2428
  const scope = scopeFromPlatform(context);
2212
2429
  return provisionAuthStack(scope, deps, platformTarget(), {
@@ -2218,6 +2435,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
2218
2435
  smtpSender: `noreply@${platform.apex}`,
2219
2436
  defaultRedirectUri: platform.archon.enabled ? `https://${platform.archon.hostname}` : null,
2220
2437
  ownerCompany,
2438
+ // In-app sign-in: the second login app copies the login's host table (the host's port writes that).
2439
+ ...(signInLogin ? { signInLogin: { bindings: null } } : {}),
2221
2440
  });
2222
2441
  }
2223
2442
  /**
@@ -3469,8 +3688,6 @@ const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
3469
3688
  const PLATFORM_ARCHON_TUNNEL_NAME = 'platform-archon-tunnel';
3470
3689
  /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_ARCHON_TUNNEL_NAME}. */
3471
3690
  const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
3472
- /** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
3473
- const ARCHON_REPO = 'wtfalch/archon';
3474
3691
  const ARCHON_PORT = '3000';
3475
3692
  /**
3476
3693
  * #480: Archon's own health route (`@wtfalch/reporting`'s `healthHandler`,
@@ -3814,6 +4031,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3814
4031
  const server = await resolvePlatformServer(deps.coolify);
3815
4032
  const githubApp = await deps.coolify.requireGithubApp();
3816
4033
  return deps.coolify.createGithubAppApplication({
4034
+ is_preview_deployments_enabled: false,
3817
4035
  project_uuid: projectUuid,
3818
4036
  server_uuid: server.uuid,
3819
4037
  environment_name: environment.name,
@@ -3850,6 +4068,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3850
4068
  await deps.coolify.updateApplication(appId, {
3851
4069
  custom_network_aliases: PLATFORM_ARCHON_INTERNAL_NAME,
3852
4070
  is_consistent_container_name_enabled: false,
4071
+ // #788: previews off; `archonRollingUpdateDrifted` plans the step again if they come back on.
4072
+ is_preview_deployments_enabled: false,
3853
4073
  // #543: the quartet below is the shared `healthCheckSettings` helper
3854
4074
  // every other app foundry creates now also goes through -- Archon's own
3855
4075
  // fields, unchanged in value, just no longer hand-rolled here.
@@ -3920,6 +4140,60 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3920
4140
  await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
3921
4141
  clientId = created.clientId;
3922
4142
  }
4143
+ // ---- in-app sign-in -----------------------------------------------------------
4144
+ // Archon signs people in on its own address, through the owner company's in-app login app. Wanted when
4145
+ // the company has the switch on and that login app is applied. In this order: trusted and bound, then
4146
+ // the env (`SIGNIN_UPSTREAM`) written and deployed, then the client's base URI, so the proxy is never
4147
+ // on for an address the login cannot serve and the base URI never points at an app that does not proxy.
4148
+ // Given up in the reverse order: the base URI back first, then the env, then the binding and the domain.
4149
+ const inAppWanted = Boolean(ownerSignIn.signInInApp && ownerSignIn.signInLoginAppId && ownerCompany);
4150
+ const archonAppZitadelId = context.recordedIds()[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
4151
+ const inAppOwed = archonInAppHost(context.recordedIds()) !== '';
4152
+ const inAppCalls = () => {
4153
+ const { ensureTrustedDomain, deleteTrustedDomain, updateOidcAppLoginBaseUri, allowPerAppLoginBaseUri, } = zitadel;
4154
+ if (!ensureTrustedDomain || !deleteTrustedDomain || !updateOidcAppLoginBaseUri) {
4155
+ throw new Error("platform:archon: the owner company's ZITADEL port lacks ensureTrustedDomain, deleteTrustedDomain or updateOidcAppLoginBaseUri, so Archon cannot sign in on its own address");
4156
+ }
4157
+ return {
4158
+ ensureTrustedDomain: ensureTrustedDomain.bind(zitadel),
4159
+ deleteTrustedDomain: deleteTrustedDomain.bind(zitadel),
4160
+ updateOidcAppLoginBaseUri: updateOidcAppLoginBaseUri.bind(zitadel),
4161
+ ...(allowPerAppLoginBaseUri
4162
+ ? { allowPerAppLoginBaseUri: allowPerAppLoginBaseUri.bind(zitadel) }
4163
+ : {}),
4164
+ };
4165
+ };
4166
+ const inAppTarget = {
4167
+ coolify: deps.coolify,
4168
+ loginHost: ownerSignIn.hostname,
4169
+ host: hostname,
4170
+ organisationId: authOrgId,
4171
+ projectId: project.id,
4172
+ appId: archonAppZitadelId ?? '',
4173
+ };
4174
+ if (inAppWanted && ownerSignIn.signInLoginAppId) {
4175
+ if (!archonAppZitadelId) {
4176
+ throw new Error("platform:archon: Archon's sign-in client is not recorded yet");
4177
+ }
4178
+ // A port that cannot allow a per-app base URI stops the step before anything is written.
4179
+ if (!zitadel.allowPerAppLoginBaseUri) {
4180
+ throw new Error("platform:archon: the owner company's ZITADEL port cannot allow a per-app login base URI, so Archon cannot sign in on its own address");
4181
+ }
4182
+ await archonInAppBind({
4183
+ ...inAppTarget,
4184
+ zitadel: inAppCalls(),
4185
+ loginAppId: ownerSignIn.signInLoginAppId,
4186
+ clientId,
4187
+ });
4188
+ }
4189
+ else if (inAppOwed) {
4190
+ // Given up: the client goes back to the company's login host before the app stops proxying.
4191
+ await archonInAppBack({
4192
+ ...inAppTarget,
4193
+ host: archonInAppHost(context.recordedIds()),
4194
+ zitadel: inAppCalls(),
4195
+ });
4196
+ }
3923
4197
  // ---- env ------------------------------------------------------------------
3924
4198
  const envs = [
3925
4199
  { key: 'APP_URL', value: `https://${hostname}` },
@@ -3929,6 +4203,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3929
4203
  { key: 'AUTH_CLIENT_ID', value: clientId },
3930
4204
  ...(firstOwnerLink ? [{ key: 'ARCHON_FIRST_OWNER_LINK', value: firstOwnerLink }] : []),
3931
4205
  ];
4206
+ if (inAppWanted)
4207
+ envs.push({ key: SIGN_IN_UPSTREAM_ENV, value: signInUpstream(ownerCompany ?? undefined) });
3932
4208
  // #265: an owner URL or reporting site already on the app is kept as it is.
3933
4209
  // The owner URL must name the adopted database's host, or the app's two
3934
4210
  // database URLs would point at different databases.
@@ -4103,7 +4379,13 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4103
4379
  const staleOperator = stale.key === 'AUTHZ_BOOTSTRAP_OPERATOR';
4104
4380
  // The link is written only while both ids are known; one that is no longer wanted goes.
4105
4381
  const staleLink = !firstOwnerLink && stale.key === 'ARCHON_FIRST_OWNER_LINK';
4106
- if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime || staleOperator || staleLink) &&
4382
+ // In-app sign-in switched off: the proxy to the login goes.
4383
+ const staleSignIn = !inAppWanted && stale.key === SIGN_IN_UPSTREAM_ENV;
4384
+ if ((stale.key === 'FILES_MANAGEMENT_KEY' ||
4385
+ staleUptime ||
4386
+ staleOperator ||
4387
+ staleLink ||
4388
+ staleSignIn) &&
4107
4389
  stale.uuid) {
4108
4390
  await deps.coolify.deleteEnv(appId, stale.uuid);
4109
4391
  staleKeyDeleted = true;
@@ -4308,7 +4590,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4308
4590
  ingress: [
4309
4591
  {
4310
4592
  hostname,
4311
- service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`,
4593
+ // Through the balancer once platform-archon runs blue/green and its balancer is written.
4594
+ service: tunnelOriginFor(archonSubject(platform, ARCHON_REPO), recorded, recorded.coolify_server, `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`),
4312
4595
  },
4313
4596
  { service: 'http_status:404' },
4314
4597
  ],
@@ -4363,6 +4646,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
4363
4646
  if (archonDeployedNow && !selfRedeploy) {
4364
4647
  await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
4365
4648
  }
4649
+ // In-app sign-in, last half. The env is deployed and Archon answers health, or no deploy was owed (the
4650
+ // running env is the one written), so Archon now proxies `/sign-in/*` (on) or no longer does (off).
4651
+ // A host that deploys itself has not confirmed that yet: the next run does the rest, and until then the
4652
+ // marker stays as it was, so the step is planned again. Recorded last of its own calls.
4653
+ if (!archonDeployedNow || !selfRedeploy) {
4654
+ if (inAppWanted) {
4655
+ await archonInAppPoint({ ...inAppTarget, zitadel: inAppCalls() });
4656
+ await record(PLATFORM_ARCHON_IN_APP_SCOPE_KEY, `${hostname}=${clientId}`);
4657
+ }
4658
+ else if (inAppOwed) {
4659
+ await archonInAppUnbind({
4660
+ ...inAppTarget,
4661
+ host: archonInAppHost(context.recordedIds()),
4662
+ zitadel: inAppCalls(),
4663
+ ...(ownerSignIn.signInLoginAppId ? { loginAppId: ownerSignIn.signInLoginAppId } : {}),
4664
+ });
4665
+ await record(PLATFORM_ARCHON_IN_APP_SCOPE_KEY, '');
4666
+ }
4667
+ }
4366
4668
  // #398: the deployment finished and answers health, so Archon now runs with
4367
4669
  // the key id its env carries. `provisionPlatformIntegrations` drops the
4368
4670
  // previous public key only on this marker.
@@ -5158,7 +5460,8 @@ async function archonRollingUpdateDrifted(coolify, appId) {
5158
5460
  const app = await coolify.getApplication(appId);
5159
5461
  return (app.settings?.is_consistent_container_name_enabled === true ||
5160
5462
  app.custom_network_aliases !== PLATFORM_ARCHON_INTERNAL_NAME ||
5161
- app.health_check_enabled !== true);
5463
+ app.health_check_enabled !== true ||
5464
+ previewsOn(app));
5162
5465
  }
5163
5466
  catch {
5164
5467
  return false;
@@ -5770,6 +6073,7 @@ async function provisionPlatformAi(context, deps) {
5770
6073
  const server = await resolvePlatformServer(coolify);
5771
6074
  const githubApp = await coolify.requireGithubApp();
5772
6075
  return coolify.createGithubAppApplication({
6076
+ is_preview_deployments_enabled: false,
5773
6077
  project_uuid: projectUuid,
5774
6078
  server_uuid: server.uuid,
5775
6079
  environment_name: environment.name,
@@ -6314,6 +6618,31 @@ export async function findOrCreatePlatformApp(coolify, name, environment, create
6314
6618
  const created = await create();
6315
6619
  return { appId: created.uuid, justCreated: true, running: false };
6316
6620
  }
6621
+ const SERVICE_USER_TOKEN_ENV = 'ZITADEL_SERVICE_USER_TOKEN';
6622
+ /**
6623
+ * {@link findOrCreatePlatformApp} for the in-app login app, which adopts only an app that is its own: one
6624
+ * of that name outside this environment, or built from another repository, is refused and left as it is.
6625
+ * The check reads the application list directly: a lookup by name through the limits wrapper would patch
6626
+ * the stranger's resource limits before this could refuse it.
6627
+ */
6628
+ async function findOrCreateSignInLoginApp(coolify, input) {
6629
+ const { name, environment, label } = input;
6630
+ const listed = await coolify.raw('GET', '/applications');
6631
+ if (!Array.isArray(listed)) {
6632
+ throw new Error(`${label}: Coolify's application list could not be read, so ${name} is not touched`);
6633
+ }
6634
+ for (const app of listed) {
6635
+ if (app.name !== name)
6636
+ continue;
6637
+ if (app.environment_id !== environment.id) {
6638
+ throw new Error(`${label}: an application named ${name} exists outside the ${environment.name} environment of the sign-in project; foundry does not adopt it`);
6639
+ }
6640
+ if (app.git_repository !== undefined && !app.git_repository.endsWith(AUTH_REPO)) {
6641
+ throw new Error(`${label}: the application ${name} is built from ${app.git_repository}, not ${AUTH_REPO}; foundry does not adopt it`);
6642
+ }
6643
+ }
6644
+ return findOrCreatePlatformApp(coolify, name, environment, input.create);
6645
+ }
6317
6646
  /**
6318
6647
  * #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
6319
6648
  * `broker/server.ts`) refuses to start without -- `ZITADEL_SERVICE_USER_TOKEN`/
@@ -6790,6 +7119,7 @@ export async function provisionFilesHost(scope, deps, target, input) {
6790
7119
  const server = await resolvePlatformServer(deps.coolify);
6791
7120
  const githubApp = await deps.coolify.requireGithubApp();
6792
7121
  return deps.coolify.createGithubAppApplication({
7122
+ is_preview_deployments_enabled: false,
6793
7123
  project_uuid: projectUuid,
6794
7124
  server_uuid: server.uuid,
6795
7125
  environment_name: environment.name,
@@ -7183,6 +7513,7 @@ async function provisionPlatformEdge(context, deps) {
7183
7513
  const app = await findOrCreatePlatformApp(deps.coolify, EDGE_APP_NAME, environment, async () => {
7184
7514
  const githubApp = await deps.coolify.requireGithubApp();
7185
7515
  return deps.coolify.createGithubAppApplication({
7516
+ is_preview_deployments_enabled: false,
7186
7517
  project_uuid: projectUuid,
7187
7518
  server_uuid: server.uuid,
7188
7519
  environment_name: environment.name,
@@ -8082,6 +8413,7 @@ async function provisionPlatformIntegrations(action, context, deps) {
8082
8413
  const server = await resolvePlatformServer(coolify);
8083
8414
  const githubApp = await coolify.requireGithubApp();
8084
8415
  return coolify.createGithubAppApplication({
8416
+ is_preview_deployments_enabled: false,
8085
8417
  project_uuid: projectUuid,
8086
8418
  server_uuid: server.uuid,
8087
8419
  environment_name: environment.name,
@@ -1,6 +1,8 @@
1
1
  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
+ import { previewsOn } from './coolify-previews.js';
5
+ import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
4
6
  /**
5
7
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
6
8
  * — the one place foundry records a provider's own id for an org today,
@@ -169,9 +171,10 @@ async function withHealthCheckConfig(client, resource, recorded) {
169
171
  // above notes for `ports_exposes` -- `getApplication` returns the raw
170
172
  // Coolify API response regardless.
171
173
  const healthCheckEnabled = app.health_check_enabled;
174
+ // #788: the same read says whether pull-request previews are on.
172
175
  return {
173
176
  ...resource,
174
- config: { ...resource.config, healthCheckEnabled },
177
+ config: { ...resource.config, healthCheckEnabled, previewsEnabled: previewsOn(app) },
175
178
  };
176
179
  }
177
180
  catch {
@@ -243,8 +246,9 @@ async function withAgoraEnvConfig(client, resource, recorded) {
243
246
  ]);
244
247
  const env = {};
245
248
  for (const e of envs) {
246
- if (!e.is_preview && e.key.startsWith('AGORA_'))
249
+ if (!e.is_preview && (e.key.startsWith('AGORA_') || e.key === SIGN_IN_UPSTREAM_ENV)) {
247
250
  env[e.key] = e.value;
251
+ }
248
252
  }
249
253
  // `@wtfalch/providers`' `CoolifyApplication` type does not declare
250
254
  // `ports_exposes` (as of 0.7.3), but `getApplication` returns the raw