@wtfalch/foundry 0.16.15 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +12 -2
  6. package/dist/index.d.ts +9 -4
  7. package/dist/index.js +7 -0
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/org.d.ts +82 -5
  14. package/dist/org.js +62 -1
  15. package/dist/plan/adopt.d.ts +76 -0
  16. package/dist/plan/adopt.js +184 -0
  17. package/dist/plan/agora.d.ts +21 -0
  18. package/dist/plan/agora.js +37 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +13 -5
  21. package/dist/plan/chat-matrix.d.ts +1 -1
  22. package/dist/plan/chat-matrix.js +11 -3
  23. package/dist/plan/core-files.d.ts +25 -0
  24. package/dist/plan/core-files.js +25 -0
  25. package/dist/plan/deploy-health.d.ts +136 -0
  26. package/dist/plan/deploy-health.js +153 -0
  27. package/dist/plan/diff.d.ts +11 -3
  28. package/dist/plan/diff.js +77 -71
  29. package/dist/plan/entry-ai-key.d.ts +3 -3
  30. package/dist/plan/entry-files-key.d.ts +6 -4
  31. package/dist/plan/entry-files-key.js +7 -10
  32. package/dist/plan/executor.d.ts +109 -28
  33. package/dist/plan/executor.js +213 -56
  34. package/dist/plan/hostnames.d.ts +6 -0
  35. package/dist/plan/hostnames.js +13 -0
  36. package/dist/plan/index.d.ts +5 -1
  37. package/dist/plan/index.js +3 -1
  38. package/dist/plan/live.d.ts +5 -0
  39. package/dist/plan/login-host.d.ts +1 -1
  40. package/dist/plan/login-host.js +6 -5
  41. package/dist/plan/plan-apply.d.ts +153 -3
  42. package/dist/plan/plan-apply.js +584 -69
  43. package/dist/plan/platform-diff.d.ts +103 -9
  44. package/dist/plan/platform-diff.js +192 -14
  45. package/dist/plan/platform-org.d.ts +25 -0
  46. package/dist/plan/platform-org.js +17 -1
  47. package/dist/plan/platform-providers.d.ts +38 -5
  48. package/dist/plan/platform-providers.js +592 -109
  49. package/dist/plan/providers-readers.d.ts +2 -0
  50. package/dist/plan/providers-readers.js +4 -0
  51. package/dist/plan/rollout.d.ts +52 -0
  52. package/dist/plan/rollout.js +37 -0
  53. package/dist/plan/step-system.d.ts +22 -0
  54. package/dist/plan/step-system.js +37 -0
  55. package/dist/plan.d.ts +3 -0
  56. package/dist/state.d.ts +13 -0
  57. package/dist/store/audit.js +15 -1
  58. package/dist/store/postgres.d.ts +1 -1
  59. package/dist/store/postgres.js +127 -14
  60. package/dist/store/schema.d.ts +134 -5
  61. package/dist/store/schema.js +12 -1
  62. package/dist/store.d.ts +47 -4
  63. package/dist/store.js +9 -1
  64. package/package.json +2 -2
@@ -1,19 +1,21 @@
1
1
  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
