@wtfalch/foundry 0.9.0 → 0.10.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.
@@ -2,12 +2,14 @@ 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 } from '../org.js';
5
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
5
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
+ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
6
7
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
7
8
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
9
+ import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
8
10
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
9
11
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
10
- import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appStepKey, resolveAppEnv, } from './platform-diff.js';
12
+ 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, } from './platform-diff.js';
11
13
  /**
12
14
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
13
15
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
@@ -48,25 +50,6 @@ export class NotBuiltError extends Error {
48
50
  // ---------------------------------------------------------------------------
49
51
  // Hostnames
50
52
  // ---------------------------------------------------------------------------
51
- /**
52
- * The Coolify name of a hosting entry's app: `<slug>` for the `app` entry, so
53
- * the move to a list finds the app that runs today, `<slug>-<key>` for any
54
- * other.
55
- */
56
- function appNameFor(org, entry) {
57
- return entry.key === APP_ENTRY_KEY ? org.slug : `${org.slug}-${entry.key}`;
58
- }
59
- /**
60
- * `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
61
- * `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
62
- * `planSite` document, reused here for the org's own hosted app, its
63
- * Cloudflare tunnel ingress and its ZITADEL issuer apps' redirect URIs
64
- * (task brief: "Hostnames: `<slug>.oikos.host` ... or `DeclaredOrg.domain`
65
- * when `hosting.usesDomain`").
66
- */
67
- function hostnameFor(org) {
68
- return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
69
- }
70
53
  /**
71
54
  * #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
72
55
  * live app's ZITADEL-reported login base URI and the one this step itself
@@ -104,36 +87,6 @@ function callsHostnameFor(org) {
104
87
  ? `calls.${org.domain}`
105
88
  : `${org.slug}-calls.oikos.host`;
106
89
  }
107
- /**
108
- * The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
109
- * (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
110
- * (`org.ts`'s `SiteConfig.enabled`) has no single app hostname to reuse -- it
111
- * needs both its public site at `org.domain` and its cms studio (the sign-in
112
- * and editing surface, `@wtfalch/cms`) at `cms.<domain>`, so both are named
113
- * here; `org.domain` is non-null in that branch, guaranteed by `org.ts`'s
114
- * `validateSite` ("site.enabled requires domain"). Every other org --
115
- * concretely a hosting org, whose one app hostname this served before #115
116
- * ever existed -- keeps exactly the prior single-hostname behavior
117
- * ({@link hostnameFor}), unchanged.
118
- *
119
- * #145: an own-domain calls service adds `calls.<domain>` on top of whichever
120
- * of those a site/hosting org already gets, since calls can run alongside
121
- * either (or neither).
122
- */
123
- function tunnelIngressHostnames(org) {
124
- const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
125
- if (org.calls.enabled && org.calls.usesDomain && org.domain) {
126
- hostnames.push(`calls.${org.domain}`);
127
- }
128
- // #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
129
- // -- no toggle to gate this on. A level-1 org (no domain) adds nothing
130
- // here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
131
- // tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
132
- if (org.domain) {
133
- hostnames.push(agoraHostnameFor(org));
134
- }
135
- return hostnames;
136
- }
137
90
  /**
138
91
  * The health URLs an outside prober should call for this org (#43): one per
139
92
  * app foundry runs for it. A hosting org's app answers at its own hostname,
@@ -143,8 +96,17 @@ function tunnelIngressHostnames(org) {
143
96
  * this file's hostname rules.
144
97
  */
