@wtfalch/foundry 0.16.16 → 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 (59) 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 +54 -0
  14. package/dist/org.js +43 -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/diff.d.ts +11 -3
  26. package/dist/plan/diff.js +27 -62
  27. package/dist/plan/entry-ai-key.d.ts +3 -3
  28. package/dist/plan/entry-files-key.d.ts +3 -3
  29. package/dist/plan/executor.d.ts +57 -23
  30. package/dist/plan/executor.js +134 -50
  31. package/dist/plan/index.d.ts +4 -0
  32. package/dist/plan/index.js +2 -0
  33. package/dist/plan/live.d.ts +5 -0
  34. package/dist/plan/login-host.d.ts +1 -1
  35. package/dist/plan/login-host.js +6 -5
  36. package/dist/plan/plan-apply.d.ts +139 -6
  37. package/dist/plan/plan-apply.js +546 -76
  38. package/dist/plan/platform-diff.d.ts +14 -0
  39. package/dist/plan/platform-diff.js +16 -1
  40. package/dist/plan/platform-org.d.ts +16 -0
  41. package/dist/plan/platform-org.js +17 -1
  42. package/dist/plan/platform-providers.d.ts +1 -1
  43. package/dist/plan/platform-providers.js +73 -11
  44. package/dist/plan/providers-readers.d.ts +2 -0
  45. package/dist/plan/providers-readers.js +4 -0
  46. package/dist/plan/rollout.d.ts +52 -0
  47. package/dist/plan/rollout.js +37 -0
  48. package/dist/plan/step-system.d.ts +22 -0
  49. package/dist/plan/step-system.js +37 -0
  50. package/dist/plan.d.ts +3 -0
  51. package/dist/state.d.ts +13 -0
  52. package/dist/store/audit.js +15 -1
  53. package/dist/store/postgres.d.ts +1 -1
  54. package/dist/store/postgres.js +127 -14
  55. package/dist/store/schema.d.ts +134 -5
  56. package/dist/store/schema.js +12 -1
  57. package/dist/store.d.ts +47 -4
  58. package/dist/store.js +9 -1
  59. package/package.json +1 -1