- import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
5
- import { 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, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
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
6
  import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_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';
10
10
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
+ import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
11
12
  import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
12
13
  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';
13
14
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
14
15
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
15
16
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
16
17
  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';
18
+ import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
17
19
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
18
20
  /**
19
21
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
@@ -209,14 +211,16 @@ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
209
211
  const DEFAULT_APP_REPO = 'wtfalch/foundry';
210
212
  const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
211
213
  /**
212
- * The one repo `cmsSite` builds both of its apps from
213
- * (https://github.com/wtfalch/cms) -- the 2026-09-25 decision this campaign
214
- * implements (`.plans/2026-09-25-org-sites.md`, that repo): foundry never
215
- * calls a cms admin API for the org's site, it provisions two ordinary
216
- * Coolify applications from this one repo, told apart by
217
- * `dockerfile_location`. `CMS_APP_DOCKERFILE` is the repo's own root
218
- * `Dockerfile` (`apps/host`, single-org mode); `CMS_SITE_APP_DOCKERFILE`
219
- * is `apps/site`'s own Dockerfile (the reference renderer).
214
+ * The shared repo `cmsSite` builds its cms admin app from, always, and its
215
+ * site app from by default (https://github.com/wtfalch/cms) -- the
216
+ * 2026-09-25 decision this campaign implements
217
+ * (`.plans/2026-09-25-org-sites.md`, that repo): foundry never calls a cms
218
+ * admin API for the org's site, it provisions two ordinary Coolify
219
+ * applications, told apart by `dockerfile_location`. `CMS_APP_DOCKERFILE`
220
+ * is the repo's own root `Dockerfile` (`apps/host`, single-org mode);
221
+ * `CMS_SITE_APP_DOCKERFILE` is `apps/site`'s own Dockerfile (the reference
222
+ * renderer). An org with `site.repo` set (#245) builds its site app from
223
+ * that repo instead, the sample skin otherwise ships to every org.
220
224
  */
221
225
  const CMS_REPO = 'wtfalch/cms';
222
226
  const CMS_APP_DOCKERFILE = '/Dockerfile';
@@ -276,6 +280,8 @@ const LOG_DIR = '/app/logs';
276
280
  */
277
281
  const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
278
282
  const SITE_APP_SCOPE_KEY = 'coolify_site_app';
283
+ /** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
284
+ const COOLIFY_SERVER_SCOPE_KEY = 'coolify_server';
279
285
  /**
280
286
  * #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
281
287
  * created inside `cmsSite` the same way `coolifyDatabase` does for the org's
@@ -658,8 +664,6 @@ export class FoundryStepExecutor {
658
664
  return action.key.includes(':')
659
665
  ? this.filesEntryKey(action, org, context)
660
666
  : this.filesRootKey(action, org, context);
661
- case 'files:storage':
662
- return this.filesStorage(action, org);
663
667
  case 'ai:organisation':
664
668
  return this.aiOrganisation(action, org);
665
669
  case 'ai:budget':
@@ -861,11 +865,16 @@ export class FoundryStepExecutor {
861
865
  if (!zitadelOrgId) {
862
866
  throw new Error(`zitadel:owner for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
863
867
  }
868
+ // ZITADEL's default invite link (`/ui/v2/login/verify`) 404s; the platform's login app, which
869
+ // every org's users sign in through on this instance, serves `/verify` at the root.
870
+ const authIssuer = action.desired.authIssuer;
864
871
  const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
865
872
  givenName: action.desired.givenName,
866
873
  familyName: action.desired.familyName,
867
874
  email: action.desired.email,
868
- });
875
+ }, authIssuer
876
+ ? `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`
877
+ : undefined);
869
878
  // An owner invited after chat was built never reaches `coolifyChat`
870
879
  // again, so the role is granted here once the messaging project exists.
871
880
  const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
@@ -977,6 +986,7 @@ export class FoundryStepExecutor {
977
986
  .map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
978
987
  ...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
979
988
  ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
989
+ ...(recorded[BOULE_CLIENT_ID_SCOPE_KEY] ? ['boule'] : []),
980
990
  ].map((name) => recorded[`zitadel:app:${name}`]));
981
991
  const projectId = recorded.zitadel_project;
982
992
  // No project yet means no apps yet: vacuously safe to decide from (fix
@@ -1250,11 +1260,37 @@ export class FoundryStepExecutor {
1250
1260
  context.recordScopeId('coolify_project', projectUuid);
1251
1261
  return projectUuid;
1252
1262
  }
1253
- async resolveServer() {
1263
+ /**
1264
+ * #445: one company lands on one Coolify server. The first call for an
1265
+ * org picks a usable, reachable server and records it
1266
+ * ({@link COOLIFY_SERVER_SCOPE_KEY}); every later call -- this apply, or a
1267
+ * later one -- reads that recording back and returns the same server,
1268
+ * regardless of what the pool's order or health looks like by then. A
1269
+ * recorded server that has gone unusable or unreachable is reported
1270
+ * blocked (never silently replaced with a second host, which would split
1271
+ * the org's resources across servers and break their internal container
1272
+ * URLs); a recorded server no longer registered at all is the same.
1273
+ * Deliberately no fallback to `servers[0]` when nothing is usable: an
1274
+ * unusable server picked blind is not a server anyone's resources could
1275
+ * actually be created on.
1276
+ */
1277
+ async resolveServer(org, context) {
1254
1278
  const servers = await this.ports.coolify.listServers();
1255
- const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
1279
+ const recorded = context.recordedIds()[COOLIFY_SERVER_SCOPE_KEY];
1280
+ if (recorded) {
1281
+ const server = servers.find((s) => s.uuid === recorded);
1282
+ if (!server) {
1283
+ throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is no longer registered on this instance`);
1284
+ }
1285
+ if (!(server.is_usable && server.is_reachable)) {
1286
+ throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is not usable/reachable right now; it is not moved to a second server`);
1287
+ }
1288
+ return server;
1289
+ }
1290
+ const server = servers.find((s) => s.is_usable && s.is_reachable);
1256
1291
  if (!server)
1257
1292
  throw new Error('no usable Coolify server is registered on this instance');
1293
+ context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
1258
1294
  return server;
1259
1295
  }
