@wtfalch/foundry 0.41.2 → 0.42.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.
@@ -14,6 +14,7 @@ import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerNam
14
14
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
15
15
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
16
16
  import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
17
+ import { previewsOn } from './coolify-previews.js';
17
18
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
18
19
  import { waitForNewDeployment, } from './deploy-health.js';
19
20
  import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
@@ -43,7 +44,9 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
43
44
  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, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
44
45
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
45
46
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
47
+ import { editSignInBindings } from './sign-in-bindings.js';
46
48
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
49
+ import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
47
50
  import { moveSigninDatabase } from './signin-db-move.js';
48
51
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
49
52
  /**
@@ -122,6 +125,32 @@ export class NotBuiltError extends Error {
122
125
  * so a host is never read as "not in use" purely because ZITADEL's own
123
126
  * casing or slashing differs from this step's.
124
127
  */
128
+ /**
129
+ * The in-app hosts a login-host step carries, read back and checked: a plan from an older build carries
130
+ * none, and a malformed entry is refused by name (never echoed) rather than bound.
131
+ */
132
+ function inAppAppsOf(raw, slug) {
133
+ if (raw === undefined)
134
+ return [];
135
+ if (!Array.isArray(raw))
136
+ throw new Error(`zitadel:login-host for ${slug}: apps is not a list`);
137
+ return raw.map((entry) => {
138
+ const e = entry;
139
+ if (!e ||
140
+ (e.app !== 'agora' && e.app !== 'boule') ||
141
+ typeof e.host !== 'string' ||
142
+ !e.host ||
143
+ typeof e.clientId !== 'string' ||
144
+ !e.clientId) {
145
+ throw new Error(`zitadel:login-host for ${slug}: an in-app host entry is malformed`);
146
+ }
147
+ return { app: e.app, host: e.host, clientId: e.clientId };
148
+ });
149
+ }
150
+ /** The ZITADEL ids of the company's Agora and Boule clients: the apps in-app sign-in can move. */
151
+ function ownInAppAppIds(recorded) {
152
+ return new Set([recorded['zitadel:app:agora'], recorded['zitadel:app:boule']].filter((id) => Boolean(id)));
153
+ }
125
154
  function normalizedLoginBaseUri(uri) {
126
155
  return uri.toLowerCase().replace(/\/$/, '');
127
156
  }