@@ -2,12 +2,13 @@ import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
- import { AGORA_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';
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';
@@ -210,14 +211,16 @@ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
210
211
  const DEFAULT_APP_REPO = 'wtfalch/foundry';
211
212
  const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
212
213
  /**
213
- * The one repo `cmsSite` builds both of its apps from
214
- * (https://github.com/wtfalch/cms) -- the 2026-09-25 decision this campaign
215
- * implements (`.plans/2026-09-25-org-sites.md`, that repo): foundry never
216
- * calls a cms admin API for the org's site, it provisions two ordinary
217
- * Coolify applications from this one repo, told apart by
218
- * `dockerfile_location`. `CMS_APP_DOCKERFILE` is the repo's own root
219
- * `Dockerfile` (`apps/host`, single-org mode); `CMS_SITE_APP_DOCKERFILE`
220
- * 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.
221
224
  */
222
225
  const CMS_REPO = 'wtfalch/cms';
223
226
  const CMS_APP_DOCKERFILE = '/Dockerfile';
@@ -277,6 +280,8 @@ const LOG_DIR = '/app/logs';
277
280
  */
278
281
  const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
279
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';
280
285
  /**
281
286
  * #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
282
287
  * created inside `cmsSite` the same way `coolifyDatabase` does for the org's
@@ -659,8 +664,6 @@ export class FoundryStepExecutor {
659
664
  return action.key.includes(':')
660
665
  ? this.filesEntryKey(action, org, context)
661
666
  : this.filesRootKey(action, org, context);
662
- case 'files:storage':
663
- return this.filesStorage(action, org);
664
667
  case 'ai:organisation':
665
668
  return this.aiOrganisation(action, org);
666
669
  case 'ai:budget':
@@ -983,6 +986,7 @@ export class FoundryStepExecutor {
983
986
  .map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
984
987
  ...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
985
988
  ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
989
+ ...(recorded[BOULE_CLIENT_ID_SCOPE_KEY] ? ['boule'] : []),
986
990
  ].map((name) => recorded[`zitadel:app:${name}`]));
987
991
  const projectId = recorded.zitadel_project;
988
992
  // No project yet means no apps yet: vacuously safe to decide from (fix
@@ -1256,11 +1260,37 @@ export class FoundryStepExecutor {
1256
1260
  context.recordScopeId('coolify_project', projectUuid);
1257
1261
  return projectUuid;
1258
1262
  }
1259
- 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) {
1260
1278
  const servers = await this.ports.coolify.listServers();
1261
- 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);
1262
1291
  if (!server)
1263
1292
  throw new Error('no usable Coolify server is registered on this instance');
1293
+ context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
1264
1294
  return server;
1265
1295
  }
1266
1296
  async coolifyApp(action, org, context) {
@@ -1326,7 +1356,7 @@ export class FoundryStepExecutor {
1326
1356
  haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
1327
1357
  }
1328
1358
  else {
1329
- const server = await this.resolveServer();
1359
+ const server = await this.resolveServer(org, context);
1330
1360
  const githubApp = await this.ports.coolify.requireGithubApp();
1331
1361
  const created = await this.ports.coolify.createGithubAppApplication({
1332
1362
  project_uuid: projectUuid,
@@ -1529,7 +1559,7 @@ export class FoundryStepExecutor {
1529
1559
  dbId = found.uuid;
1530
1560
  }
1531
1561
  else {
1532
- const server = await this.resolveServer();
1562
+ const server = await this.resolveServer(org, context);
1533
1563
  const dbName = org.slug.replace(/-/g, '_');
1534
1564
  const created = await this.ports.coolify.createPostgresDatabase({
1535
1565
  project_uuid: projectUuid,
@@ -2161,16 +2191,22 @@ export class FoundryStepExecutor {
2161
2191
  const projectUuid = await this.ensureCoolifyProject(org, context);
2162
2192
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2163
2193
  const site = await this.findOrCreateCmsApp({
2194
+ org,
2195
+ context,
2164
2196
  projectUuid,
2165
2197
  environment,
2166
2198
  name: `${org.slug}-site`,
2199
+ repo: org.site.repo ?? CMS_REPO,
2167
2200
  dockerfileLocation: CMS_SITE_APP_DOCKERFILE,
2168
2201
  domain,
2169
2202
  });
2170
2203
  const cms = await this.findOrCreateCmsApp({
2204
+ org,
2205
+ context,
2171
2206
  projectUuid,
2172
2207
  environment,
2173
2208
  name: `${org.slug}-cms`,
2209
+ repo: CMS_REPO,
2174
2210
  dockerfileLocation: CMS_APP_DOCKERFILE,
2175
2211
  domain: cmsHostnameFor(org),
2176
2212
  });
@@ -2281,7 +2317,7 @@ export class FoundryStepExecutor {
2281
2317
  const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
2282
2318
  if (found)
2283
2319
  return found.uuid;
2284
- const server = await this.resolveServer();
2320
+ const server = await this.resolveServer(org, context);
2285
2321
  const dbName = name.replace(/-/g, '_');
2286
2322
  const created = await this.ports.coolify.createPostgresDatabase({
2287
2323
  project_uuid: projectUuid,
@@ -2368,6 +2404,10 @@ export class FoundryStepExecutor {
2368
2404
  * `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
2369
2405
  * app either) has since closed the same way, inline in that method (#130);
2370
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.
2371
2411
  */
2372
2412
  async findOrCreateCmsApp(input) {
2373
2413
  const existing = await this.ports.coolify.findApplicationByName(input.name, {
@@ -2380,7 +2420,7 @@ export class FoundryStepExecutor {
2380
2420
  }
2381
2421
  return { appId: existing.uuid, existed: true };
2382
2422
  }
2383
- const server = await this.resolveServer();
2423
+ const server = await this.resolveServer(input.org, input.context);
2384
2424
  const githubApp = await this.ports.coolify.requireGithubApp();
2385
2425
  const created = await this.ports.coolify.createGithubAppApplication({
2386
2426
  project_uuid: input.projectUuid,
@@ -2388,7 +2428,7 @@ export class FoundryStepExecutor {
2388
2428
  environment_name: input.environment.name,
2389
2429
  environment_uuid: input.environment.uuid,
2390
2430
  github_app_uuid: githubApp.uuid,
2391
- git_repository: CMS_REPO,
2431
+ git_repository: input.repo,
2392
2432
  git_branch: 'main',
2393
2433
  build_pack: 'dockerfile',
2394
2434
  name: input.name,
@@ -2426,16 +2466,16 @@ export class FoundryStepExecutor {
2426
2466
  }
2427
2467
  const projectUuid = await this.ensureCoolifyProject(org, context);
2428
2468
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2429
- const server = await this.resolveServer();
2469
+ const server = await this.resolveServer(org, context);
2430
2470
  const names = {
2431
2471
  synapse: `${org.slug}-calls-synapse`,
2432
2472
  mas: `${org.slug}-calls-mas`,
2433
2473
  synapseDb: `${org.slug}-calls-synapse-db`,
2434
2474
  masDb: `${org.slug}-calls-mas-db`,
2435
2475
  };
2436
- 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);
2437
2477
  context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
2438
- 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);
2439
2479
  context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
2440
2480
  const [synapseDbRow, masDbRow] = await Promise.all([
2441
2481
  getDatabaseStarted(this.ports.coolify, synapseDbId),
@@ -2589,14 +2629,14 @@ export class FoundryStepExecutor {
2589
2629
  * database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
2590
2630
  * `system:kind:key` slot of its own for the same reason.
2591
2631
  */
2592
- async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
2632
+ async ensureCallsDatabase(org, context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
2593
2633
  const existing = context.recordedIds()[scopeKey];
2594
2634
  if (existing)
2595
2635
  return existing;
2596
2636
  const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
2597
2637
  if (found)
2598
2638
  return found.uuid;
2599
- const server = await this.resolveServer();
2639
+ const server = await this.resolveServer(org, context);
2600
2640
  const created = await this.ports.coolify.createPostgresDatabase({
2601
2641
  project_uuid: projectUuid,
2602
2642
  server_uuid: server.uuid,
@@ -2722,13 +2762,13 @@ export class FoundryStepExecutor {
2722
2762
  await this.ensureChatAdmins(org, zitadelOrgId, project.id, recorded);
2723
2763
  const projectUuid = await this.ensureCoolifyProject(org, context);
2724
2764
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2725
- const server = await this.resolveServer();
2765
+ const server = await this.resolveServer(org, context);
2726
2766
  const names = chatInternalNames(org.slug);
2727
- 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);
2728
2768
  context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
2729
- 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);
2730
2770
  context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
2731
- 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);
2732
2772
  context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
2733
2773
  const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
2734
2774
  getDatabaseStarted(this.ports.coolify, synapseDbId),
@@ -2904,13 +2944,13 @@ export class FoundryStepExecutor {
2904
2944
  const synapseHost = `${org.slug}-calls-synapse`;
2905
2945
  const masHost = `${org.slug}-calls-mas`;
2906
2946
  const dbName = `${name}-db`;
2907
- 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);
2908
2948
  context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
2909
2949
  const db = await getDatabaseStarted(this.ports.coolify, dbId);
2910
2950
  if (!db.internal_db_url) {
2911
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`);
2912
2952
  }
2913
- const server = await this.resolveServer();
2953
+ const server = await this.resolveServer(org, context);
2914
2954
  const githubApp = await this.ports.coolify.requireGithubApp();
2915
2955
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
2916
2956
  project_uuid: projectUuid,
@@ -3123,6 +3163,19 @@ export class FoundryStepExecutor {
3123
3163
  * pushed once calls are on and this org's calls service app carries its
3124
3164
  * internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
3125
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.
3126
3179
  */
3127
3180
  async coolifyAgora(action, org, context) {
3128
3181
  // The host values the plan read, carried in the step: never read again
@@ -3138,7 +3191,7 @@ export class FoundryStepExecutor {
3138
3191
  const projectUuid = await this.ensureCoolifyProject(org, context);
3139
3192
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3140
3193
  const name = `${org.slug}-agora`;
3141
- const server = await this.resolveServer();
3194
+ const server = await this.resolveServer(org, context);
3142
3195
  const githubApp = await this.ports.coolify.requireGithubApp();
3143
3196
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
3144
3197
  project_uuid: projectUuid,
@@ -3155,7 +3208,20 @@ export class FoundryStepExecutor {
3155
3208
  }));
3156
3209
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3157
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;
3158
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);
3159
3225
  return { resourceId: app.appId };
3160
3226
  }
3161
3227
  // ---- boule (#161) --------------------------------------------------------
@@ -3195,9 +3261,9 @@ export class FoundryStepExecutor {
3195
3261
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3196
3262
  const name = `${org.slug}-boule`;
3197
3263
  const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
3198
- 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);
3199
3265
  context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
3200
- const server = await this.resolveServer();
3266
+ const server = await this.resolveServer(org, context);
3201
3267
  const githubApp = await this.ports.coolify.requireGithubApp();
3202
3268
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
3203
3269
  project_uuid: projectUuid,
@@ -3409,7 +3475,7 @@ export class FoundryStepExecutor {
3409
3475
  appName: `${org.slug}-cloudflared`,
3410
3476
  projectUuid,
3411
3477
  environment,
3412
- resolveServer: () => this.resolveServer(),
3478
+ resolveServer: () => this.resolveServer(org, context),
3413
3479
  recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
3414
3480
  recordAppId: async (appId) => {
3415
3481
  context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
@@ -4029,11 +4095,11 @@ export class FoundryStepExecutor {
4029
4095
  }
4030
4096
  // ---- files / ai --------------------------------------------------------
4031
4097
  /**
4032
- * #80: provisions the org's own organisation on Files, before
4033
- * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
4034
- * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
4035
- * this is called every time regardless of whether it already ran --
4036
- * 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.
4037
4103
  *
4038
4104
  * F6b: `action.desired.shareHost` (`diff.ts`'s `planFilesOrganisation`) is
4039
4105
  * passed through every time, even though `entry-files-key.ts` reports the
@@ -4046,15 +4112,23 @@ export class FoundryStepExecutor {
4046
4112
  * repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
4047
4113
  * gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
4048
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.
4049
4123
  */
4050
4124
  async filesOrganisation(action, org, context) {
4051
4125
  const files = await this.filesPort();
4052
- // F6b: an org whose entries use files needs an owner member in its files
4053
- // organisation, which its own key is minted below. Only a new
4054
- // organisation takes one, so it is passed when the owner is known now.
4055
- const owner = org.hosting.entries.some((e) => e.uses?.files)
4056
- ? filesOwnerUserId(org, context.recordedIds())
4057
- : 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);
4058
4132
  const provisioned = await files.provisionOrganisation({
4059
4133
  organisationId: org.id,
4060
4134
  ceilingBytes: action.desired.ceilingBytes ?? null,
@@ -4071,6 +4145,22 @@ export class FoundryStepExecutor {
4071
4145
  if (provisioned.agoraKey) {
4072
4146
  await this.deliverFilesAgoraKey(files, org, provisioned.agoraKey, context);
4073
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);
4074
4164
  return { resourceId: provisioned.organisationId };
4075
4165
  }
4076
4166
  /**
@@ -4107,12 +4197,6 @@ export class FoundryStepExecutor {
4107
4197
  context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, '');
4108
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'}`);
4109
4199
  }
4110
- async filesStorage(action, org) {
4111
- const files = await this.filesPort();
4112
- await files.setQuota(org.id, action.desired.limitBytes ?? null);
4113
- await files.setBudget(org.id, action.desired.limitMicros ?? null);
4114
- return { resourceId: org.id };
4115
- }
4116
4200
  /**
4117
4201
  * #80: provisions the org's own organisation on AI and switches its AI use
4118
4202
  * on, before {@link aiBudget} can set its own spend limit. Both
@@ -2,12 +2,16 @@ 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
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
@@ -1,8 +1,10 @@
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
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.
@@ -48,6 +48,11 @@ export interface ZitadelReader {
48
48
  }
49
49
  export interface CoolifyReader {
50
50
  listResources(orgId: OrgId): Promise<LiveResource[]>;
51
+ /**
52
+ * Reads one Coolify project by its uuid, for an org that records none yet
53
+ * (adopt, archon#271). Optional: without it nothing can be adopted.
54
+ */
55
+ listProject?(projectUuid: string): Promise<LiveResource[]>;
51
56
  }
52
57
  export interface CloudflareReader {
53
58
  listResources(orgId: OrgId): Promise<LiveResource[]>;
@@ -66,7 +66,7 @@ export interface LoginHostBindingsPort {
66
66
  * on the next run.
67
67
  */
68
68
  export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
69
- /** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
69
+ /** The org's recorded app client ids (hosted app, each entry's own, cms, agora, boule), deduplicated and sorted. */
70
70
  export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
71
71
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
72
72
  export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
@@ -1,4 +1,5 @@
1
1
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
2
+ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.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
@@ -36,18 +37,18 @@ export function loginHostnameFor(org) {
36
37
  * on the next run.
37
38
  */
38
39
  export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
39
- /** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
40
+ /** The org's recorded app client ids (hosted app, each entry's own, cms, agora, boule), deduplicated and sorted. */
40
41
  export function loginHostClientIds(recorded) {
41
- return [
42
+ const ids = [
42
43
  recorded[APP_CLIENT_ID_SCOPE_KEY],
43
44
  recorded[CMS_CLIENT_ID_SCOPE_KEY],
44
45
  recorded[AGORA_CLIENT_ID_SCOPE_KEY],
46
+ recorded[BOULE_CLIENT_ID_SCOPE_KEY],
45
47
  ...Object.entries(recorded)
46
48
  .filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
47
49
  .map(([, id]) => id),
48
- ]
49
- .filter((id) => Boolean(id))
50
- .sort();
50
+ ].filter((id) => Boolean(id));
51
+ return [...new Set(ids)].sort();
51
52
  }
52
53
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
53
54
  export function loginBindingState(host, clientIds) {