@wtfalch/foundry 0.18.0 → 0.19.1

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.
@@ -2,8 +2,8 @@ import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
+ import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
8
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
9
  import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -14,7 +14,7 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
14
14
  import { EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
15
15
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
16
16
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
17
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
17
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
18
18
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
19
19
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
20
20
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
@@ -240,10 +240,14 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
240
240
  * followed rather than reopened (#164 "Decide" 1). The agora repo's own
241
241
  * Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
242
242
  * from a build built at the repository root, the same one-repo shape
243
- * `CMS_REPO`'s own two apps use -- no `ports_exposes`/`PORT` env is needed
244
- * the way `CALLS_SERVICE_PORT` is for the calls service's own Node process,
245
- * since Coolify's github-app build already reads the exposed port the same
246
- * way it does for `cmsSite`'s two apps above.
243
+ * `CMS_REPO`'s own two apps use. This comment used to claim that needed no
244
+ * `ports_exposes` of its own, on the theory that Coolify's github-app build
245
+ * reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
246
+ * apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
247
+ * runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
248
+ * Traefik is left routing to a port nothing listens on, and a company's
249
+ * Agora answers 502 behind the tunnel. Seen live on Company Included,
250
+ * 2026-10-05.
247
251
  */
248
252
  const AGORA_REPO = 'wtfalch/agora';
249
253
  const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
@@ -665,7 +669,7 @@ export class FoundryStepExecutor {
665
669
  case 'cloudflare:tunnel':
666
670
  return this.cloudflareTunnel(action, org, context);
667
671
  case 'cloudflare:zone':
668
- return this.cloudflareZone(action, org);
672
+ return this.cloudflareZone(action, org, context);
669
673
  case 'cloudflare:access':
670
674
  return this.cloudflareAccess(action, org);
671
675
  case 'cloudflare:ratelimit':
@@ -1232,11 +1236,16 @@ export class FoundryStepExecutor {
1232
1236
  throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
1233
1237
  }
1234
1238
  const host = action.desired.host;
1235
- await this.ports.zitadel.ensureTrustedDomain(host);
1236
- // zitadel-login's own routing table, written before any app is repointed
1237
- // so no app ever points at a host with no binding.
1239
+ // The platform's own shared sign-in host: already trusted and routed by
1240
+ // the shared instance, so it gets no trusted domain or binding here.
1241
+ const shared = action.desired.shared === true;
1238
1242
  const clientIds = loginHostClientIds(recorded);
1239
- await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1243
+ if (!shared) {
1244
+ await this.ports.zitadel.ensureTrustedDomain(host);
1245
+ // zitadel-login's own routing table, written before any app is repointed
1246
+ // so no app ever points at a host with no binding.
1247
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1248
+ }
1240
1249
  // Only apps whose client id the binding now holds: any other app in the
1241
1250
  // project would lose sign-in. The rest move on a later run, once the
1242
1251
  // recorded client ids differ from the binding and replan this step.
@@ -1337,7 +1346,10 @@ export class FoundryStepExecutor {
1337
1346
  // the binding and replans the step next run; a nonempty pending list
1338
1347
  // does too (`planLoginHost`), independent of the host comparison that
1339
1348
  // normally alone decides it.
1340
- context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
1349
+ if (shared)
1350
+ context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
1351
+ else
1352
+ context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
1341
1353
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
1342
1354
  return { resourceId: host };
1343
1355
  }
@@ -3067,6 +3079,74 @@ export class FoundryStepExecutor {
3067
3079
  }
3068
3080
  }
3069
3081
  // ---- chat -----------------------------------------------------------------
3082
+ /**
3083
+ * The chat service's introspection client: an API app in the org's
3084
+ * messaging project (the audience Agora's token carries), so ZITADEL's
3085
+ * introspection accepts it. The chat service authenticates as it with
3086
+ * `client_secret_basic`. Minted once, the way {@link callsTokenEnvs} does:
3087
+ * the id is recorded under an undelivered key and persisted before the
3088
+ * secret leaves, the secret goes to `deliverSecret` and to the caller's env
3089
+ * write, never to a log, error or return beyond that. A re-run reads the id
3090
+ * and secret back from the service's own env and mints nothing. `changed`
3091
+ * is true only for a fresh mint.
3092
+ */
3093
+ async ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context) {
3094
+ const recorded = context.recordedIds();
3095
+ const done = recorded[CHAT_INTROSPECTION_SCOPE_KEY];
3096
+ const undelivered = recorded[CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
3097
+ if (done) {
3098
+ const envs = await this.ports.coolify.listEnvs(serviceAppId);
3099
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
3100
+ const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
3101
+ const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
3102
+ if (!clientId || !clientSecret) {
3103
+ throw new Error(`coolify:chat for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the service; remove the recorded keys ${CHAT_INTROSPECTION_SCOPE_KEY} and ${CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
3104
+ }
3105
+ return { clientId, clientSecret, appId: done, changed: false };
3106
+ }
3107
+ if (undelivered) {
3108
+ // A minted client with no marker: a crash or failure after the mint.
3109
+ // Both values on the service mean the write landed and only the marker
3110
+ // was lost; otherwise nothing usable was delivered, and the app is
3111
+ // deleted below and made again.
3112
+ const envs = await this.ports.coolify.listEnvs(serviceAppId);
3113
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
3114
+ const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
3115
+ const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
3116
+ if (clientId && clientSecret) {
3117
+ return { clientId, clientSecret, appId: undelivered, changed: false };
3118
+ }
3119
+ }
3120
+ // An app by this name with no recorded secret is a crash between create
3121
+ // and record: nothing was delivered, so it is deleted and made again.
3122
+ const stale = (await this.ports.zitadel.listApps(zitadelOrgId, projectId)).apps.filter((app) => app.name === chatIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
3123
+ const staleIds = new Set(stale.map((app) => app.id));
3124
+ if (undelivered)
3125
+ staleIds.add(undelivered);
3126
+ for (const id of staleIds) {
3127
+ try {
3128
+ await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, id);
3129
+ }
3130
+ catch (error) {
3131
+ if (!isNotFoundError(error))
3132
+ throw error;
3133
+ }
3134
+ }
3135
+ const client = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
3136
+ name: chatIntrospectionAppName(org),
3137
+ });
3138
+ context.recordScopeId(CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
3139
+ await context.persistScopeIds();
3140
+ if (!(await this.deliverWithRetry(`zitadel:chat:${org.slug}:introspection`, client.clientSecret))) {
3141
+ throw new Error(`coolify:chat for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
3142
+ }
3143
+ return {
3144
+ clientId: client.clientId,
3145
+ clientSecret: client.clientSecret,
3146
+ appId: client.appId,
3147
+ changed: true,
3148
+ };
3149
+ }
3070
3150
  /**
3071
3151
  * Creates the `chat.admin` role on the org's messaging project and grants
3072
3152
  * it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
@@ -3135,6 +3215,29 @@ export class FoundryStepExecutor {
3135
3215
  context.recordScopeId(markerKey, '1');
3136
3216
  return { resourceId: serviceAppId };
3137
3217
  }
3218
+ // `planChat` plans this for a live chat whose service has no
3219
+ // introspection client: mint it once, push only its two env vars and
3220
+ // redeploy. Never the re-key paths or a fresh provision.
3221
+ if (action.desired.refreshIntrospection === true) {
3222
+ const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
3223
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
3224
+ const projectId = recorded[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
3225
+ if (!serviceAppId || !zitadelOrgId || !projectId) {
3226
+ throw new Error(`coolify:chat introspection for ${org.slug}: the chat service app, the ZITADEL org or the messaging project is not recorded yet`);
3227
+ }
3228
+ const hadMarker = Boolean(recorded[CHAT_INTROSPECTION_SCOPE_KEY]);
3229
+ const client = await this.ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context);
3230
+ await this.ports.coolify.bulkUpsertEnvs(serviceAppId, [
3231
+ { key: 'CHAT_INTROSPECTION_CLIENT_ID', value: client.clientId },
3232
+ { key: 'CHAT_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
3233
+ ]);
3234
+ // Deploy first, marker last: a failed deploy leaves the marker unset,
3235
+ // so the next plan still carries this step and the container gets the env.
3236
+ if (client.changed || !hadMarker)
3237
+ await this.ports.coolify.deploy(serviceAppId);
3238
+ context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, client.appId);
3239
+ return { resourceId: serviceAppId };
3240
+ }
3138
3241
  if (action.desired.usesDomain === false) {
3139
3242
  throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
3140
3243
  }
@@ -3268,6 +3371,7 @@ export class FoundryStepExecutor {
3268
3371
  rsaKeyPem: secrets.value.masRsaKeyPem,
3269
3372
  ecKeyPem: secrets.value.masEcKeyPem,
3270
3373
  }));
3374
+ const introspection = await this.ensureChatIntrospection(org, zitadelOrgId, project.id, service.appId, context);
3271
3375
  await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
3272
3376
  orgSlug: org.slug,
3273
3377
  companyName: org.displayName,
@@ -3277,6 +3381,8 @@ export class FoundryStepExecutor {
3277
3381
  agoraHost,
3278
3382
  masClientId: secrets.value.masClientId,
3279
3383
  masClientSecret: secrets.value.masClientSecret,
3384
+ introspectionClientId: introspection.clientId,
3385
+ introspectionClientSecret: introspection.clientSecret,
3280
3386
  }));
3281
3387
  // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
3282
3388
  // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
@@ -3294,11 +3400,14 @@ export class FoundryStepExecutor {
3294
3400
  for (const app of [mas, synapse, service]) {
3295
3401
  if (!app.existed ||
3296
3402
  secrets.changed ||
3297
- (app === service && serviceDbPush.changed) ||
3403
+ (app === service && (serviceDbPush.changed || introspection.changed)) ||
3298
3404
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
3299
3405
  await this.ports.coolify.deploy(app.appId);
3300
3406
  }
3301
3407
  }
3408
+ // After the write and the deploys, so a failure before them leaves the
3409
+ // marker unset and the next apply repeats the step.
3410
+ context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, introspection.appId);
3302
3411
  // #456: recorded here too, not only on the `refreshDatabaseSplit` replan
3303
3412
  // path above -- without it a fresh provision's very first apply never
3304
3413
  // sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
@@ -3603,11 +3712,25 @@ export class FoundryStepExecutor {
3603
3712
  * actually differs, or whenever the last restart this step confirmed
3604
3713
  * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
3605
3714
  * applied before this existed, whose live env already matched desired
3606
- * with nothing ever confirmed running it). A brand new app has nothing
3607
- * running yet to restart. The activation marker is recorded last, after
3608
- * the restart (if any) returns without throwing, so a failed restart
3609
- * leaves it stale and the next plan replans this step even though
3610
- * Coolify's own env store already matches desired.
3715
+ * with nothing ever confirmed running it). The activation marker is
3716
+ * recorded last, after the restart (if any) returns without throwing, so a
3717
+ * failed restart leaves it stale and the next plan replans this step even
3718
+ * though Coolify's own env store already matches desired.
3719
+ *
3720
+ * #504: a brand new app is never restarted -- but it is deployed, once
3721
+ * ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
3722
+ * sets `instant_deploy`, and Coolify defaults it to false, so without an
3723
+ * explicit deploy the app sat created but never built or started.
3724
+ *
3725
+ * #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
3726
+ * itself, so Traefik has a port to route to from the moment the app
3727
+ * exists. An app `findOrCreateCallsApp` instead *found* may predate this
3728
+ * fix and carry no `ports_exposes` at all (or the wrong one), so its live
3729
+ * value is read and, when it differs, patched -- and, since the container
3730
+ * listens on `ports_exposes` only at its next deploy (the same rule
3731
+ * `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
3732
+ * there), redeployed even on a run that already has its one #504 deploy
3733
+ * behind it.
3611
3734
  */
3612
3735
  async coolifyAgora(action, org, context) {
3613
3736
  // The host values the plan read, carried in the step: never read again
@@ -3637,9 +3760,22 @@ export class FoundryStepExecutor {
3637
3760
  name,
3638
3761
  domains: coolifyDomains([agoraHostnameFor(org)]),
3639
3762
  dockerfile_location: AGORA_APP_DOCKERFILE,
3763
+ ports_exposes: AGORA_APP_PORTS_EXPOSES,
3640
3764
  }));
3641
3765
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3642
3766
  await this.ensureLogVolume(app.appId, org, app.existed);
3767
+ // #506: an app found rather than just created may predate passing
3768
+ // `ports_exposes` at create -- Traefik then routes to a port nothing
3769
+ // listens on and Agora answers 502 behind the tunnel. A freshly created
3770
+ // app already got the right port above, so only a found one is checked.
3771
+ const portDiffers = app.existed &&
3772
+ (await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
3773
+ AGORA_APP_PORTS_EXPOSES;
3774
+ if (portDiffers) {
3775
+ await this.ports.coolify.updateApplication(app.appId, {
3776
+ ports_exposes: AGORA_APP_PORTS_EXPOSES,
3777
+ });
3778
+ }
3643
3779
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
3644
3780
  // Coolify's own env store matching `env`, which is why that store alone
3645
3781
  // can no longer tell a changed value from one already live.
@@ -3650,7 +3786,25 @@ export class FoundryStepExecutor {
3650
3786
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
3651
3787
  const revision = agoraEnvRevision(env);
3652
3788
  const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
3653
- if (app.existed && (envChanged || !alreadyActivated)) {
3789
+ // #504: `createGithubAppApplication` never sets `instant_deploy`, and
3790
+ // Coolify defaults it to false -- a brand new app, and one found by name
3791
+ // that a previous apply created but crashed before deploying, both sat
3792
+ // built but never started. `restart` cannot fix either: it starts a
3793
+ // container that was never built. Deployed once this executor has
3794
+ // confirmed (the marker never changes value once set), never again -- a
3795
+ // live app is only ever restarted from here on, unless #506's port fix
3796
+ // below needs its own deploy.
3797
+ if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
3798
+ await this.ports.coolify.deploy(app.appId);
3799
+ context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
3800
+ }
3801
+ else if (portDiffers) {
3802
+ // #506: a port fix needs its own full deploy -- env drift alone only
3803
+ // needs the cheaper restart, and this deploy already carries this
3804
+ // run's env (pushed above), so the two never both run.
3805
+ await this.ports.coolify.deploy(app.appId);
3806
+ }
3807
+ else if (app.existed && (envChanged || !alreadyActivated)) {
3654
3808
  await this.ports.coolify.restart(app.appId);
3655
3809
  }
3656
3810
  context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
@@ -3679,6 +3833,10 @@ export class FoundryStepExecutor {
3679
3833
  *
3680
3834
  * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
3681
3835
  * (`bouleBrokerEntry`).
3836
+ *
3837
+ * #504: a brand new app is never restarted -- but it is deployed, once
3838
+ * ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
3839
+ * shape {@link coolifyAgora}'s own doc comment explains.
3682
3840
  */
3683
3841
  async coolifyBoule(action, org, context) {
3684
3842
  // The issuer and Archon URL the plan read, carried in the step: the env
@@ -3730,7 +3888,17 @@ export class FoundryStepExecutor {
3730
3888
  if (!dbPush.pushed) {
3731
3889
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3732
3890
  }
3733
- if (app.existed && (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
3891
+ // #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
3892
+ // never sets `instant_deploy`, so a brand new app (or one found by name
3893
+ // that a previous apply created but crashed before deploying) sat built
3894
+ // but never started. Deployed once this executor has confirmed, never
3895
+ // again -- a live app is only ever restarted from here on.
3896
+ if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
3897
+ await this.ports.coolify.deploy(app.appId);
3898
+ context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
3899
+ }
3900
+ else if (app.existed &&
3901
+ (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
3734
3902
  await this.ports.coolify.restart(app.appId);
3735
3903
  }
3736
3904
  return { resourceId: app.appId };
@@ -4219,8 +4387,17 @@ export class FoundryStepExecutor {
4219
4387
  * shared-hostname collision this ticket exists to refuse. An org re-running
4220
4388
  * its own already-recorded zone is unaffected: the store then names this
4221
4389
  * same org as the owner.
4390
+ *
4391
+ * #510: also records the zone's own id under the flat `cloudflare_zone`
4392
+ * scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
4393
+ * already records from this method's return value. Without it, nothing
4394
+ * ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
4395
+ * scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
4396
+ * so that reader's `listResources` always returned `[]` and `diff.ts`'s
4397
+ * `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
4398
+ * once the zone was live.
4222
4399
  */
4223
- async cloudflareZone(action, org) {
4400
+ async cloudflareZone(action, org, context) {
4224
4401
  const domain = action.key;
4225
4402
  const cf = this.ports.cloudflare;
4226
4403
  const existing = await cf.findZoneByApex(domain);
@@ -4229,9 +4406,11 @@ export class FoundryStepExecutor {
4229
4406
  if (owner !== null && owner !== org.id) {
4230
4407
  throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
4231
4408
  }
4409
+ context.recordScopeId('cloudflare_zone', existing.id);
4232
4410
  return { resourceId: existing.id };
4233
4411
  }
4234
4412
  const created = await cf.createZone(domain);
4413
+ context.recordScopeId('cloudflare_zone', created.id);
4235
4414
  return { resourceId: created.id };
4236
4415
  }
4237
4416
  // ---- mail -----------------------------------------------------------
@@ -5318,7 +5497,14 @@ export class FoundryStepExecutor {
5318
5497
  // `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
5319
5498
  // for this org again to finish removing it once destroy deletes
5320
5499
  // the ZITADEL project and org below, so it has to go here too.
5321
- for (const host of [target.id, ...pendingRemovalHosts(recordedIds)]) {
5500
+ // A shared instance host's trusted domain is not this org's to remove.
5501
+ // The flag may be missing, so the recorded host is also compared
5502
+ // with the platform's shared auth host.
5503
+ const sharedHost = await this.ports.platformOrg?.authHostname?.();
5504
+ const isShared = recordedIds[LOGIN_HOST_SHARED_SCOPE_KEY] === '1' ||
5505
+ (sharedHost != null && target.id === sharedHost);
5506
+ const own = isShared ? [] : [target.id];
5507
+ for (const host of [...own, ...pendingRemovalHosts(recordedIds)]) {
5322
5508
  try {
5323
5509
  await this.ports.zitadel.deleteTrustedDomain(host);
5324
5510
  }
@@ -22,18 +22,22 @@ import type { DeclaredOrg } from '../org.js';
22
22
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
23
  * way `AGORA_SUFFIX` reserves `-agora`.
24
24
  *
25
- * #488: `auth.<domain>` is refused when it would equal `platformAuthHostname`
26
- * -- the platform's own org, declared with-domain on the platform's own
27
- * domain, would otherwise compute the shared ZITADEL instance's own login
28
- * host (`platform.auth.hostname`, e.g. `auth.companyincriminated.com` ->
29
- * `auth.companyincluded.com`). Archon's login-host write throws on that
30
- * duplicate host (`archon`'s `validateLoginHostRegistry`); if it were ever
31
- * written instead, `auth`'s own `company-host.ts` denies every request
32
- * through a duplicate host, the shared one included. `platformAuthHostname`
33
- * is `null` when the caller has none to compare against (no platform
34
- * declared, or sign-in not ready yet), in which case this never refuses.
25
+ * #488: the platform's own org is not handled here: it signs in on the shared
26
+ * ZITADEL instance's own host (`platformAuthHostname`), whatever
27
+ * `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
28
+ * that host itself. That host must never get a trusted
29
+ * domain or a login binding of its own:
30
+ * Archon's login-host write throws on a duplicate host, and `auth`'s own
31
+ * `company-host.ts` denies every request through one, the shared one
32
+ * included (`planLoginHost` and `zitadelLoginHost` skip both for it).
35
33
  */
36
- export declare function loginHostnameFor(org: DeclaredOrg, platformAuthHostname?: string | null): string;
34
+ export declare function loginHostnameFor(org: DeclaredOrg): string;
35
+ /**
36
+ * Scope id set to `'1'` once an org's recorded login host is the platform's
37
+ * shared instance host: that host's trusted domain belongs to the shared
38
+ * instance, so `destroy` must not remove it.
39
+ */
40
+ export declare const LOGIN_HOST_SHARED_SCOPE_KEY = "zitadel_login_host_shared";
37
41
  /**
38
42
  * One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
39
43
  * (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
@@ -23,30 +23,30 @@ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
23
23
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
24
24
  * way `AGORA_SUFFIX` reserves `-agora`.
25
25
  *
26
- * #488: `auth.<domain>` is refused when it would equal `platformAuthHostname`
27
- * -- the platform's own org, declared with-domain on the platform's own
28
- * domain, would otherwise compute the shared ZITADEL instance's own login
29
- * host (`platform.auth.hostname`, e.g. `auth.companyincriminated.com` ->
30
- * `auth.companyincluded.com`). Archon's login-host write throws on that
31
- * duplicate host (`archon`'s `validateLoginHostRegistry`); if it were ever
32
- * written instead, `auth`'s own `company-host.ts` denies every request
33
- * through a duplicate host, the shared one included. `platformAuthHostname`
34
- * is `null` when the caller has none to compare against (no platform
35
- * declared, or sign-in not ready yet), in which case this never refuses.
26
+ * #488: the platform's own org is not handled here: it signs in on the shared
27
+ * ZITADEL instance's own host (`platformAuthHostname`), whatever
28
+ * `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
29
+ * that host itself. That host must never get a trusted
30
+ * domain or a login binding of its own:
31
+ * Archon's login-host write throws on a duplicate host, and `auth`'s own
32
+ * `company-host.ts` denies every request through one, the shared one
33
+ * included (`planLoginHost` and `zitadelLoginHost` skip both for it).
36
34
  */
37
- export function loginHostnameFor(org, platformAuthHostname = null) {
35
+ export function loginHostnameFor(org) {
38
36
  // `issuer.usesDomain` is the issuer's opt-in into the org's domain
39
37
  // (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
40
38
  // with `usesDomain` false stays on the tier 1 host.
41
39
  if (org.domain && org.issuer.usesDomain) {
42
- const host = `auth.${org.domain}`;
43
- if (platformAuthHostname && host === platformAuthHostname) {
44
- return `${org.slug}-auth.oikos.host`;
45
- }
46
- return host;
40
+ return `auth.${org.domain}`;
47
41
  }
48
42
  return `${org.slug}-auth.oikos.host`;
49
43
  }
44
+ /**
45
+ * Scope id set to `'1'` once an org's recorded login host is the platform's
46
+ * shared instance host: that host's trusted domain belongs to the shared
47
+ * instance, so `destroy` must not remove it.
48
+ */
49
+ export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
50
50
  /**
51
51
  * Scope id holding what the binding was last written with
52
52
  * ({@link loginBindingState}). Client ids are not secrets. The planner
@@ -45,6 +45,7 @@ export declare class PlatformOrgPorts {
45
45
  files(): Promise<FilesOrganisationPort>;
46
46
  ai(): Promise<AiOrganisationPort>;
47
47
  jobs(): Promise<JobsSchedulerPort>;
48
+ authHostname(): Promise<string | null>;
48
49
  private recorded;
49
50
  private held;
50
51
  }
@@ -47,6 +47,9 @@ export class PlatformOrgPorts {
47
47
  const token = await this.held('jobs', JOBS_ADMIN_TOKEN_REF);
48
48
  return new HttpJobsSchedulerClient(`https://${platform.jobs.hostname}`, token, this.options.fetch);
49
49
  }
50
+ async authHostname() {
51
+ return (await this.options.store.get())?.auth.hostname ?? null;
52
+ }
50
53
  async recorded(service, scopeKey) {
51
54
  const value = (await this.options.store.getServiceIds())[scopeKey];
52
55
  if (!value) {
@@ -640,8 +640,9 @@ export declare const PLATFORM_AI_MANAGEMENT_KEY_REF = "ai:platform:managementKey
640
640
  * What Archon's management key may do in ai: exactly `ai`'s own
641
641
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
642
642
  * configuration only (offerings, provisioning, offers, budgets, usage, cost)
643
- * plus deleting (archiving) an organisation, never connections, runs or
644
- * webhooks. ai's `/v1/platform/keys` route refuses any permission outside
643
+ * plus deleting (archiving) an organisation and setting another
644
+ * organisation's webhook address and budget, never connections, runs or
645
+ * webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
645
646
  * this same list, so a request for more than this is a 403, not a wider key.
646
647
  */
647
648
  export declare const PLATFORM_AI_MANAGEMENT_PERMISSIONS: readonly string[];
@@ -2330,11 +2330,14 @@ const PLATFORM_ARCHON_PROJECT = 'platform';
2330
2330
  const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
2331
2331
  const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
2332
2332
  /**
2333
- * The database Coolify creates for Archon. Archon's own
2334
- * `drizzle/0001_runtime_role.sql` names its runtime role
2335
- * `current_database() || '_rt'`, so {@link ARCHON_RUNTIME_ROLE} follows.
2333
+ * The database Archon runs on. Live platform-archon moved to `archon_v2` on
2334
+ * the same server (2026-10-02, the move onto `@wtfalch/db`), while its Coolify
2335
+ * resource still names `archon` as its first database. So both URLs built
2336
+ * here set this database themselves, never `internal_db_url`'s path.
2337
+ * Archon's own migrations grant to `current_database() || '_rt'`, so
2338
+ * {@link ARCHON_RUNTIME_ROLE} follows.
2336
2339
  */
2337
- const ARCHON_POSTGRES_DB = 'archon';
2340
+ const ARCHON_POSTGRES_DB = 'archon_v2';
2338
2341
  const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
2339
2342
  /** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
2340
2343
  const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
@@ -2668,11 +2671,12 @@ async function provisionPlatformArchon(context, deps) {
2668
2671
  // The owner URL must name the adopted database's host, or the app's two
2669
2672
  // database URLs would point at different databases.
2670
2673
  const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
2674
+ const ownerUrl = archonDatabaseUrl(db.internal_db_url);
2671
2675
  if (liveOwnerUrl) {
2672
2676
  assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
2673
2677
  }
2674
2678
  else {
2675
- envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
2679
+ envs.push({ key: 'DATABASE_URL_OWNER', value: ownerUrl });
2676
2680
  }
2677
2681
  const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
2678
2682
  if (!liveReportingSite) {
@@ -2692,7 +2696,7 @@ async function provisionPlatformArchon(context, deps) {
2692
2696
  // reflects the env Archon actually ends up running with, not just this
2693
2697
  // run's delta.
2694
2698
  const archonEnvHashOverrides = new Map([
2695
- ['DATABASE_URL_OWNER', liveOwnerUrl ?? db.internal_db_url],
2699
+ ['DATABASE_URL_OWNER', liveOwnerUrl ?? ownerUrl],
2696
2700
  ['REPORTING_SITE', liveReportingSite ?? 'archon'],
2697
2701
  ]);
2698
2702
  const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
@@ -3625,10 +3629,9 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
3625
3629
  return null;
3626
3630
  }
3627
3631
  let host;
3628
- let pathname;
3629
3632
  let search;
3630
3633
  try {
3631
- ({ host, pathname, search } = new URL(dbInternalUrl));
3634
+ ({ host, search } = new URL(dbInternalUrl));
3632
3635
  }
3633
3636
  catch {
3634
3637
  // Node's `ERR_INVALID_URL` carries the whole input, credential included, so
@@ -3636,7 +3639,19 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
3636
3639
  throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
3637
3640
  }
3638
3641
  const password = randomBytes(24).toString('base64url');
3639
- return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
3642
+ return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}/${ARCHON_POSTGRES_DB}${search}`;
3643
+ }
3644
+ /** `internalUrl` on {@link ARCHON_POSTGRES_DB}. The error never carries the URL: it holds a credential. */
3645
+ function archonDatabaseUrl(internalUrl) {
3646
+ let url;
3647
+ try {
3648
+ url = new URL(internalUrl);
3649
+ }
3650
+ catch {
3651
+ throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL_OWNER. Check the database in Coolify and apply again');
3652
+ }
3653
+ url.pathname = `/${ARCHON_POSTGRES_DB}`;
3654
+ return url.toString();
3640
3655
  }
3641
3656
  /**
3642
3657
  * Thrown by {@link createAiManagementKeyMinter} (never by a test's own
@@ -3720,8 +3735,9 @@ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
3720
3735
  * What Archon's management key may do in ai: exactly `ai`'s own
3721
3736
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
3722
3737
  * configuration only (offerings, provisioning, offers, budgets, usage, cost)
3723
- * plus deleting (archiving) an organisation, never connections, runs or
3724
- * webhooks. ai's `/v1/platform/keys` route refuses any permission outside
3738
+ * plus deleting (archiving) an organisation and setting another
3739
+ * organisation's webhook address and budget, never connections, runs or
3740
+ * webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
3725
3741
  * this same list, so a request for more than this is a 403, not a wider key.
3726
3742
  */
3727
3743
  export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
@@ -3732,6 +3748,8 @@ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
3732
3748
  'ai.organisations:provision',
3733
3749
  'ai.organisations:offer',
3734
3750
  'ai.organisations:delete',
3751
+ 'ai.organisations:webhook',
3752
+ 'ai.organisations:budget',
3735
3753
  'ai.keys:issue',
3736
3754
  'ai.budgets:read',
3737
3755
  'ai.budgets:update',