145
98
  export function healthUrls(org) {
146
- if (org.hosting.enabled)
147
- return [`https://${hostnameFor(org)}/api/health`];
99
+ // One URL per entry that has a public name; an internal entry has none.
100
+ if (org.hosting.enabled && appEntry(org.hosting)) {
101
+ return org.hosting.entries.flatMap((entry) => {
102
+ const [hostname] = entryHostnames(org, entry);
103
+ if (!hostname)
104
+ return [];
105
+ return [
106
+ `https://${hostname}${entry.healthPath ?? (entry.buildPack === 'static' ? '/' : '/api/health')}`,
107
+ ];
108
+ });
109
+ }
148
110
  if (org.site.enabled && org.domain) {
149
111
  return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
150
112
  }
@@ -166,7 +128,7 @@ function mailOrgFor(org, usesDomain, mode) {
166
128
  /**
167
129
  * Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
168
130
  * itself points that proxy's routing at whatever `domains` an application
169
- * declares (`coolifyApp`'s `hostnameFor(org)`) -- so a tunnel's ingress only
131
+ * declares (`coolifyApp`'s `appHostname(org)`) -- so a tunnel's ingress only
170
132
  * has to reach the proxy, never a specific app container.
171
133
  * `wtfalch/app-template`'s bootstrap (`bootstrap/steps/cf-tunnel.ts`, prior
172
134
  * art for the exact org this file's `cloudflareTunnel` implements, cited by
@@ -587,6 +549,8 @@ export class FoundryStepExecutor {
587
549
  return this.zitadelCustomers(org, context);
588
550
  case 'coolify:app':
589
551
  return this.coolifyApp(action, org, context);
552
+ case 'coolify:broker-entry':
553
+ return this.coolifyBrokerEntry(action, org, context);
590
554
  case 'coolify:database':
591
555
  return this.coolifyDatabase(action, org, context);
592
556
  case 'coolify:calls':
@@ -637,7 +601,12 @@ export class FoundryStepExecutor {
637
601
  * any policy but foundry's, since that policy could let others in.
638
602
  */
639
603
  async cloudflareAccess(org) {
640
- const domain = hostnameFor(org);
604
+ // Only the `app` entry has a public name (`planAccess`); per-entry
605
+ // hostnames are issue 309.
606
+ const entry = appEntry(org.hosting);
607
+ if (!entry)
608
+ throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
609
+ const domain = appHostname(org);
641
610
  const name = `foundry:${org.id}`;
642
611
  const cf = this.ports.cloudflare;
643
612
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
@@ -655,7 +624,7 @@ export class FoundryStepExecutor {
655
624
  await cf.createAccessPolicy(app.id, {
656
625
  name: ACCESS_POLICY_NAME,
657
626
  decision: 'allow',
658
- include: (appEntry(org.hosting)?.accessEmails ?? []).map((email) => ({ email: { email } })),
627
+ include: entry.accessEmails.map((email) => ({ email: { email } })),
659
628
  });
660
629
  }
661
630
  return { resourceId: app.id };
@@ -842,6 +811,9 @@ export class FoundryStepExecutor {
842
811
  ...(recorded[APP_CLIENT_ID_SCOPE_KEY]
843
812
  ? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
844
813
  : []),
814
+ ...Object.keys(recorded)
815
+ .filter((key) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
816
+ .map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
845
817
  ...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
846
818
  ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
847
819
  ].map((name) => recorded[`zitadel:app:${name}`]));
@@ -1025,7 +997,7 @@ export class FoundryStepExecutor {
1025
997
  ? cmsHostnameFor(org)
1026
998
  : isAgoraClient
1027
999
  ? agoraHostnameFor(org)
1028
- : hostnameFor(org);
1000
+ : appHostname(org);
1029
1001
  // Native apps sign in via device code, with no redirect of their own
1030
1002
  // (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
1031
1003
  // file) -- empty arrays rather than a web app's callback/logout URLs.
@@ -1037,11 +1009,23 @@ export class FoundryStepExecutor {
1037
1009
  const redirectUri = isAgoraClient
1038
1010
  ? `https://${hostname}/callback`
1039
1011
  : `https://${hostname}/auth/callback`;
1012
+ // An entry with `uses.auth` signs in at its own exact hostnames, one pair
1013
+ // of addresses each; a `*` adds none (`entryHostnames`).
1014
+ const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
1015
+ const hosts = authEntry ? entryHostnames(org, authEntry) : null;
1040
1016
  const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
1041
1017
  name: action.key,
1042
1018
  appType,
1043
- redirectUris: appType === 'native' ? [] : [redirectUri],
1044
- postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
1019
+ redirectUris: appType === 'native'
1020
+ ? []
1021
+ : hosts
1022
+ ? hosts.map((host) => `https://${host}/auth/callback`)
1023
+ : [redirectUri],
1024
+ postLogoutRedirectUris: appType === 'native'
1025
+ ? []
1026
+ : hosts
1027
+ ? hosts.map((host) => `https://${host}/`)
1028
+ : [`https://${hostname}/`],
1045
1029
  });
1046
1030
  if (appType === 'web') {
1047
1031
  // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
@@ -1056,7 +1040,9 @@ export class FoundryStepExecutor {
1056
1040
  ? CMS_CLIENT_ID_SCOPE_KEY
1057
1041
  : isAgoraClient
1058
1042
  ? AGORA_CLIENT_ID_SCOPE_KEY
1059
- : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
1043
+ : authEntry
1044
+ ? entryClientIdScopeKey(action.key)
1045
+ : APP_CLIENT_ID_SCOPE_KEY, created.clientId);
1060
1046
  }
1061
1047
  return { resourceId: created.appId };
1062
1048
  }
@@ -1111,9 +1097,9 @@ export class FoundryStepExecutor {
1111
1097
  await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
1112
1098
  return { resourceId: recordedAppId };
1113
1099
  }
1114
- const entry = appEntry(org.hosting);
1100
+ const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
1115
1101
  if (!entry)
1116
- throw new Error(`coolify:app for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
1102
+ throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
1117
1103
  // A level-1 org with no bespoke app of its own gets the shared standard
1118
1104
  // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
1119
1105
  // with no `repo`.
@@ -1139,6 +1125,15 @@ export class FoundryStepExecutor {
1139
1125
  environmentIds: [environment.id],
1140
1126
  });
1141
1127
  }
1128
+ // An entry with no names is internal: the other entries reach it as
1129
+ // `<slug>-<key>`, and it gets no domain, route or DNS record.
1130
+ const names = entryHostnames(org, entry);
1131
+ const isStatic = entry.buildPack === 'static';
1132
+ const folderField = isStatic ? 'publish_directory' : 'base_directory';
1133
+ const settingsKey = appSettingsAppliedScopeKey(entry.key);
1134
+ const domainsKey = appDomainsAppliedScopeKey(entry.key);
1135
+ let haveNames;
1136
+ let have;
1142
1137
  let appId;
1143
1138
  if (existing) {
1144
1139
  appId = existing.uuid;
@@ -1152,6 +1147,8 @@ export class FoundryStepExecutor {
1152
1147
  if (!isRunningOrStarting(live.status)) {
1153
1148
  await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
1154
1149
  }
1150
+ have = appliedAppSettings(context.recordedIds(), entry.key);
1151
+ haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
1155
1152
  }
1156
1153
  else {
1157
1154
  const server = await this.resolveServer();
@@ -1166,13 +1163,49 @@ export class FoundryStepExecutor {
1166
1163
  git_branch: entry.branch,
1167
1164
  build_pack: entry.buildPack,
1168
1165
  name: appNameFor(org, entry),
1169
- domains: hostnameFor(org),
1166
+ domains: names.length > 0 ? names.join(',') : undefined,
1167
+ autogenerate_domain: names.length > 0 ? undefined : false,
1168
+ // A static site is served by Coolify's own web server on port 80.
1169
+ ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
1170
+ is_auto_deploy_enabled: entry.autoDeploy,
1171
+ [folderField]: entry.folder === null ? undefined : `/${entry.folder}`,
1170
1172
  // Only the shared standard app builds from this repo; a bespoke
1171
1173
  // app's Dockerfile is at its own repo's root.
1172
1174
  dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
1173
1175
  });
1174
1176
  appId = created.uuid;
1175
- }
1177
+ // Create sent every setting, so nothing is left to patch.
1178
+ have = appSettingsOf(entry);
1179
+ haveNames = names;
1180
+ }
1181
+ // Patch what differs from what the app has. A crash before the marker is
1182
+ // recorded below leaves the old one, so the retry patches the same
1183
+ // difference again.
1184
+ const want = appSettingsOf(entry);
1185
+ const patch = {};
1186
+ if (want.folder !== have.folder)
1187
+ patch[folderField] = want.folder ? `/${want.folder}` : '/';
1188
+ if (want.port !== have.port && want.port !== null && !isStatic) {
1189
+ patch.ports_exposes = String(want.port);
1190
+ }
1191
+ if (want.autoDeploy !== have.autoDeploy)
1192
+ patch.is_auto_deploy_enabled = want.autoDeploy;
1193
+ // Create names the app after `domains`; a changed name reaches a live app
1194
+ // here (rule 3). The internal name is set after create, as the calls
1195
+ // stack's apps do.
1196
+ if (JSON.stringify(names) !== JSON.stringify(haveNames))
1197
+ patch.domains = names.join(',');
1198
+ if (names.length === 0 && (!existing || patch.domains !== undefined)) {
1199
+ patch.custom_internal_name = appNameFor(org, entry);
1200
+ }
1201
+ if (Object.keys(patch).length > 0)
1202
+ await this.ports.coolify.updateApplication(appId, patch);
1203
+ // The container builds and listens from these only at its next deploy.
1204
+ const settingsNeedDeploy = Boolean(patch.base_directory ||
1205
+ patch.publish_directory ||
1206
+ patch.ports_exposes ||
1207
+ patch.domains !== undefined ||
1208
+ patch.custom_internal_name);
1176
1209
  // The database plans before the app (`diff.ts`'s `computePlanDetail`,
1177
1210
  // hosting-wiring campaign) so that, on the same apply, the database's
1178
1211
  // resource id is already recorded by the time this runs -- but this also
@@ -1188,7 +1221,7 @@ export class FoundryStepExecutor {
1188
1221
  // Restart when the push changed something, or when no earlier run got as
1189
1222
  // far as restarting after one (marker missing): a crash between the push
1190
1223
  // and the restart leaves the env right and the container stale.
1191
- const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]);
1224
+ const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbEnvAppliedScopeKey(entry.key)]);
1192
1225
  // Every app keeps its own logs across a redeploy (#75). Unconditional,
1193
1226
  // like the env push below: found by mount path before create, so a
1194
1227
  // re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
@@ -1196,27 +1229,42 @@ export class FoundryStepExecutor {
1196
1229
  // `ensureLogVolume` whether this app was already running before this
1197
1230
  // call -- see its own doc comment for why that decides whether to
1198
1231
  // restart it.
1199
- await this.ensureLogVolume(appId, org, Boolean(existing));
1232
+ await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
1200
1233
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
1201
1234
  // unlike the database push above: every app has a hostname, and an
1202
1235
  // upsert of the same value on a later apply is a no-op, not a change.
1203
- const appEnv = await this.pushAppEnv(appId, org, entry, context);
1236
+ const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null);
1204
1237
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
1205
1238
  // that was already there is restarted, once, when its database URLs or its
1206
1239
  // declared appEnv changed.
1207
- if ((appEnv.changed || dbNeedsRestart) && existing)
1240
+ // A new folder needs a build, which a restart does not do, so a settings
1241
+ // change deploys instead (rule 3), and that carries the env too.
1242
+ if (settingsNeedDeploy)
1243
+ await this.ports.coolify.deploy(appId);
1244
+ else if ((appEnv.changed || dbNeedsRestart) && existing)
1208
1245
  await this.ports.coolify.restart(appId);
1209
- // Recorded only after the restart succeeded: a failed restart leaves the
1210
- // old value, so the next plan replans (rule 3). The db marker is not a URL.
1211
- context.recordScopeId(APP_ENV_APPLIED_SCOPE_KEY, appEnv.applied);
1246
+ // Recorded only after the redeploy succeeded: a failed one leaves the old
1247
+ // value, so the next plan replans (rule 3). The db marker is not a URL.
1248
+ // A port taken back to null was not patched, so the marker keeps the old
1249
+ // one and `planApp` reports it instead of planning it.
1250
+ context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
1251
+ context.recordScopeId(domainsKey, JSON.stringify(names));
1252
+ context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
1253
+ if (entry.uses?.auth) {
1254
+ context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
1255
+ }
1212
1256
  if (dbPush.pushed)
1213
- context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
1257
+ context.recordScopeId(dbEnvAppliedScopeKey(entry.key), '1');
1214
1258
  // #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
1215
1259
  // Lettermint project, domain restriction and `MAIL_FROM` (only the app
1216
1260
  // started by this same step, per the design's "only apps that send mail
1217
1261
  // get it" -- cms and calls do not). A no-op until `coolify:mail` has
1218
1262
  // actually resolved a mail domain for this org.
1219
- await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
1263
+ // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
1264
+ // second entry sharing the name would delete the first's live token.
1265
+ if (entry.key === APP_ENTRY_KEY) {
1266
+ await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
1267
+ }
1220
1268
  return { resourceId: appId };
1221
1269
  }
1222
1270
  /**
@@ -1240,13 +1288,13 @@ export class FoundryStepExecutor {
1240
1288
  * `createGithubAppApplication`'s own instant-deploy path, so a restart
1241
1289
  * here would only be a redundant extra one).
1242
1290
  */
1243
- async ensureLogVolume(appId, org, wasAlreadyRunning) {
1291
+ async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
1244
1292
  const storages = await this.ports.coolify.listStorages(appId);
1245
1293
  const already = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
1246
1294
  if (already)
1247
1295
  return;
1248
1296
  await this.ports.coolify.createPersistentStorage(appId, {
1249
- name: `${org.slug}-logs`,
1297
+ name: `${appName}-logs`,
1250
1298
  mount_path: LOG_DIR,
1251
1299
  });
1252
1300
  if (wasAlreadyRunning) {
@@ -1293,14 +1341,16 @@ export class FoundryStepExecutor {
1293
1341
  // Handles the database being provisioned after the app already exists
1294
1342
  // (`coolifyApp`'s own push handles the more common order, database
1295
1343
  // before app on the same apply -- `diff.ts`'s `computePlanDetail`).
1296
- const appId = context.recordedIds()[`coolify:app:${appStepKey(org)}`];
1297
- if (appId) {
1344
+ const dbEntry = org.hosting.entries.find((e) => e.database);
1345
+ const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
1346
+ if (dbEntry && appId) {
1347
+ const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
1298
1348
  const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
1299
1349
  if (push.pushed) {
1300
- if (push.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]) {
1350
+ if (push.changed || !context.recordedIds()[markerKey]) {
1301
1351
  await this.ports.coolify.restart(appId);
1302
1352
  }
1303
- context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
1353
+ context.recordScopeId(markerKey, '1');
1304
1354
  }
1305
1355
  }
1306
1356
  return { resourceId: dbId };
@@ -1390,21 +1440,41 @@ export class FoundryStepExecutor {
1390
1440
  * that call runs only once the `app` entry's `database` is declared and
1391
1441
  * provisioned, where this one is unconditional (#239).
1392
1442
  */
1393
- async pushAppEnv(appId, org, entry, context) {
1443
+ async pushAppEnv(appId, org, entry, context, authIssuer) {
1394
1444
  const recorded = context.recordedIds();
1395
1445
  const envs = [
1396
- { key: 'APP_URL', value: `https://${hostnameFor(org)}` },
1446
+ // Only the `app` entry has a public name; the others are internal.
1447
+ ...(entry.key === APP_ENTRY_KEY
1448
+ ? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
1449
+ : []),
1397
1450
  // #75: read back by the package-side pino sink (not this repo's job,
1398
1451
  // #75 "What belongs where") once `ensureLogVolume` has mounted the
1399
1452
  // matching volume.
1400
1453
  { key: 'LOG_DIR', value: LOG_DIR },
1401
1454
  ];
1402
- const zitadelOrgId = recorded.zitadel_org;
1403
- if (zitadelOrgId)
1404
- envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
1405
- const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
1406
- if (clientId)
1407
- envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
1455
+ // An entry with `uses.auth` signs in one organisation, with its own client
1456
+ // and the issuer; any other entry keeps the org's staff organisation and
1457
+ // the one hosted-app client.
1458
+ const authEnv = entryAuthEnv(org, entry, recorded, authIssuer);
1459
+ if (entry.uses?.auth) {
1460
+ // A customers entry never falls back to the staff organisation's id.
1461
+ if (!authEnv.AUTH_ORGANIZATION_ID) {
1462
+ throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth.users}, whose organisation id is not recorded yet`);
1463
+ }
1464
+ for (const [key, value] of Object.entries(authEnv))
1465
+ envs.push({ key, value });
1466
+ const appKey = await this.ensureAuthAppKey(appId);
1467
+ if (appKey)
1468
+ envs.push({ key: 'AUTH_APP_KEY', value: appKey });
1469
+ }
1470
+ else {
1471
+ const zitadelOrgId = recorded.zitadel_org;
1472
+ if (zitadelOrgId)
1473
+ envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
1474
+ const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
1475
+ if (clientId)
1476
+ envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
1477
+ }
1408
1478
  // The invited owner's ZITADEL user id (gap P3): the app uses it to make
1409
1479
  // them its own owner in authz on first sign-in. `runSteps` records this
1410
1480
  // under the same `system:kind:key` shape every step's `resourceId` uses
@@ -1433,7 +1503,66 @@ export class FoundryStepExecutor {
1433
1503
  // replans this step when the declared map changes (rule 3). Ids and a
1434
1504
  // domain, never a secret.
1435
1505
  const applied = JSON.stringify(appEnv);
1436
- return { changed: applied !== (recorded[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}'), applied };
1506
+ // The sign-in values, recorded by `coolifyApp` the same way; the key is
1507
+ // never part of them.
1508
+ const authApplied = JSON.stringify(authEnv);
1509
+ return {
1510
+ changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
1511
+ (Boolean(entry.uses?.auth) &&
1512
+ authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
1513
+ applied,
1514
+ authApplied,
1515
+ };
1516
+ }
1517
+ /**
1518
+ * F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
1519
+ * of at least 32 characters (the broker refuses a shorter one). `null` once
1520
+ * it has one: the app's env is where the key lives, and
1521
+ * `coolifyBrokerEntry` reads it back from there. It goes to this env and
1522
+ * the broker's, nowhere else.
1523
+ */
1524
+ async ensureAuthAppKey(appId) {
1525
+ const envs = await this.ports.coolify.listEnvs(appId);
1526
+ const already = envs.some((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview && (e.value ?? '').length >= 32);
1527
+ return already ? null : randomBytes(32).toString('base64url');
1528
+ }
1529
+ /**
1530
+ * `plan/diff.ts`'s `planBrokerEntries` (F5): the entry's `AUTH_APPS` entry,
1531
+ * written by `writeBrokerEntry` with the key `pushAppEnv` put in the app's
1532
+ * env. Records what it wrote (never the key) and any foreign entry the
1533
+ * broker would drop.
1534
+ */
1535
+ async coolifyBrokerEntry(action, org, context) {
1536
+ const broker = this.ports.broker;
1537
+ if (!broker)
1538
+ throw new Error(`coolify:broker-entry for ${org.slug}: the broker port is not wired`);
1539
+ const recorded = context.recordedIds();
1540
+ const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
1541
+ if (!entry?.uses?.auth) {
1542
+ throw new Error(`coolify:broker-entry for ${org.slug}: no uses.auth entry for ${action.key}`);
1543
+ }
1544
+ const wanted = wantedBrokerEntry(org, entry, recorded);
1545
+ if ('missing' in wanted) {
1546
+ throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
1547
+ }
1548
+ const appId = recorded[`coolify:app:${action.key}`];
1549
+ if (!appId) {
1550
+ throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
1551
+ }
1552
+ const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
1553
+ if (!appKey || appKey.length < 32) {
1554
+ throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
1555
+ }
1556
+ const previousRaw = recorded[brokerEntryAppliedScopeKey(entry.key)];
1557
+ const result = await writeBrokerEntry(broker, {
1558
+ want: wanted.want,
1559
+ appKey,
1560
+ previous: previousRaw ? JSON.parse(previousRaw) : null,
1561
+ });
1562
+ context.recordScopeId(brokerEntryWarningsScopeKey(entry.key), JSON.stringify(result.invalidForeign));
1563
+ // Last: a run that failed above plans the step again.
1564
+ context.recordScopeId(brokerEntryAppliedScopeKey(entry.key), JSON.stringify(wanted.want));
1565
+ return {};
1437
1566
  }
1438
1567
  /**
1439
1568
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
@@ -2554,7 +2683,7 @@ export class FoundryStepExecutor {
2554
2683
  // offline.tf's bootstrap, cited by
2555
2684
  // https://github.com/wtfalch/foundry/issues/71): every app on the
2556
2685
  // Coolify box already gets a Traefik route from the `domains` its
2557
- // `coolify:app` step declared (`hostnameFor(org)`, this file's
2686
+ // `coolify:app` step declared (`appHostname(org)`, this file's
2558
2687
  // `coolifyApp`), so a cloudflared connector joined to the same Docker
2559
2688
  // network only has to reach Traefik and let it route by Host header --
2560
2689
  // the connector never needs to know the app's own container name or
@@ -2802,7 +2931,7 @@ export class FoundryStepExecutor {
2802
2931
  }
2803
2932
  const dnsTarget = `${tunnelId}.cfargotunnel.com`;
2804
2933
  const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
2805
- const hostnames = tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
2934
+ const hostnames = tunnelDnsHostnames(org, domain);
2806
2935
  const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
2807
2936
  const conflicts = [];
2808
2937
  const toCreate = [];
@@ -0,0 +1,24 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ /**
3
+ * The full names an entry is served at. With `hosting.usesDomain` off, or no
4
+ * domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
5
+ * and every other entry is internal. `'*'` names nothing here; the wildcard is
6
+ * acted on elsewhere.
7
+ */
8
+ export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
9
+ /**
10
+ * The one primary name of the org's app: its first, or the name the app was
11
+ * served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
12
+ * and the jobs registration read it.
13
+ */
14
+ export declare function appHostname(org: DeclaredOrg): string;
15
+ /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
16
+ export declare function legacyAppHostname(org: DeclaredOrg): string;
17
+ /**
18
+ * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
19
+ * #164): a site's two names, every entry's names, an own-domain calls name and
20
+ * Agora's. An org that does not host keeps the single name it always had.
21
+ */
22
+ export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
23
+ /** The ingress names that get a DNS record: those under the org's own domain. */
24
+ export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string): string[];
@@ -0,0 +1,56 @@
1
+ import { APP_ENTRY_KEY } from '../org.js';
2
+ import { agoraHostnameFor } from './agora.js';
3
+ /**
4
+ * The full names an entry is served at. With `hosting.usesDomain` off, or no
5
+ * domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
6
+ * and every other entry is internal. `'*'` names nothing here; the wildcard is
7
+ * acted on elsewhere.
8
+ */
9
+ export function entryHostnames(org, entry) {
10
+ const { domain } = org;
11
+ if (!org.hosting.usesDomain || !domain) {
12
+ return entry.key === APP_ENTRY_KEY ? [`${org.slug}.oikos.host`] : [];
13
+ }
14
+ return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
15
+ }
16
+ /**
17
+ * The one primary name of the org's app: its first, or the name the app was
18
+ * served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
19
+ * and the jobs registration read it.
20
+ */
21
+ export function appHostname(org) {
22
+ const entry = org.hosting.entries.find((e) => e.key === APP_ENTRY_KEY);
23
+ const first = entry ? entryHostnames(org, entry)[0] : undefined;
24
+ return first ?? legacyAppHostname(org);
25
+ }
26
+ /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
27
+ export function legacyAppHostname(org) {
28
+ return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
29
+ }
30
+ /**
31
+ * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
32
+ * #164): a site's two names, every entry's names, an own-domain calls name and
33
+ * Agora's. An org that does not host keeps the single name it always had.
34
+ */
35
+ export function tunnelIngressHostnames(org) {
36
+ const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
37
+ if (org.hosting.enabled) {
38
+ for (const entry of org.hosting.entries)
39
+ hostnames.push(...entryHostnames(org, entry));
40
+ }
41
+ else if (hostnames.length === 0) {
42
+ hostnames.push(legacyAppHostname(org));
43
+ }
44
+ if (org.calls.enabled && org.calls.usesDomain && org.domain) {
45
+ hostnames.push(`calls.${org.domain}`);
46
+ }
47
+ // #164: Agora always moves onto `agora.<domain>` once `org.domain` is set.
48
+ if (org.domain) {
49
+ hostnames.push(agoraHostnameFor(org));
50
+ }
51
+ return [...new Set(hostnames)];
52
+ }
53
+ /** The ingress names that get a DNS record: those under the org's own domain. */
54
+ export function tunnelDnsHostnames(org, domain) {
55
+ return tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
56
+ }
@@ -24,6 +24,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
24
24
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
25
25
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
26
26
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
27
+ export type { BrokerPort } from './broker-entries.js';
28
+ export { PlatformBrokerPort } from './broker-entries.js';
27
29
  export type { LoginHostBinding, LoginHostBindingsPort } from './login-host.js';
28
30
  export { loginHostnameFor } from './login-host.js';
29
31
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
@@ -12,6 +12,7 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
12
12
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
13
13
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
14
14
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
15
+ export { PlatformBrokerPort } from './broker-entries.js';
15
16
  export { loginHostnameFor } from './login-host.js';
16
17
  export { checkMailIdentityAudience } from './mail-identity.js';
17
18
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
@@ -0,0 +1,8 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ /**
3
+ * The names under the org's domain that foundry owns, so no hosting entry may
4
+ * take one: Agora, the cms studio when the site is on, calls when it uses the
5
+ * domain, the sign-in host when the issuer does, and every platform service
6
+ * hostname that falls under the domain. Empty for an org with no domain.
7
+ */
8
+ export declare function lockedHostnames(org: DeclaredOrg, platformHostnames: readonly string[]): Set<string>;
@@ -0,0 +1,26 @@
1
+ import { agoraHostnameFor } from './agora.js';
2
+ import { loginHostnameFor } from './login-host.js';
3
+ /**
4
+ * The names under the org's domain that foundry owns, so no hosting entry may
5
+ * take one: Agora, the cms studio when the site is on, calls when it uses the
6
+ * domain, the sign-in host when the issuer does, and every platform service
7
+ * hostname that falls under the domain. Empty for an org with no domain.
8
+ */
9
+ export function lockedHostnames(org, platformHostnames) {
10
+ const { domain } = org;
11
+ const locked = new Set();
12
+ if (!domain)
13
+ return locked;
14
+ locked.add(agoraHostnameFor(org));
15
+ if (org.site.enabled)
16
+ locked.add(`cms.${domain}`);
17
+ if (org.calls.enabled && org.calls.usesDomain)
18
+ locked.add(`calls.${domain}`);
19
+ if (org.issuer.usesDomain)
20
+ locked.add(loginHostnameFor(org));
21
+ for (const hostname of platformHostnames) {
22
+ if (hostname.endsWith(`.${domain}`))
23
+ locked.add(hostname);
24
+ }
25
+ return locked;
26
+ }