1260
1296
  async coolifyApp(action, org, context) {
@@ -1320,7 +1356,7 @@ export class FoundryStepExecutor {
1320
1356
  haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
1321
1357
  }
1322
1358
  else {
1323
- const server = await this.resolveServer();
1359
+ const server = await this.resolveServer(org, context);
1324
1360
  const githubApp = await this.ports.coolify.requireGithubApp();
1325
1361
  const created = await this.ports.coolify.createGithubAppApplication({
1326
1362
  project_uuid: projectUuid,
@@ -1424,7 +1460,13 @@ export class FoundryStepExecutor {
1424
1460
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
1425
1461
  // unlike the database push above: every app has a hostname, and an
1426
1462
  // upsert of the same value on a later apply is a no-op, not a change.
1427
- const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null);
1463
+ const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null,
1464
+ // What the plan decided is available (diff.ts `planApps`), so the marker
1465
+ // recorded below is the one the plan compares against. Absent: none.
1466
+ action.desired.secretSources ?? {
1467
+ platformMailKey: false,
1468
+ sharedSecret: false,
1469
+ });
1428
1470
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
1429
1471
  // that was already there is restarted, once, when its database URLs or its
1430
1472
  // declared appEnv changed.
@@ -1517,7 +1559,7 @@ export class FoundryStepExecutor {
1517
1559
  dbId = found.uuid;
1518
1560
  }
1519
1561
  else {
1520
- const server = await this.resolveServer();
1562
+ const server = await this.resolveServer(org, context);
1521
1563
  const dbName = org.slug.replace(/-/g, '_');
1522
1564
  const created = await this.ports.coolify.createPostgresDatabase({
1523
1565
  project_uuid: projectUuid,
@@ -1633,7 +1675,7 @@ export class FoundryStepExecutor {
1633
1675
  * that call runs only once the `app` entry's `database` is declared and
1634
1676
  * provisioned, where this one is unconditional (#239).
1635
1677
  */
1636
- async pushAppEnv(appId, org, entry, context, authIssuer) {
1678
+ async pushAppEnv(appId, org, entry, context, authIssuer, secretSources) {
1637
1679
  const recorded = context.recordedIds();
1638
1680
  const envs = [
1639
1681
  // Only the `app` entry has a public name; the others are internal.
@@ -1691,14 +1733,44 @@ export class FoundryStepExecutor {
1691
1733
  // minted here; a source with no value yet (its own step has not run,
1692
1734
  // or has not run for this apply) is simply omitted, the same way every
1693
1735
  // other optional entry above is.
1694
- const appEnv = resolveAppEnv(org, entry, recorded).values;
1736
+ // The secret sources (`sharedSecret`, `platformMailKey`) are filled here
1737
+ // only, straight into `envs`: never recorded, returned or logged.
1738
+ const heldKeys = this.ports.heldKeys;
1739
+ // Availability is the plan's (`secretSources`). If the plan said a key is
1740
+ // held and this port cannot read it, the host wired the two differently:
1741
+ // fail by ref, never push a marker for an env that was not written.
1742
+ let mailKey = null;
1743
+ if (secretSources.platformMailKey) {
1744
+ try {
1745
+ mailKey = (await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) || null;
1746
+ }
1747
+ catch {
1748
+ // reported below, by ref
1749
+ }
1750
+ if (mailKey === null) {
1751
+ throw new Error(`coolify:app for ${org.slug}: the plan found ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF} held but the executor's heldKeys cannot read it`);
1752
+ }
1753
+ }
1754
+ if (secretSources.sharedSecret && typeof heldKeys?.put !== 'function') {
1755
+ throw new Error(`coolify:app for ${org.slug}: the plan found held keys that can put but the executor's heldKeys cannot`);
1756
+ }
1757
+ const resolved = resolveAppEnv(org, entry, recorded, secretSources);
1758
+ const appEnv = resolved.values;
1695
1759
  for (const [key, value] of Object.entries(appEnv))
1696
1760
  envs.push({ key, value });
1761
+ for (const { name, source } of resolved.secrets) {
1762
+ envs.push({
1763
+ key: name,
1764
+ value: source === 'platformMailKey'
1765
+ ? mailKey
1766
+ : await this.ensureSharedSecret(org, source.name, recorded),
1767
+ });
1768
+ }
1697
1769
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
1698
1770
  // `coolifyApp` records `applied` once the restart succeeded, so `planApp`
1699
1771
  // replans this step when the declared map changes (rule 3). Ids and a
1700
1772
  // domain, never a secret.
1701
- const applied = JSON.stringify(appEnv);
1773
+ const applied = JSON.stringify(resolved.marker);
1702
1774
  // The sign-in values, recorded by `coolifyApp` the same way; the key is
1703
1775
  // never part of them.
1704
1776
  const authApplied = JSON.stringify(authEnv);
@@ -1710,6 +1782,28 @@ export class FoundryStepExecutor {
1710
1782
  authApplied,
1711
1783
  };
1712
1784
  }
1785
+ /**
1786
+ * The org's shared secret `name`: read from the held keys, else generated
1787
+ * and held first, so a crash after the `put` and before the env write
1788
+ * hands the same value out on the next apply. Only called when `put` exists.
1789
+ * An error here names the ref, never a value.
1790
+ */
1791
+ async ensureSharedSecret(org, name, recorded) {
1792
+ const heldKeys = this.ports.heldKeys;
1793
+ const ref = sharedSecretRef(org.id, name);
1794
+ const held = await heldKeys?.resolve(ref);
1795
+ if (held)
1796
+ return held;
1797
+ // Another entry (or this one) already got a value. Generating again would
1798
+ // split them, so a lost value needs a person, not a new mint.
1799
+ const label = `"secret:sharedSecret:${name}"`;
1800
+ if (Object.entries(recorded).some(([key, value]) => key.startsWith('app_env_applied.') && value.includes(label))) {
1801
+ throw new Error(`coolify:app for ${org.slug}: shared secret ${name} was delivered but is no longer held (ref ${ref}); restore the held value, or clear it from every entry's env and applied marker to mint a new one`);
1802
+ }
1803
+ const value = randomBytes(32).toString('base64url');
1804
+ await heldKeys?.put?.(ref, value);
1805
+ return value;
1806
+ }
1713
1807
  /**
1714
1808
  * F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
1715
1809
  * of at least 32 characters (the broker refuses a shorter one). `null` once
@@ -2097,16 +2191,22 @@ export class FoundryStepExecutor {
2097
2191
  const projectUuid = await this.ensureCoolifyProject(org, context);
2098
2192
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2099
2193
  const site = await this.findOrCreateCmsApp({
2194
+ org,
2195
+ context,
2100
2196
  projectUuid,
2101
2197
  environment,
2102
2198
  name: `${org.slug}-site`,
2199
+ repo: org.site.repo ?? CMS_REPO,
2103
2200
  dockerfileLocation: CMS_SITE_APP_DOCKERFILE,
2104
2201
  domain,
2105
2202
  });
2106
2203
  const cms = await this.findOrCreateCmsApp({
2204
+ org,
2205
+ context,
2107
2206
  projectUuid,
2108
2207
  environment,
2109
2208
  name: `${org.slug}-cms`,
2209
+ repo: CMS_REPO,
2110
2210
  dockerfileLocation: CMS_APP_DOCKERFILE,
2111
2211
  domain: cmsHostnameFor(org),
2112
2212
  });
@@ -2217,7 +2317,7 @@ export class FoundryStepExecutor {
2217
2317
  const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
2218
2318
  if (found)
2219
2319
  return found.uuid;
2220
- const server = await this.resolveServer();
2320
+ const server = await this.resolveServer(org, context);
2221
2321
  const dbName = name.replace(/-/g, '_');
2222
2322
  const created = await this.ports.coolify.createPostgresDatabase({
2223
2323
  project_uuid: projectUuid,
@@ -2304,6 +2404,10 @@ export class FoundryStepExecutor {
2304
2404
  * `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
2305
2405
  * app either) has since closed the same way, inline in that method (#130);
2306
2406
  * this method is `cmsSite`'s only caller.
2407
+ *
2408
+ * `repo` (#245) lets the site app build from an org's own skin repo
2409
+ * (`org.site.repo`) instead of the shared `CMS_REPO`; `cmsSite` always
2410
+ * passes `CMS_REPO` for the cms admin app, which is never org-specific.
2307
2411
  */
2308
2412
  async findOrCreateCmsApp(input) {
2309
2413
  const existing = await this.ports.coolify.findApplicationByName(input.name, {
@@ -2316,7 +2420,7 @@ export class FoundryStepExecutor {
2316
2420
  }
2317
2421
  return { appId: existing.uuid, existed: true };
2318
2422
  }
2319
- const server = await this.resolveServer();
2423
+ const server = await this.resolveServer(input.org, input.context);
2320
2424
  const githubApp = await this.ports.coolify.requireGithubApp();
2321
2425
  const created = await this.ports.coolify.createGithubAppApplication({
2322
2426
  project_uuid: input.projectUuid,
@@ -2324,7 +2428,7 @@ export class FoundryStepExecutor {
2324
2428
  environment_name: input.environment.name,
2325
2429
  environment_uuid: input.environment.uuid,
2326
2430
  github_app_uuid: githubApp.uuid,
2327
- git_repository: CMS_REPO,
2431
+ git_repository: input.repo,
2328
2432
  git_branch: 'main',
2329
2433
  build_pack: 'dockerfile',
2330
2434
  name: input.name,
@@ -2362,16 +2466,16 @@ export class FoundryStepExecutor {
2362
2466
  }
2363
2467
  const projectUuid = await this.ensureCoolifyProject(org, context);
2364
2468
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2365
- const server = await this.resolveServer();
2469
+ const server = await this.resolveServer(org, context);
2366
2470
  const names = {
2367
2471
  synapse: `${org.slug}-calls-synapse`,
2368
2472
  mas: `${org.slug}-calls-mas`,
2369
2473
  synapseDb: `${org.slug}-calls-synapse-db`,
2370
2474
  masDb: `${org.slug}-calls-mas-db`,
2371
2475
  };
2372
- const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2476
+ const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2373
2477
  context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
2374
- const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
2478
+ const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
2375
2479
  context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
2376
2480
  const [synapseDbRow, masDbRow] = await Promise.all([
2377
2481
  getDatabaseStarted(this.ports.coolify, synapseDbId),
@@ -2525,14 +2629,14 @@ export class FoundryStepExecutor {
2525
2629
  * database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
2526
2630
  * `system:kind:key` slot of its own for the same reason.
2527
2631
  */
2528
- async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
2632
+ async ensureCallsDatabase(org, context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
2529
2633
  const existing = context.recordedIds()[scopeKey];
2530
2634
  if (existing)
2531
2635
  return existing;
2532
2636
  const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
2533
2637
  if (found)
2534
2638
  return found.uuid;
2535
- const server = await this.resolveServer();
2639
+ const server = await this.resolveServer(org, context);
2536
2640
  const created = await this.ports.coolify.createPostgresDatabase({
2537
2641
  project_uuid: projectUuid,
2538
2642
  server_uuid: server.uuid,
@@ -2658,13 +2762,13 @@ export class FoundryStepExecutor {
2658
2762
  await this.ensureChatAdmins(org, zitadelOrgId, project.id, recorded);
2659
2763
  const projectUuid = await this.ensureCoolifyProject(org, context);
2660
2764
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2661
- const server = await this.resolveServer();
2765
+ const server = await this.resolveServer(org, context);
2662
2766
  const names = chatInternalNames(org.slug);
2663
- const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2767
+ const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2664
2768
  context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
2665
- const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
2769
+ const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
2666
2770
  context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
2667
- const serviceDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
2771
+ const serviceDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
2668
2772
  context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
2669
2773
  const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
2670
2774
  getDatabaseStarted(this.ports.coolify, synapseDbId),
@@ -2840,13 +2944,13 @@ export class FoundryStepExecutor {
2840
2944
  const synapseHost = `${org.slug}-calls-synapse`;
2841
2945
  const masHost = `${org.slug}-calls-mas`;
2842
2946
  const dbName = `${name}-db`;
2843
- const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
2947
+ const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
2844
2948
  context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
2845
2949
  const db = await getDatabaseStarted(this.ports.coolify, dbId);
2846
2950
  if (!db.internal_db_url) {
2847
2951
  throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
2848
2952
  }
2849
- const server = await this.resolveServer();
2953
+ const server = await this.resolveServer(org, context);
2850
2954
  const githubApp = await this.ports.coolify.requireGithubApp();
2851
2955
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
2852
2956
  project_uuid: projectUuid,
@@ -3059,6 +3163,19 @@ export class FoundryStepExecutor {
3059
3163
  * pushed once calls are on and this org's calls service app carries its
3060
3164
  * internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
3061
3165
  * not the host values).
3166
+ *
3167
+ * #442: a found-but-already-running app's container binds env only at
3168
+ * start, so writing it alone leaves that process on its old config --
3169
+ * restarted (never redeployed: no new image, so `restart` is the same
3170
+ * primitive `ensureLogVolume`'s own restart uses) whenever the live env
3171
+ * actually differs, or whenever the last restart this step confirmed
3172
+ * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
3173
+ * applied before this existed, whose live env already matched desired
3174
+ * with nothing ever confirmed running it). A brand new app has nothing
3175
+ * running yet to restart. The activation marker is recorded last, after
3176
+ * the restart (if any) returns without throwing, so a failed restart
3177
+ * leaves it stale and the next plan replans this step even though
3178
+ * Coolify's own env store already matches desired.
3062
3179
  */
3063
3180
  async coolifyAgora(action, org, context) {
3064
3181
  // The host values the plan read, carried in the step: never read again
@@ -3074,7 +3191,7 @@ export class FoundryStepExecutor {
3074
3191
  const projectUuid = await this.ensureCoolifyProject(org, context);
3075
3192
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3076
3193
  const name = `${org.slug}-agora`;
3077
- const server = await this.resolveServer();
3194
+ const server = await this.resolveServer(org, context);
3078
3195
  const githubApp = await this.ports.coolify.requireGithubApp();
3079
3196
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
3080
3197
  project_uuid: projectUuid,
@@ -3091,7 +3208,20 @@ export class FoundryStepExecutor {
3091
3208
  }));
3092
3209
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3093
3210
  await this.ensureLogVolume(app.appId, org, app.existed);
3211
+ // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
3212
+ // Coolify's own env store matching `env`, which is why that store alone
3213
+ // can no longer tell a changed value from one already live.
3214
+ const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
3215
+ .filter((e) => !e.is_preview)
3216
+ .map((e) => [e.key, e.value]));
3217
+ const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
3094
3218
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
3219
+ const revision = agoraEnvRevision(env);
3220
+ const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
3221
+ if (app.existed && (envChanged || !alreadyActivated)) {
3222
+ await this.ports.coolify.restart(app.appId);
3223
+ }
3224
+ context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
3095
3225
  return { resourceId: app.appId };
3096
3226
  }
3097
3227
  // ---- boule (#161) --------------------------------------------------------
@@ -3131,9 +3261,9 @@ export class FoundryStepExecutor {
3131
3261
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3132
3262
  const name = `${org.slug}-boule`;
3133
3263
  const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
3134
- const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
3264
+ const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
3135
3265
  context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
3136
- const server = await this.resolveServer();
3266
+ const server = await this.resolveServer(org, context);
3137
3267
  const githubApp = await this.ports.coolify.requireGithubApp();
3138
3268
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
3139
3269
  project_uuid: projectUuid,
@@ -3345,7 +3475,7 @@ export class FoundryStepExecutor {
3345
3475
  appName: `${org.slug}-cloudflared`,
3346
3476
  projectUuid,
3347
3477
  environment,
3348
- resolveServer: () => this.resolveServer(),
3478
+ resolveServer: () => this.resolveServer(org, context),
3349
3479
  recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
3350
3480
  recordAppId: async (appId) => {
3351
3481
  context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
@@ -3965,30 +4095,45 @@ export class FoundryStepExecutor {
3965
4095
  }
3966
4096
  // ---- files / ai --------------------------------------------------------
3967
4097
  /**
3968
- * #80: provisions the org's own organisation on Files, before
3969
- * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
3970
- * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
3971
- * this is called every time regardless of whether it already ran --
3972
- * idempotent by find-or-create, not by checking `recordedIds` first.
4098
+ * #80: provisions the org's own organisation on Files.
4099
+ * `provisionOrganisation` is safely repeatable on its own
4100
+ * (`FilesOrganisationPort`'s doc comment), so this is called every time
4101
+ * regardless of whether it already ran -- idempotent by find-or-create,
4102
+ * not by checking `recordedIds` first.
4103
+ *
4104
+ * F6b: `action.desired.shareHost` (`diff.ts`'s `planFilesOrganisation`) is
4105
+ * passed through every time, even though `entry-files-key.ts` reports the
4106
+ * route itself as blocked until the platform's files host can serve
4107
+ * `share.<domain>` -- this is the one place the name reaches files at all,
4108
+ * so files can act on it the moment its own route exists.
3973
4109
  *
3974
4110
  * #273: also ensures the org's Agora origin is on the files host's CORS
3975
4111
  * allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
3976
4112
  * repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
3977
4113
  * gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
3978
4114
  * `desired.agoraOrigin`.
4115
+ *
4116
+ * #437: the owner member/name, the core ZITADEL files project and the core
4117
+ * collection (`core-files.ts`) are no longer conditioned on a hosting entry
4118
+ * declaring `uses.files` -- the core bundle (Agora's own uploads) needs
4119
+ * all three just as much as a bespoke entry does, and withholding them
4120
+ * until one existed is why a plain Agora/Boule org never got a usable
4121
+ * Files setup at all. Both the project and the collection are idempotent
4122
+ * by name, so a re-run that already created either finds it instead.
3979
4123
  */
3980
4124
  async filesOrganisation(action, org, context) {
3981
4125
  const files = await this.filesPort();
3982
- // F6b: an org whose entries use files needs an owner member in its files
3983
- // organisation, which its own key is minted below. Only a new
3984
- // organisation takes one, so it is passed when the owner is known now.
3985
- const owner = org.hosting.entries.some((e) => e.uses?.files)
3986
- ? filesOwnerUserId(org, context.recordedIds())
3987
- : undefined;
4126
+ const recorded = context.recordedIds();
4127
+ // F6b: an organisation takes an owner member and name only while it is
4128
+ // being created, so it is passed whenever the owner is known now --
4129
+ // every org, not only one with a hosting entry declaring `uses.files`
4130
+ // (#437).
4131
+ const owner = filesOwnerUserId(org, recorded);
3988
4132
  const provisioned = await files.provisionOrganisation({
3989
4133
  organisationId: org.id,
3990
4134
  ceilingBytes: action.desired.ceilingBytes ?? null,
3991
4135
  ceilingMicros: action.desired.ceilingMicros ?? null,
4136
+ shareHost: action.desired.shareHost ?? null,
3992
4137
  ...(owner ? { name: org.displayName, owner } : {}),
3993
4138
  });
3994
4139
  const agoraOrigin = action.desired.agoraOrigin;
@@ -4000,6 +4145,22 @@ export class FoundryStepExecutor {
4000
4145
  if (provisioned.agoraKey) {
4001
4146
  await this.deliverFilesAgoraKey(files, org, provisioned.agoraKey, context);
4002
4147
  }
4148
+ // #437: the audience Agora requests to read/browse Files
4149
+ // (`agoras.ts`'s `filesProjectId`), the same `urn:zitadel:iam:org:
4150
+ // project:id:<id>:aud` shape `messagingProjectId` already uses. Retried
4151
+ // on a later apply, same as the owner above, while the org's ZITADEL
4152
+ // org id is not recorded yet (`zitadel:issuer` has not run).
4153
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
4154
+ if (zitadelOrgId) {
4155
+ const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, `${org.slug}-files`);
4156
+ context.recordScopeId(ZITADEL_FILES_PROJECT_SCOPE_KEY, project.id);
4157
+ }
4158
+ // #437: the one collection every file Agora itself uploads goes into
4159
+ // (`agoras.ts`'s `filesCollectionId`) -- Files has no route to list an
4160
+ // organisation's collections and an ordinary member cannot create one,
4161
+ // so it has to exist before Agora ever tries.
4162
+ const collection = await files.ensureCollection(org.id, CORE_FILES_COLLECTION_NAME);
4163
+ context.recordScopeId(FILES_CORE_COLLECTION_SCOPE_KEY, collection.id);
4003
4164
  return { resourceId: provisioned.organisationId };
4004
4165
  }
4005
4166
  /**
@@ -4036,12 +4197,6 @@ export class FoundryStepExecutor {
4036
4197
  context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, '');
4037
4198
  throw new Error(`${at}: Agora's key ${key.id} could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause}); ${revoked ? 'it was revoked again' : 'revoke it in files'}`);
4038
4199
  }
4039
- async filesStorage(action, org) {
4040
- const files = await this.filesPort();
4041
- await files.setQuota(org.id, action.desired.limitBytes ?? null);
4042
- await files.setBudget(org.id, action.desired.limitMicros ?? null);
4043
- return { resourceId: org.id };
4044
- }
4045
4200
  /**
4046
4201
  * #80: provisions the org's own organisation on AI and switches its AI use
4047
4202
  * on, before {@link aiBudget} can set its own spend limit. Both
@@ -4429,6 +4584,7 @@ export class FoundryStepExecutor {
4429
4584
  const envs = [
4430
4585
  { key: 'FILES_URL', value: d.filesUrl },
4431
4586
  { key: 'FILES_ORGANISATION_ID', value: org.id },
4587
+ ...(d.shareOrigin ? [{ key: 'FILES_SHARE_ORIGIN', value: d.shareOrigin }] : []),
4432
4588
  ];
4433
4589
  for (const name of d.collections) {
4434
4590
  let collection;
@@ -4495,6 +4651,7 @@ export class FoundryStepExecutor {
4495
4651
  permissions: d.permissions,
4496
4652
  collections: d.collections,
4497
4653
  filesUrl: d.filesUrl,
4654
+ ...(d.shareOrigin ? { shareOrigin: d.shareOrigin } : {}),
4498
4655
  }));
4499
4656
  if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
4500
4657
  await this.revokeFilesEntryKey(files, org, entryKey, previousKeyId, context);
@@ -26,6 +26,12 @@ export declare function appHostname(org: DeclaredOrg): string;
26
26
  export declare function coolifyDomains(names: readonly string[]): string;
27
27
  /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
28
28
  export declare function legacyAppHostname(org: DeclaredOrg): string;
29
+ /**
30
+ * `share.<domain>` when hosting is on and an entry declares
31
+ * `uses.files.shareHost`, else `null`. The files service answers public
32
+ * shares there; foundry never puts it behind Access.
33
+ */
34
+ export declare function filesShareHostname(org: DeclaredOrg): string | null;
29
35
  /**
30
36
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
31
37
  * #164): a site's two names, every entry's names, an own-domain calls name,
@@ -47,6 +47,16 @@ export function coolifyDomains(names) {
47
47
  export function legacyAppHostname(org) {
48
48
  return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
49
49
  }
50
+ /**
51
+ * `share.<domain>` when hosting is on and an entry declares
52
+ * `uses.files.shareHost`, else `null`. The files service answers public
53
+ * shares there; foundry never puts it behind Access.
54
+ */
55
+ export function filesShareHostname(org) {
56
+ if (!org.hosting.enabled || !org.domain)
57
+ return null;
58
+ return org.hosting.entries.some((e) => e.uses?.files?.shareHost) ? `share.${org.domain}` : null;
59
+ }
50
60
  /**
51
61
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
52
62
  * #164): a site's two names, every entry's names, an own-domain calls name,
@@ -71,6 +81,9 @@ export function tunnelIngressHostnames(org) {
71
81
  // #161: and Boule moves onto `boule.<domain>` the same way.
72
82
  hostnames.push(bouleHostnameFor(org));
73
83
  }
84
+ const share = filesShareHostname(org);
85
+ if (share)
86
+ hostnames.push(share);
74
87
  // The wildcard goes last: Cloudflare takes the first ingress rule that
75
88
  // matches, so every exact name above must come before `*.<domain>`.
76
89
  const wildcard = wildcardHostname(org);
@@ -2,15 +2,19 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
2
2
  export { readLiveResources, readLivePlatformResources } from './live.js';
3
3
  export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
4
4
  export { computePlanDetail, toPlan } from './diff.js';
5
+ export type { StepSystem } from './step-system.js';
6
+ export { stepSystemOf } from './step-system.js';
5
7
  export type { PlatformPlanDetail } from './platform-diff.js';
6
8
  export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
7
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
8
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
9
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
10
12
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
13
+ export type { AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot } from './adopt.js';
14
+ export { AdoptRefusedError } from './adopt.js';
11
15
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
12
16
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
13
- export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
17
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
14
18
  export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
15
19
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
16
20
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
@@ -1,10 +1,12 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
+ export { stepSystemOf } from './step-system.js';
3
4
  export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
4
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
5
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
+ export { AdoptRefusedError } from './adopt.js';
6
8
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
7
- export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
9
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
8
10
  // #171: how a host signs files management requests with the delivered key.
9
11
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
10
12
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';