@@ -1101,7 +1130,7 @@ export class FoundryStepExecutor {
1101
1130
  case 'cloudflare:gate':
1102
1131
  return this.cloudflareGate(action, org, context);
1103
1132
  case 'cloudflare:ratelimit':
1104
- return this.cloudflareRateLimit(org);
1133
+ return this.cloudflareRateLimit(org, context);
1105
1134
  case 'cloudflare:worker':
1106
1135
  return this.cloudflareWorker(action, org, context);
1107
1136
  case 'files:organisation':
@@ -1310,7 +1339,7 @@ export class FoundryStepExecutor {
1310
1339
  * `cloudflareAccess`'s own refusal when a foreign Access app already
1311
1340
  * holds the hostname.
1312
1341
  */
1313
- async cloudflareRateLimit(org) {
1342
+ async cloudflareRateLimit(org, context) {
1314
1343
  if (!org.domain) {
1315
1344
  throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
1316
1345
  }
@@ -1319,20 +1348,41 @@ export class FoundryStepExecutor {
1319
1348
  if (!zone) {
1320
1349
  throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
1321
1350
  }
1351
+ // The zone's one Free-plan rule: the base one, or with in-app sign-in the same limit that also
1352
+ // covers POSTs under `/sign-in/` (static assets there stay unlimited).
1353
+ const inApp = inAppSignIn(org);
1354
+ const wanted = inApp
1355
+ ? { description: SIGN_IN_RATE_LIMIT_DESCRIPTION, expression: SIGN_IN_RATE_LIMIT_EXPRESSION }
1356
+ : { description: RATE_LIMIT_RULE_DESCRIPTION, expression: SIGN_IN_PATH_EXPRESSION };
1357
+ const ours = [RATE_LIMIT_RULE_DESCRIPTION, SIGN_IN_RATE_LIMIT_DESCRIPTION];
1322
1358
  const rateLimits = this.rateLimitPort();
1323
1359
  const rules = await rateLimits.listRateLimitRules(zone.id);
1324
- const own = rules.find((r) => r.description === RATE_LIMIT_RULE_DESCRIPTION);
1360
+ const own = rules.find((r) => r.description === wanted.description);
1325
1361
  if (!own) {
1326
- const foreign = rules[0];
1362
+ const foreign = rules.find((r) => !ours.includes(r.description ?? ''));
1327
1363
  if (foreign) {
1328
1364
  throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
1329
1365
  }
1366
+ // The other variant of foundry's own rule holds the zone's one slot: remove it, then make this one.
1367
+ const stale = rules.find((r) => ours.includes(r.description ?? ''));
1368
+ if (stale) {
1369
+ if (!rateLimits.deleteRateLimitRule) {
1370
+ throw new Error(`cloudflare:ratelimit for ${org.slug}: the rate limit rule must change but this host's rate limit port cannot delete a rule`);
1371
+ }
1372
+ try {
1373
+ await rateLimits.deleteRateLimitRule(zone.id, stale.id);
1374
+ }
1375
+ catch (error) {
1376
+ if (!isNotFoundError(error))
1377
+ throw error;
1378
+ }
1379
+ }
1330
1380
  }
1331
1381
  // Edge adopts a live rule with no edge row (made before edge owned rate
1332
1382
  // limits) and then makes the rule once: a repeat returns it unchanged.
1333
1383
  const created = await rateLimits.createRateLimitRule(zone.id, {
1334
- description: RATE_LIMIT_RULE_DESCRIPTION,
1335
- expression: SIGN_IN_PATH_EXPRESSION,
1384
+ description: wanted.description,
1385
+ expression: wanted.expression,
1336
1386
  action: 'block',
1337
1387
  ratelimit: {
1338
1388
  characteristics: ['cf.colo.id', 'ip.src'],
@@ -1341,6 +1391,8 @@ export class FoundryStepExecutor {
1341
1391
  mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
1342
1392
  },
1343
1393
  });
1394
+ // Last: a crash before here plans the step again.
1395
+ context.recordScopeId(SIGN_IN_RATE_LIMIT_SCOPE_KEY, inApp ? '1' : '');
1344
1396
  return { resourceId: created.id };
1345
1397
  }
1346
1398
  /**
@@ -1745,6 +1797,13 @@ export class FoundryStepExecutor {
1745
1797
  * client id that binding holds. Apps created later are not in it yet:
1746
1798
  * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
1747
1799
  *
1800
+ * In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
1801
+ * bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
1802
+ * records that). The bind moves the app's client id off the company's binding in the same write (the
1803
+ * login refuses a client id in two bindings); a company binding left with no client is removed.
1804
+ * Switched off, the client id goes back and the base URI returns to the login host, and a trusted
1805
+ * domain is given up only after the app dropped the env.
1806
+ *
1748
1807
  * #274: when the org's host has moved (`recorded[key]`, the *previous*
1749
1808
  * run's resourceId, differs from this run's `host`), also removes every
1750
1809
  * host that is no longer the org's current one from both places --
@@ -1812,8 +1871,13 @@ export class FoundryStepExecutor {
1812
1871
  if (!zitadelOrgId) {
1813
1872
  throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1814
1873
  }
1815
- // `planLoginHost` blocks the step while this port is not wired.
1816
- const bindings = this.ports.loginHostBindings;
1874
+ // A company that runs its own services writes the host table of its own in-app login app; every
1875
+ // other company, the shared one's through the host's port. `planLoginHost` blocks the step while
1876
+ // the shared port is not wired.
1877
+ const own = ownsServices(org);
1878
+ const bindings = own
1879
+ ? this.ownSignInBindings(org, recorded, action.desired.host)
1880
+ : this.ports.loginHostBindings;
1817
1881
  if (!bindings) {
1818
1882
  throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
1819
1883
  }
@@ -1821,13 +1885,84 @@ export class FoundryStepExecutor {
1821
1885
  // The platform's own shared sign-in host: already trusted and routed by
1822
1886
  // the shared instance, so it gets no trusted domain or binding here.
1823
1887
  const shared = action.desired.shared === true;
1824
- const clientIds = loginHostClientIds(org, recorded);
1888
+ // In-app sign-in: the plan carries the app hosts to bind (empty with the switch off).
1889
+ const inApp = inAppAppsOf(action.desired.apps, org.slug);
1890
+ // The instance ignores an app's own base URI unless it is told not to: a port that cannot say so
1891
+ // stops the step before anything is written, rather than recording a base URI that has no effect.
1892
+ if (!shared && own && inApp.length > 0 && !this.ports.zitadel.allowPerAppLoginBaseUri) {
1893
+ throw new Error(`zitadel:login-host for ${org.slug}: the ZITADEL port cannot allow a per-app login base URI, so the app hosts cannot sign in on their own addresses`);
1894
+ }
1895
+ // The app hosts that are bound and get the app's own address as base URI: only once their app runs
1896
+ // with `SIGNIN_UPSTREAM` (the app steps record it), never before. Every other app host is trusted
1897
+ // and waits, and the company's own binding keeps its client id, so the old path signs in meanwhile.
1898
+ const targets = inAppTargets(inApp, recorded);
1899
+ const activePairs = inAppStatePairs(targets.active);
1900
+ const activeHosts = new Set(activePairs.map((pair) => pair.host));
1901
+ const envLive = signInEnvLiveHosts(recorded);
1902
+ // The login refuses a client id held by two bindings: an active app host's client id is in its own binding only.
1903
+ const clientIds = loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId));
1825
1904
  if (!shared) {
1826
- await this.ports.zitadel.ensureTrustedDomain(host);
1905
+ // The company's own login host is its instance's external domain: trusted and routed already.
1906
+ if (!own)
1907
+ await this.ports.zitadel.ensureTrustedDomain(host);
1908
+ // Each app host is trusted first.
1909
+ for (const app of inApp)
1910
+ await this.ports.zitadel.ensureTrustedDomain(app.host);
1911
+ // An app host bound before and not active now gives its client id back to the company's binding:
1912
+ // its own binding goes first, so no write leaves the id in two bindings.
1913
+ let unbound = false;
1914
+ for (const old of inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY])) {
1915
+ if (activeHosts.has(old))
1916
+ continue;
1917
+ try {
1918
+ await bindings.deleteBinding(old);
1919
+ }
1920
+ catch (error) {
1921
+ if (!isNotFoundError(error))
1922
+ throw error;
1923
+ }
1924
+ unbound = true;
1925
+ }
1926
+ if (unbound)
1927
+ await bindings.flush?.();
1827
1928
  // zitadel-login's own routing table, written before any app is repointed
1828
1929
  // so no app ever points at a host with no binding.
1829
- await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1930
+ if (!own) {
1931
+ if (clientIds.length > 0 || activePairs.length === 0) {
1932
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1933
+ }
1934
+ else {
1935
+ // Every client of the company signs in on its app's own address: the login refuses a binding
1936
+ // with no client ids, so the company's own binding goes (nothing signs in on this host).
1937
+ try {
1938
+ await bindings.deleteBinding(host);
1939
+ }
1940
+ catch (error) {
1941
+ if (!isNotFoundError(error))
1942
+ throw error;
1943
+ }
1944
+ }
1945
+ }
1946
+ for (const app of inApp) {
1947
+ if (!activeHosts.has(app.host))
1948
+ continue;
1949
+ await bindings.upsertBinding({ ...inAppBindingFor(app), organizationId: zitadelOrgId });
1950
+ }
1951
+ // One write for every upsert above (one redeploy of the login), before any app is repointed.
1952
+ await bindings.flush?.();
1953
+ if (own && activeHosts.size > 0) {
1954
+ await this.ports.zitadel.allowPerAppLoginBaseUri?.(`https://${host}/`);
1955
+ }
1830
1956
  }
1957
+ // The base URI each app's sign-in starts from: its own address under `/sign-in/` in-app, else the login host.
1958
+ const appBaseUris = new Map();
1959
+ for (const app of inApp) {
1960
+ const appId = recorded[`zitadel:app:${app.app}`];
1961
+ if (appId && activeHosts.has(app.host)) {
1962
+ appBaseUris.set(appId, `https://${app.host}${SIGN_IN_BASE_PATH}/`);
1963
+ }
1964
+ }
1965
+ const baseUriOf = (appId) => appBaseUris.get(appId) ?? `https://${host}/`;
1831
1966
  // Only apps whose client id the binding now holds: any other app in the
1832
1967
  // project would lose sign-in. The rest move on a later run, once the
1833
1968
  // recorded client ids differ from the binding and replan this step.
@@ -1858,7 +1993,10 @@ export class FoundryStepExecutor {
1858
1993
  for (const app of liveApps) {
1859
1994
  if (!boundAppIds.has(app.id))
1860
1995
  continue;
1861
- await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
1996
+ // A company's own instance has the login host as its default; only the in-app apps move.
1997
+ if (own && !appBaseUris.has(app.id) && !ownInAppAppIds(recorded).has(app.id))
1998
+ continue;
1999
+ await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, baseUriOf(app.id));
1862
2000
  }
1863
2001
  }
1864
2002
  // Fix round 3 point 5: a bound app's current base URI is `host` for
@@ -1870,7 +2008,7 @@ export class FoundryStepExecutor {
1870
2008
  // compare against, same as an app this loop never visits at all.
1871
2009
  const currentLoginBaseUris = new Set();
1872
2010
  for (const app of liveApps) {
1873
- const uri = boundAppIds.has(app.id) ? `https://${host}/` : app.loginBaseUri;
2011
+ const uri = boundAppIds.has(app.id) ? baseUriOf(app.id) : app.loginBaseUri;
1874
2012
  if (typeof uri === 'string')
1875
2013
  currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
1876
2014
  }
@@ -1897,6 +2035,19 @@ export class FoundryStepExecutor {
1897
2035
  candidates.delete(host);
1898
2036
  if (previousHost && previousHost !== host)
1899
2037
  candidates.add(previousHost);
2038
+ // An in-app host applied before and no longer wanted (switch off, or the app is gone) is owed a removal
2039
+ // the same way an old login host is; a host still wanted is never a candidate.
2040
+ // An app that still holds `SIGNIN_UPSTREAM` keeps its binding and trusted domain: it is switched off
2041
+ // in reverse order, the base URI first, then the app's env, then these.
2042
+ for (const old of [
2043
+ ...inAppStateHosts(recorded[SIGN_IN_TRUSTED_SCOPE_KEY]),
2044
+ ...inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY]),
2045
+ ]) {
2046
+ if (!envLive.has(old))
2047
+ candidates.add(old);
2048
+ }
2049
+ for (const app of inApp)
2050
+ candidates.delete(app.host);
1900
2051
  // #274: a removal, once the new host is fully live (trusted, bound, and
1901
2052
  // every repointable app repointed) -- never the other way round, so an
1902
2053
  // org is never left with no trusted host at all if this throws partway
@@ -1905,9 +2056,11 @@ export class FoundryStepExecutor {
1905
2056
  // its connector app and its tunnel): a domain already gone must not skip
1906
2057
  // the binding delete that follows it.
1907
2058
  const stillPending = [];
2059
+ let removed = false;
1908
2060
  for (const candidate of candidates) {
1909
2061
  if (!canDecideSafely ||
1910
- currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))) {
2062
+ currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`)) ||
2063
+ currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}${SIGN_IN_BASE_PATH}/`))) {
1911
2064
  stillPending.push(candidate);
1912
2065
  continue;
1913
2066
  }
@@ -1918,6 +2071,7 @@ export class FoundryStepExecutor {
1918
2071
  if (!isNotFoundError(error))
1919
2072
  throw error;
1920
2073
  }
2074
+ removed = true;
1921
2075
  try {
1922
2076
  await bindings.deleteBinding(candidate);
1923
2077
  }
@@ -1926,6 +2080,9 @@ export class FoundryStepExecutor {
1926
2080
  throw error;
1927
2081
  }
1928
2082
  }
2083
+ // Nothing queued, nothing to write: a step that only added hosts has already flushed once.
2084
+ if (removed)
2085
+ await bindings.flush?.();
1929
2086
  // Both last, so a crash above reruns the whole step rather than losing
1930
2087
  // track of either. A client id recorded after this point differs from
1931
2088
  // the binding and replans the step next run; a nonempty pending list
@@ -1933,11 +2090,44 @@ export class FoundryStepExecutor {
1933
2090
  // normally alone decides it.
1934
2091
  if (shared)
1935
2092
  context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
1936
- else
1937
- context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
2093
+ else {
2094
+ if (!own)
2095
+ context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
2096
+ context.recordScopeId(SIGN_IN_TRUSTED_SCOPE_KEY, targets.trusted);
2097
+ context.recordScopeId(SIGN_IN_IN_APP_SCOPE_KEY, targets.active);
2098
+ }
1938
2099
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
1939
2100
  return { resourceId: host };
1940
2101
  }
2102
+ /**
2103
+ * The host table of a company's own in-app login app as a bindings port that collects its edits and
2104
+ * writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
2105
+ * its own services, which has no host-supplied bindings port.
2106
+ */
2107
+ ownSignInBindings(org, recorded, instanceHost) {
2108
+ const appId = recorded[SIGN_IN_LOGIN_APP_SCOPE_KEY];
2109
+ if (!appId) {
2110
+ throw new Error(`zitadel:login-host for ${org.slug}: the company's in-app login app is not recorded yet`);
2111
+ }
2112
+ const upsert = [];
2113
+ const remove = [];
2114
+ return {
2115
+ upsertBinding: async (binding) => {
2116
+ upsert.push(binding);
2117
+ },
2118
+ deleteBinding: async (host) => {
2119
+ remove.push(host);
2120
+ },
2121
+ flush: async () => {
2122
+ if (upsert.length === 0 && remove.length === 0)
2123
+ return;
2124
+ const edits = { appId, instanceHost, upsert: [...upsert], remove: [...remove] };
2125
+ upsert.length = 0;
2126
+ remove.length = 0;
2127
+ await editSignInBindings(this.ports.coolify, edits);
2128
+ },
2129
+ };
2130
+ }
1941
2131
  /**
1942
2132
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
1943
2133
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -2403,6 +2593,7 @@ export class FoundryStepExecutor {
2403
2593
  const server = await this.resolveServer(org, context);
2404
2594
  const githubApp = await this.ports.coolify.requireGithubApp();
2405
2595
  const created = await this.ports.coolify.createGithubAppApplication({
2596
+ is_preview_deployments_enabled: false,
2406
2597
  project_uuid: projectUuid,
2407
2598
  server_uuid: server.uuid,
2408
2599
  environment_name: environment.name,
@@ -2438,6 +2629,10 @@ export class FoundryStepExecutor {
2438
2629
  }
2439
2630
  if (want.autoDeploy !== have.autoDeploy)
2440
2631
  patch.is_auto_deploy_enabled = want.autoDeploy;
2632
+ // #788: an app found with previews on gets them off (`planApp` plans the
2633
+ // step for it). A created app has them off from the create.
2634
+ if (previewsOn(live))
2635
+ patch.is_preview_deployments_enabled = false;
2441
2636
  // #543: the health-check quartet too, every run -- the same reason
2442
2637
  // Archon's own doc comment gives (without one, Coolify's rolling update
2443
2638
  // has nothing to wait on and stops the old container immediately). The
@@ -3857,6 +4052,7 @@ export class FoundryStepExecutor {
3857
4052
  const server = await this.resolveServer(input.org, input.context);
3858
4053
  const githubApp = await this.ports.coolify.requireGithubApp();
3859
4054
  const created = await this.ports.coolify.createGithubAppApplication({
4055
+ is_preview_deployments_enabled: false,
3860
4056
  project_uuid: input.projectUuid,
3861
4057
  server_uuid: server.uuid,
3862
4058
  environment_name: input.environment.name,
@@ -4389,6 +4585,7 @@ export class FoundryStepExecutor {
4389
4585
  }
4390
4586
  const githubApp = await this.ports.coolify.requireGithubApp();
4391
4587
  const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
4588
+ is_preview_deployments_enabled: false,
4392
4589
  project_uuid: projectUuid,
4393
4590
  server_uuid: server.uuid,
4394
4591
  environment_name: environment.name,
@@ -4413,6 +4610,7 @@ export class FoundryStepExecutor {
4413
4610
  instant_deploy: false,
4414
4611
  }), { startIfStopped: false });
4415
4612
  const service = await this.findOrCreateCallsApp(names.service, environment.id, () => this.ports.coolify.createGithubAppApplication({
4613
+ is_preview_deployments_enabled: false,
4416
4614
  project_uuid: projectUuid,
4417
4615
  server_uuid: server.uuid,
4418
4616
  environment_name: environment.name,
@@ -4613,6 +4811,7 @@ export class FoundryStepExecutor {
4613
4811
  }
4614
4812
  const githubApp = await this.ports.coolify.requireGithubApp();
4615
4813
  const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
4814
+ is_preview_deployments_enabled: false,
4616
4815
  project_uuid: projectUuid,
4617
4816
  server_uuid: server.uuid,
4618
4817
  environment_name: environment.name,
@@ -4819,6 +5018,7 @@ export class FoundryStepExecutor {
4819
5018
  const server = await this.resolveServer(org, context);
4820
5019
  const githubApp = await this.ports.coolify.requireGithubApp();
4821
5020
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
5021
+ is_preview_deployments_enabled: false,
4822
5022
  project_uuid: projectUuid,
4823
5023
  server_uuid: server.uuid,
4824
5024
  environment_name: environment.name,
@@ -5199,6 +5399,7 @@ export class FoundryStepExecutor {
5199
5399
  const server = await this.resolveServer(org, context);
5200
5400
  const githubApp = await this.ports.coolify.requireGithubApp();
5201
5401
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
5402
+ is_preview_deployments_enabled: false,
5202
5403
  project_uuid: projectUuid,
5203
5404
  server_uuid: server.uuid,
5204
5405
  environment_name: environment.name,
@@ -5233,10 +5434,10 @@ export class FoundryStepExecutor {
5233
5434
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
5234
5435
  // Coolify's own env store matching `env`, which is why that store alone
5235
5436
  // can no longer tell a changed value from one already live.
5236
- const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
5237
- .filter((e) => !e.is_preview)
5238
- .map((e) => [e.key, e.value]));
5437
+ const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
5438
+ const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
5239
5439
  const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5440
+ await this.dropStaleSignInUpstream(app.appId, env, liveRows);
5240
5441
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
5241
5442
  const revision = agoraEnvRevision(env);
5242
5443
  const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
@@ -5262,9 +5463,43 @@ export class FoundryStepExecutor {
5262
5463
  else if (app.existed && (envChanged || !alreadyActivated)) {
5263
5464
  await this.ports.coolify.restart(app.appId);
5264
5465
  }
5466
+ // The login-host step points the sign-in at this app's own address only once the env is running. Before the
5467
+ // activation marker, so a crash between the two plans this step again and records it.
5468
+ this.recordSignInEnvLive(context, 'agora', agoraHostnameFor(org), env);
5265
5469
  context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
5266
5470
  return { resourceId: app.appId };
5267
5471
  }
5472
+ /**
5473
+ * Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
5474
+ * does not), so the login-host step knows when to point the base URI at the app and when it may give the
5475
+ * host up. Written only when the value changes.
5476
+ */
5477
+ recordSignInEnvLive(context, app, host, env) {
5478
+ const key = `${SIGN_IN_ENV_LIVE_SCOPE_PREFIX}${app}`;
5479
+ const value = env[SIGN_IN_UPSTREAM_ENV] !== undefined ? host : '';
5480
+ if ((context.recordedIds()[key] ?? '') !== value)
5481
+ context.recordScopeId(key, value);
5482
+ }
5483
+ /**
5484
+ * In-app sign-in switched off (or its host given up): the app's live `SIGNIN_UPSTREAM` is deleted, because
5485
+ * `bulkUpsertEnvs` never removes a key. Left in place, the app would keep proxying `/sign-in/*` to the login.
5486
+ * Nothing happens while the env is desired or the app does not hold it.
5487
+ */
5488
+ async dropStaleSignInUpstream(appId, desired, liveRows) {
5489
+ if (desired[SIGN_IN_UPSTREAM_ENV] !== undefined)
5490
+ return;
5491
+ for (const row of liveRows) {
5492
+ if (row.key !== SIGN_IN_UPSTREAM_ENV || !row.uuid)
5493
+ continue;
5494
+ try {
5495
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
5496
+ }
5497
+ catch (error) {
5498
+ if (!isNotFoundError(error))
5499
+ throw error;
5500
+ }
5501
+ }
5502
+ }
5268
5503
  // ---- boule (#161) --------------------------------------------------------
5269
5504
  /**
5270
5505
  * `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
@@ -5318,6 +5553,7 @@ export class FoundryStepExecutor {
5318
5553
  const server = await this.resolveServer(org, context);
5319
5554
  const githubApp = await this.ports.coolify.requireGithubApp();
5320
5555
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
5556
+ is_preview_deployments_enabled: false,
5321
5557
  project_uuid: projectUuid,
5322
5558
  server_uuid: server.uuid,
5323
5559
  environment_name: environment.name,
@@ -5339,10 +5575,10 @@ export class FoundryStepExecutor {
5339
5575
  ...healthCheckSettings('/api/health', BOULE_PORT),
5340
5576
  ...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
5341
5577
  });
5342
- const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
5343
- .filter((e) => !e.is_preview)
5344
- .map((e) => [e.key, e.value]));
5578
+ const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
5579
+ const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
5345
5580
  const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5581
+ await this.dropStaleSignInUpstream(app.appId, env, liveRows);
5346
5582
  const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
5347
5583
  const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
5348
5584
  if (cookieSecret)
@@ -5416,6 +5652,8 @@ export class FoundryStepExecutor {
5416
5652
  if (action.desired.platformOwner === true) {
5417
5653
  context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
5418
5654
  }
5655
+ // Last: the login-host step points the sign-in at this app's own address only once the env is running.
5656
+ this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
5419
5657
  return { resourceId: app.appId };
5420
5658
  }
5421
5659
  /**
@@ -5858,6 +6096,9 @@ export class FoundryStepExecutor {
5858
6096
  smtpSender: `noreply@${hn.mailDomain}`,
5859
6097
  defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
5860
6098
  ownerCompany: null,
6099
+ // In-app sign-in: the company's own stack gets the second login app. Its host table starts
6100
+ // empty; the login-host step writes it once the apps' clients exist.
6101
+ ...(inAppSignIn(org) ? { signInLogin: { bindings: null } } : {}),
5861
6102
  });
5862
6103
  if (refChanged) {
5863
6104
  const now = scope.recordedIds();
@@ -7953,3 +8194,23 @@ export function isNotFoundError(error) {
7953
8194
  const message = error instanceof Error ? error.message : String(error);
7954
8195
  return /^\S+ \S+ \S+ → 404:/.test(message);
7955
8196
  }
8197
+ /** ZITADEL's error code for an update that changes nothing (`COMMAND-1m88i`, "No changes"). */
8198
+ const ZITADEL_NO_CHANGES_CODE = 'COMMAND-1m88i';
8199
+ /** True when ZITADEL refused an update because the value already is the wanted one. Matches the error code, never the free text. */
8200
+ export function isNoChangesError(error) {
8201
+ const message = error instanceof Error ? error.message : String(error);
8202
+ return message.includes(ZITADEL_NO_CHANGES_CODE);
8203
+ }
8204
+ /**
8205
+ * {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
8206
+ * done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
8207
+ */
8208
+ export async function setOidcAppLoginBaseUri(zitadel, orgId, projectId, appId, loginBaseUri) {
8209
+ try {
8210
+ await zitadel.updateOidcAppLoginBaseUri(orgId, projectId, appId, loginBaseUri);
8211
+ }
8212
+ catch (error) {
8213
+ if (!isNoChangesError(error))
8214
+ throw error;
8215
+ }
8216
+ }
@@ -1,4 +1,5 @@
1
1
  import { type DeclaredOrg } from '../org.js';
2
+ import { type InAppApp, type SIGN_IN_BASE_PATH } from './sign-in.js';
2
3
  /**
3
4
  * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
5
  * need to agree on for an org's own company login host -- the hostname and
@@ -99,7 +100,17 @@ export interface LoginHostBinding {
99
100
  host: string;
100
101
  organizationId: string;
101
102
  clientIds: string[];
103
+ /** In-app sign-in: which app each client id is, so the login draws that app's room. Absent: the company's own room. */
104
+ apps?: Record<string, 'agora' | 'boule' | 'archon'>;
105
+ /** In-app sign-in: the login serves this host under `/sign-in`. Absent: the root. */
106
+ basePath?: typeof SIGN_IN_BASE_PATH;
102
107
  }
108
+ /**
109
+ * The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
110
+ * Boule's, each only once its client id is recorded. Empty with the switch off. Archon (owner company
111
+ * only) is not here: the platform's Archon step owns its host (`sign-in-archon.ts`).
112
+ */
113
+ export declare function inAppApps(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): InAppApp[];
103
114
  /**
104
115
  * Host-supplied write access to zitadel-login's own `LOGIN_HOST_BINDINGS`
105
116
  * env (`auth/docs/company-login-hosts.md`'s own "Set `LOGIN_HOST_BINDINGS`
@@ -121,6 +132,12 @@ export interface LoginHostBindingsPort {
121
132
  * package.
122
133
  */
123
134
  deleteBinding(host: string): Promise<void>;
135
+ /**
136
+ * A port that queues its upserts writes them here, once: one redeploy of the login however many hosts
137
+ * the step bound. A port without it writes on each call. `deleteBinding` writes at once on either.
138
+ * The login-host step calls it after all its upserts and before any app is repointed.
139
+ */
140
+ flush?(): Promise<void>;
124
141
  }
125
142
  /**
126
143
  * Scope id holding what the binding was last written with
@@ -137,8 +154,12 @@ export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
137
154
  * is no longer declared is not here, so it stays bound as before.
138
155
  */
139
156
  export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
140
- /** The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule), deduplicated and sorted. */
141
- export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string[];
157
+ /**
158
+ * The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule),
159
+ * deduplicated and sorted, bar `except`: the login refuses a client id held by two bindings, so the ids of the
160
+ * app hosts that have their own binding (in-app sign-in) are not in the company's.
161
+ */
162
+ export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, except?: readonly string[]): string[];
142
163
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
143
164
  export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
144
165
  /**