@wtfalch/foundry 0.61.0 → 0.62.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 (40) hide show
  1. package/dist/index.d.ts +5 -1
  2. package/dist/index.js +2 -0
  3. package/dist/migrations/0065_native_build_runs.sql +32 -0
  4. package/dist/migrations/0066_org_kosmos.sql +10 -0
  5. package/dist/modules/sign-in.js +1 -1
  6. package/dist/native-build.d.ts +70 -0
  7. package/dist/native-build.js +92 -0
  8. package/dist/org.d.ts +22 -3
  9. package/dist/org.js +25 -2
  10. package/dist/plan/agora.d.ts +1 -1
  11. package/dist/plan/agora.js +4 -0
  12. package/dist/plan/blue-green.d.ts +19 -7
  13. package/dist/plan/blue-green.js +35 -8
  14. package/dist/plan/deploy-health.js +9 -2
  15. package/dist/plan/desktop-callback.js +4 -1
  16. package/dist/plan/diff.js +180 -63
  17. package/dist/plan/edge-access.js +1 -0
  18. package/dist/plan/executor.d.ts +40 -18
  19. package/dist/plan/executor.js +153 -49
  20. package/dist/plan/kosmos.d.ts +56 -0
  21. package/dist/plan/kosmos.js +84 -0
  22. package/dist/plan/locked-hostnames.js +5 -2
  23. package/dist/plan/org-names.d.ts +3 -3
  24. package/dist/plan/org-names.js +3 -1
  25. package/dist/plan/org-postgres-migrate.js +1 -0
  26. package/dist/plan/own-limits.d.ts +6 -0
  27. package/dist/plan/own-limits.js +1 -0
  28. package/dist/plan/platform-providers.js +6 -0
  29. package/dist/plan/post-logout-uris.d.ts +1 -1
  30. package/dist/plan/post-logout-uris.js +5 -2
  31. package/dist/plan/providers-readers.js +20 -1
  32. package/dist/release/migration-compatibility.d.ts +66 -0
  33. package/dist/release/migration-compatibility.js +340 -0
  34. package/dist/requested-changes.js +2 -1
  35. package/dist/slug.d.ts +1 -1
  36. package/dist/slug.js +11 -2
  37. package/dist/store/postgres.js +13 -0
  38. package/dist/store/schema.d.ts +36 -0
  39. package/dist/store/schema.js +6 -0
  40. package/package.json +2 -2
@@ -5,7 +5,7 @@ import { EdgeApiError } from '@wtfalch/edge';
5
5
  import { coolifyImageFor } from '../bootstrap/versions.js';
6
6
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
7
7
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
8
- import { APPS_DOMAIN, APP_ENTRY_KEY, FULL_COMMIT_SHA, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, filesCollectionEnvName, heldEnvRef, isLiveEntry, isMainEntry, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
8
+ import { APPS_DOMAIN, APP_ENTRY_KEY, FULL_COMMIT_SHA, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, filesCollectionEnvName, heldEnvRef, isLiveEntry, isMainEntry, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
9
9
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
10
10
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
11
11
  import { APPS_DATABASE_ADMIN_URL_ENV, applyAppsDbRoute, retireAppsDbRoute, } from './apps-db-route.js';
@@ -19,7 +19,7 @@ import { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, REGISTRAR_APPLIED_SCOPE
19
19
  import { BROKER_WORKSPACES_DATABASE_SCOPE_KEY, canHoldWorkspacesDatabase, ensureBrokerWorkspacesDatabase, } from './broker-workspaces-database.js';
20
20
  import { recordBuiltCommit } from './build-record.js';
21
21
  import { CallError, withTimeout as withLabelledTimeout } from './call-timeout.js';
22
- import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
22
+ import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
23
23
  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';
24
24
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
25
25
  import { BACKUP_POSTGRES_DATABASES_SCOPE_KEY, BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
@@ -50,6 +50,7 @@ import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSett
50
50
  import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
51
51
  import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, workspaceHost, } from './hostnames.js';
52
52
  import { KEYS_REGISTRAR_DELIVERY_ATTEMPTS, KEYS_REGISTRAR_KEY_DAYS, KEYS_REGISTRAR_KEY_ENV, KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, KEYS_REGISTRAR_URL_ENV, } from './keys-registrar.js';
53
+ import { KOSMOS_CLIENT_ID_SCOPE_KEY, KOSMOS_DATABASE_SCOPE_KEY, KOSMOS_DEPLOYED_SCOPE_KEY, desiredKosmosEnv, kosmosEnvDrift, kosmosHostnameFor, } from './kosmos.js';
53
54
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
54
55
  import { machineBypassScopeKey } from './machine-paths.js';
55
56
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
@@ -392,6 +393,15 @@ export const BOULE_REPO = 'wtfalch/boule';
392
393
  export const BOULE_APP_DOCKERFILE = '/Dockerfile';
393
394
  /** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
394
395
  export const BOULE_PORT = 3000;
396
+ /**
397
+ * foundry#949: kosmos (https://github.com/wtfalch/kosmos), bootstrapped from the same `wtfalch/package-template`
398
+ * service shape Boule is, with its own Dockerfile at the repository root, serving on 3000 (`EXPOSE 3000`) and
399
+ * running its migrations through `apps/host/scripts/migrate.mjs` before it serves, the same shape Boule's
400
+ * `migrate-and-start.sh` follows.
401
+ */
402
+ export const KOSMOS_REPO = 'wtfalch/kosmos';
403
+ export const KOSMOS_APP_DOCKERFILE = '/Dockerfile';
404
+ export const KOSMOS_PORT = 3000;
395
405
  /**
396
406
  * Every app foundry deploys keeps its own logs across a redeploy, so a
397
407
  * database outage does not also erase the only other copy of the evidence
@@ -657,6 +667,14 @@ const CALLS_SYNAPSE_SCOPE_KEY = 'coolify_calls_synapse';
657
667
  const CALLS_MAS_SCOPE_KEY = 'coolify_calls_mas';
658
668
  const CALLS_SYNAPSE_DB_SCOPE_KEY = 'coolify_calls_synapse_database';
659
669
  const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
670
+ /** #430: the calls service's own Matrix/MAS env vars, from before calls#93 (transit-only). Removed from every org's calls service, never written again. */
671
+ const CALLS_MATRIX_ENV_KEYS = [
672
+ 'MAS_URL',
673
+ 'MAS_HOMESERVER',
674
+ 'MAS_CLIENT_ID',
675
+ 'MAS_CLIENT_SECRET',
676
+ 'SYNAPSE_URL',
677
+ ];
660
678
  /**
661
679
  * Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
662
680
  * is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
@@ -947,6 +965,8 @@ function limitProfileOf(action) {
947
965
  case 'coolify:boule':
948
966
  case 'coolify:boule-broker-entry':
949
967
  return LIMITS.boule;
968
+ case 'coolify:kosmos':
969
+ return LIMITS.kosmos;
950
970
  case 'coolify:broker-registrar':
951
971
  return LIMITS.brokerEntry;
952
972
  case 'coolify:keys-registrar':
@@ -1380,6 +1400,8 @@ export class FoundryStepExecutor {
1380
1400
  return this.bouleOwners(org, context);
1381
1401
  case 'coolify:boule-broker-entry':
1382
1402
  return this.bouleBrokerEntry(org, context);
1403
+ case 'coolify:kosmos':
1404
+ return this.coolifyKosmos(action, org, context);
1383
1405
  case 'coolify:broker-registrar':
1384
1406
  return this.brokerRegistrar(org, context);
1385
1407
  case 'coolify:keys-registrar':
@@ -2778,6 +2800,7 @@ export class FoundryStepExecutor {
2778
2800
  const isCmsClient = action.key === 'cms' && org.site.enabled;
2779
2801
  const isAgoraClient = action.key === 'agora';
2780
2802
  const isBouleClient = action.key === 'boule';
2803
+ const isKosmosClient = action.key === 'kosmos' && kosmosOn(org);
2781
2804
  const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
2782
2805
  // A workspace's own client `<key>-work`, signing in at the workspace host.
2783
2806
  const workspaceEntry = authEntry ? undefined : workspaceAuthEntryOf(org, action.key);
@@ -2787,9 +2810,11 @@ export class FoundryStepExecutor {
2787
2810
  ? AGORA_CLIENT_ID_SCOPE_KEY
2788
2811
  : isBouleClient
2789
2812
  ? BOULE_CLIENT_ID_SCOPE_KEY
2790
- : authEntry || workspaceEntry
2791
- ? entryClientIdScopeKey(action.key)
2792
- : APP_CLIENT_ID_SCOPE_KEY;
2813
+ : isKosmosClient
2814
+ ? KOSMOS_CLIENT_ID_SCOPE_KEY
2815
+ : authEntry || workspaceEntry
2816
+ ? entryClientIdScopeKey(action.key)
2817
+ : APP_CLIENT_ID_SCOPE_KEY;
2793
2818
  const priorAppId = recorded[`zitadel:app:${action.key}`];
2794
2819
  // Only a step the planner flagged as destroying (`recreate`, set with
2795
2820
  // `destroys: true` so plan-apply asks for the typed confirmation) may
@@ -4247,6 +4272,8 @@ export class FoundryStepExecutor {
4247
4272
  }
4248
4273
  case 'boule':
4249
4274
  return { appIds: await byName(`${org.slug}-boule`), template: true };
4275
+ case 'kosmos':
4276
+ return { appIds: await byName(`${org.slug}-kosmos`), template: true };
4250
4277
  case 'chat_service':
4251
4278
  return { appIds: await byName(chat.service), template: true };
4252
4279
  case 'calls_service':
@@ -6530,16 +6557,18 @@ export class FoundryStepExecutor {
6530
6557
  // ---- calls service (#145, part 2 of #128) -------------------------------
6531
6558
  /**
6532
6559
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
6533
- * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
6534
- * {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
6535
- * Static config checks run first, before any Coolify call (#128's own
6536
- * carried-over PR 344 review fix, restated in #145's own issue body): the
6537
- * calls Matrix stack must already be recorded, its MAS app must already
6538
- * carry its client id/secret, and the shared SFU's LiveKit credentials must
6539
- * already be resolvable -- none of the three is minted or guessed here.
6540
- * Park stops the app only, the same park-not-delete rule
6541
- * {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
6542
- * 1); its own database is never touched from that branch.
6560
+ * `wtfalch/calls`'s `packages/service`, wired to the shared LiveKit SFU
6561
+ * (#122/#127). #430: calls run transit-only (calls#93's
6562
+ * `CALLS_ENCRYPTION=transit`), so this no longer waits on or wires a
6563
+ * per-org Matrix stack at all -- an org whose stack is already live from
6564
+ * before this keeps running it, unused, until a human removes it
6565
+ * separately. Static config checks run first, before any Coolify call
6566
+ * (#128's own carried-over PR 344 review fix, restated in #145's own
6567
+ * issue body): the shared SFU's LiveKit credentials must already be
6568
+ * resolvable -- not minted or guessed here. Park stops the app only, the
6569
+ * same park-not-delete rule {@link coolifyCalls}/{@link coolifyApp}
6570
+ * already follow (#7 corrections 1); its own database is never touched
6571
+ * from that branch.
6543
6572
  */
6544
6573
  async coolifyCallsService(action, org, context) {
6545
6574
  if (action.desired.enabled === false) {
@@ -6550,18 +6579,8 @@ export class FoundryStepExecutor {
6550
6579
  await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
6551
6580
  return { resourceId: appId };
6552
6581
  }
6553
- // Refused before anything is created, like the missing-stack refusal below.
6582
+ // Refused before anything is created, like the missing-SFU refusal below.
6554
6583
  const sfuUrls = resolveCallsSfuUrls(this.ports.callsSfuUrls);
6555
- const recorded = context.recordedIds();
6556
- const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
6557
- const masId = recorded[CALLS_MAS_SCOPE_KEY];
6558
- if (!synapseId || !masId) {
6559
- throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack has not been provisioned yet -- coolify:calls must run first`);
6560
- }
6561
- const masCredentials = await this.readCallsMasClientCredentials(masId);
6562
- if (!masCredentials) {
6563
- throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack's MAS client credentials are not on the MAS app yet -- retry this apply once coolify:calls has minted them`);
6564
- }
6565
6584
  const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
6566
6585
  if (!sfu) {
6567
6586
  throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials for this org yet -- see https://github.com/wtfalch/foundry/issues/152`);
@@ -6569,8 +6588,6 @@ export class FoundryStepExecutor {
6569
6588
  const projectUuid = await this.ensureCoolifyProject(org, context);
6570
6589
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
6571
6590
  const name = callsServiceInternalName(org);
6572
- const synapseHost = `${org.slug}-calls-synapse`;
6573
- const masHost = `${org.slug}-calls-mas`;
6574
6591
  const dbName = `${name}-db`;
6575
6592
  const serviceHome = await this.ensureHomeDatabase(org, context, 'calls_service', CALLS_SERVICE_DB_SCOPE_KEY, () => this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY));
6576
6593
  if (!(await this.homeOwnerUrl(serviceHome))) {
@@ -6612,17 +6629,25 @@ export class FoundryStepExecutor {
6612
6629
  });
6613
6630
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
6614
6631
  await this.ensureLogVolume(app.appId, org, app.existed);
6632
+ // #430: an org whose Matrix stack is still live from before this keeps
6633
+ // the Synapse/MAS apps running, unused, until a human removes them
6634
+ // separately -- but its calls service's own env drops their vars on its
6635
+ // next apply, the same as a brand new org that never got them.
6636
+ for (const key of CALLS_MATRIX_ENV_KEYS) {
6637
+ const env = (await this.ports.coolify.listEnvs(app.appId)).find((e) => e.key === key && !e.is_preview);
6638
+ if (env?.uuid)
6639
+ await this.ports.coolify.deleteEnv(app.appId, env.uuid);
6640
+ }
6615
6641
  const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
6616
6642
  const envs = [
6617
6643
  { key: 'LIVEKIT_URL', value: sfuUrls.privateUrl },
6618
6644
  { key: 'LIVEKIT_PUBLIC_URL', value: sfuUrls.publicUrl },
6619
6645
  { key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
6620
6646
  { key: 'LIVEKIT_API_SECRET', value: sfu.apiSecret },
6621
- { key: 'MAS_URL', value: `http://${masHost}:8080/` },
6622
- { key: 'MAS_HOMESERVER', value: callsServerName(org.slug) },
6623
- { key: 'MAS_CLIENT_ID', value: masCredentials.clientId },
6624
- { key: 'MAS_CLIENT_SECRET', value: masCredentials.clientSecret },
6625
- { key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
6647
+ // #430 (calls#93): calls are transit-only, so the service never needs
6648
+ // a Matrix/MAS provider -- MAS_URL, MAS_HOMESERVER, MAS_CLIENT_ID,
6649
+ // MAS_CLIENT_SECRET and SYNAPSE_URL are never written.
6650
+ { key: 'CALLS_ENCRYPTION', value: 'transit' },
6626
6651
  { key: 'CALLS_ORIGIN', value: `https://${agoraHostnameFor(org)}` },
6627
6652
  { key: 'CALLS_HOST_ADAPTER', value: 'production' },
6628
6653
  // Deliberately kept as it was, not widened: the calls service refuses to
@@ -6748,6 +6773,9 @@ export class FoundryStepExecutor {
6748
6773
  throw new Error(`${at}: token ${tokenId} is recorded but chat-service no longer holds a usable one; remove the recorded keys ${CHAT_CALLS_TOKEN_SCOPE_KEY} and ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
6749
6774
  }
6750
6775
  const token = usable ?? randomBytes(32).toString('hex');
6776
+ // Registered before either write, so a Coolify error body echoing the upsert is scrubbed
6777
+ // from the run record the same way the write loop's own dropped-error fallback intends.
6778
+ this.handledSecrets.add(token);
6751
6779
  if (!tokenId) {
6752
6780
  tokenId = randomBytes(8).toString('hex');
6753
6781
  context.recordScopeId(CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, tokenId);
@@ -6852,22 +6880,6 @@ export class FoundryStepExecutor {
6852
6880
  appId: client.appId,
6853
6881
  };
6854
6882
  }
6855
- /**
6856
- * Reads back the calls Matrix stack's MAS client id/secret from the MAS
6857
- * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
6858
- * either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
6859
- * here means that stack has not finished provisioning yet, not something
6860
- * this step can fix on its own.
6861
- */
6862
- async readCallsMasClientCredentials(masAppId) {
6863
- const envs = await this.ports.coolify.listEnvs(masAppId);
6864
- const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
6865
- const clientId = read(CALLS_SECRET_ENV_KEYS.masClientId);
6866
- const clientSecret = read(CALLS_SECRET_ENV_KEYS.masClientSecret);
6867
- if (!clientId || !clientSecret)
6868
- return null;
6869
- return { clientId, clientSecret };
6870
- }
6871
6883
  /**
6872
6884
  * `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
6873
6885
  * never rotated, the same rule {@link ensureAuthCookieSecret} follows for
@@ -7452,6 +7464,98 @@ export class FoundryStepExecutor {
7452
7464
  this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
7453
7465
  return { resourceId: app.appId };
7454
7466
  }
7467
+ // ---- kosmos (foundry#949) --------------------------------------------------
7468
+ /**
7469
+ * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
7470
+ * (https://github.com/wtfalch/kosmos) as one more Coolify application, from
7471
+ * that repo's root Dockerfile ({@link KOSMOS_REPO}), with a database of its
7472
+ * own, and pushes its sign-in env. The same shape as {@link coolifyBoule},
7473
+ * trimmed: kosmos is opt-in (`kosmosOn(org)` guards every caller, so this is
7474
+ * never reached while it is off), has no mail, no owner seeding and no
7475
+ * Archon credential.
7476
+ *
7477
+ * The sign-in env comes from `plan/kosmos.ts`'s `desiredKosmosEnv`, the same
7478
+ * function the plan compares the live app against; it never writes a
7479
+ * partial set. The database is `<slug>-kosmos-db`, split the same way
7480
+ * Boule's is: the superuser URL as `DATABASE_URL_OWNER` and a `<dbName>_rt`
7481
+ * role's URL as `DATABASE_URL`, both through `pushDatabaseUrl`.
7482
+ *
7483
+ * #504 (same reason {@link coolifyBoule}'s own comment gives): a brand new
7484
+ * app is never restarted, but it is deployed once ({@link KOSMOS_DEPLOYED_SCOPE_KEY}).
7485
+ */
7486
+ async coolifyKosmos(action, org, context) {
7487
+ // Park stops the app only; its database is never touched (#7 corrections 1).
7488
+ if (action.desired.enabled === false) {
7489
+ const appId = context.recordedIds()[`coolify:kosmos:${action.key}`];
7490
+ if (!appId) {
7491
+ throw new Error(`coolify:kosmos for ${org.slug}: no live app recorded, nothing to park`);
7492
+ }
7493
+ await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
7494
+ return { resourceId: appId };
7495
+ }
7496
+ const issuer = action.desired.issuer ?? null;
7497
+ const { env, missing } = desiredKosmosEnv(org, context.recordedIds(), issuer);
7498
+ if (missing.length > 0) {
7499
+ throw new Error(`kosmos for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
7500
+ }
7501
+ const projectUuid = await this.ensureCoolifyProject(org, context);
7502
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
7503
+ const name = `${org.slug}-kosmos`;
7504
+ const dbName = `${org.slug}-kosmos-db`.replace(/-/g, '_');
7505
+ // #444: on the company's server there is no Coolify database to make, like Boule's.
7506
+ const kosmosServer = this.homeOnServer(org, context, 'kosmos')
7507
+ ? await this.serverHome(org, context, 'kosmos')
7508
+ : null;
7509
+ const dbId = kosmosServer
7510
+ ? ''
7511
+ : await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-kosmos-db`, dbName, KOSMOS_DATABASE_SCOPE_KEY);
7512
+ if (!kosmosServer)
7513
+ context.recordScopeId(KOSMOS_DATABASE_SCOPE_KEY, dbId);
7514
+ const server = await this.resolveServer(org, context);
7515
+ const githubApp = await this.ports.coolify.requireGithubApp();
7516
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
7517
+ is_preview_deployments_enabled: false,
7518
+ project_uuid: projectUuid,
7519
+ server_uuid: server.uuid,
7520
+ environment_name: environment.name,
7521
+ environment_uuid: environment.uuid,
7522
+ github_app_uuid: githubApp.uuid,
7523
+ git_repository: KOSMOS_REPO,
7524
+ git_branch: 'main',
7525
+ build_pack: 'dockerfile',
7526
+ name,
7527
+ domains: coolifyTunnelDomains([kosmosHostnameFor(org)]),
7528
+ dockerfile_location: KOSMOS_APP_DOCKERFILE,
7529
+ }));
7530
+ // Every app foundry deploys keeps its own logs (#75), reused as-is here.
7531
+ await this.ensureLogVolume(app.appId, org, app.existed);
7532
+ await this.ports.coolify.updateApplication(app.appId, {
7533
+ ...healthCheckSettings('/api/health', KOSMOS_PORT),
7534
+ ...(await this.routedLabels(name, kosmosHostnameFor(org), KOSMOS_PORT)),
7535
+ });
7536
+ const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
7537
+ const liveEnv = Object.fromEntries(liveRows.map((e) => [e.key, e.value]));
7538
+ const envChanged = kosmosEnvDrift(env, liveEnv).length > 0;
7539
+ const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
7540
+ const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
7541
+ if (cookieSecret)
7542
+ envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
7543
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
7544
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName, kosmosServer ?? undefined);
7545
+ if (!dbPush.pushed) {
7546
+ throw new Error(`kosmos for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
7547
+ }
7548
+ if (!context.recordedIds()[KOSMOS_DEPLOYED_SCOPE_KEY]) {
7549
+ const runtime = this.ports.runtime ?? {};
7550
+ const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), kosmosHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:kosmos', `${org.slug}'s kosmos stays at its previous deploy`);
7551
+ await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'kosmos' }, (key, value) => context.recordScopeId(key, value));
7552
+ context.recordScopeId(KOSMOS_DEPLOYED_SCOPE_KEY, '1');
7553
+ }
7554
+ else if (app.existed && (envChanged || cookieSecret !== null || dbPush.changed)) {
7555
+ await this.ports.coolify.restart(app.appId);
7556
+ }
7557
+ return { resourceId: app.appId };
7558
+ }
7455
7559
  /**
7456
7560
  * C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
7457
7561
  * when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
@@ -0,0 +1,56 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ /**
3
+ * foundry#949: what both the planner (`diff.ts`) and the executor
4
+ * (`executor.ts`) need to agree on for an org's kosmos -- the hostname, the
5
+ * recorded ids and the env vars those produce -- so the plan can compare the
6
+ * live app with exactly what the executor would write. The same split
7
+ * `boule.ts` and `agora.ts` already follow, trimmed to what kosmos needs for
8
+ * its first release: a ZITADEL client, a database and sign-in env. Kosmos has
9
+ * no mail, no owner seeding and no Archon credential -- it is not Boule.
10
+ */
11
+ /**
12
+ * The fixed `kosmos` ZITADEL web client's `clientId`, recorded by
13
+ * `executor.ts`'s `zitadelApp` and read by `coolifyKosmos` for
14
+ * `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
15
+ * `boule_client_id`.
16
+ */
17
+ export declare const KOSMOS_CLIENT_ID_SCOPE_KEY = "kosmos_client_id";
18
+ /**
19
+ * The kosmos database's Coolify uuid. It has no `system:kind:key` slot of its
20
+ * own (the step's one auto-recorded `resourceId` is the app), so it is a flat
21
+ * scope id, like the Boule and cms databases.
22
+ */
23
+ export declare const KOSMOS_DATABASE_SCOPE_KEY = "coolify_kosmos_database";
24
+ /**
25
+ * Recorded once `coolifyKosmos` has actually called `deploy` on this org's
26
+ * kosmos app -- the same marker {@link BOULE_DEPLOYED_SCOPE_KEY} (`boule.ts`)
27
+ * and {@link AGORA_DEPLOYED_SCOPE_KEY} (`agora.ts`) are, and for the same
28
+ * reason: Coolify defaults `instant_deploy` to false, so a freshly created
29
+ * app sat built but never started.
30
+ */
31
+ export declare const KOSMOS_DEPLOYED_SCOPE_KEY = "coolify_kosmos_deployed";
32
+ /**
33
+ * `kosmos.<domain>` when the org has a domain, `<slug>-kosmos.oikos.host`
34
+ * otherwise -- the same tier rule `bouleHostnameFor`/`agoraHostnameFor`
35
+ * follow; `../slug.ts`'s `KOSMOS_SUFFIX` reserves the collision.
36
+ */
37
+ export declare function kosmosHostnameFor(org: DeclaredOrg): string;
38
+ /** The env names `desiredKosmosEnv` can write, so the live reader knows which to read back. */
39
+ export declare const KOSMOS_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID"];
40
+ /**
41
+ * The env vars an org's kosmos app should carry, and every input still missing
42
+ * to build them. Callers that write must refuse on a non-empty `missing`: an
43
+ * kosmos with a blank client id fails every sign-in. `issuer` is the host's
44
+ * issuer address (`AgoraHostValues.issuer`), the same one Agora and Boule sign
45
+ * in at.
46
+ *
47
+ * Not here, and written by the executor instead: the database URLs
48
+ * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
49
+ * (generated once, never rotated) -- the same split `desiredBouleEnv` keeps.
50
+ */
51
+ export declare function desiredKosmosEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null): {
52
+ env: Record<string, string>;
53
+ missing: string[];
54
+ };
55
+ /** The desired env keys the live app lacks or holds a different value for. */
56
+ export declare function kosmosEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
@@ -0,0 +1,84 @@
1
+ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
2
+ /**
3
+ * foundry#949: what both the planner (`diff.ts`) and the executor
4
+ * (`executor.ts`) need to agree on for an org's kosmos -- the hostname, the
5
+ * recorded ids and the env vars those produce -- so the plan can compare the
6
+ * live app with exactly what the executor would write. The same split
7
+ * `boule.ts` and `agora.ts` already follow, trimmed to what kosmos needs for
8
+ * its first release: a ZITADEL client, a database and sign-in env. Kosmos has
9
+ * no mail, no owner seeding and no Archon credential -- it is not Boule.
10
+ */
11
+ /**
12
+ * The fixed `kosmos` ZITADEL web client's `clientId`, recorded by
13
+ * `executor.ts`'s `zitadelApp` and read by `coolifyKosmos` for
14
+ * `AUTH_CLIENT_ID`. Public, not a secret, so a flat scope id like
15
+ * `boule_client_id`.
16
+ */
17
+ export const KOSMOS_CLIENT_ID_SCOPE_KEY = 'kosmos_client_id';
18
+ /**
19
+ * The kosmos database's Coolify uuid. It has no `system:kind:key` slot of its
20
+ * own (the step's one auto-recorded `resourceId` is the app), so it is a flat
21
+ * scope id, like the Boule and cms databases.
22
+ */
23
+ export const KOSMOS_DATABASE_SCOPE_KEY = 'coolify_kosmos_database';
24
+ /**
25
+ * Recorded once `coolifyKosmos` has actually called `deploy` on this org's
26
+ * kosmos app -- the same marker {@link BOULE_DEPLOYED_SCOPE_KEY} (`boule.ts`)
27
+ * and {@link AGORA_DEPLOYED_SCOPE_KEY} (`agora.ts`) are, and for the same
28
+ * reason: Coolify defaults `instant_deploy` to false, so a freshly created
29
+ * app sat built but never started.
30
+ */
31
+ export const KOSMOS_DEPLOYED_SCOPE_KEY = 'coolify_kosmos_deployed';
32
+ /**
33
+ * `kosmos.<domain>` when the org has a domain, `<slug>-kosmos.oikos.host`
34
+ * otherwise -- the same tier rule `bouleHostnameFor`/`agoraHostnameFor`
35
+ * follow; `../slug.ts`'s `KOSMOS_SUFFIX` reserves the collision.
36
+ */
37
+ export function kosmosHostnameFor(org) {
38
+ return org.domain ? `kosmos.${org.domain}` : `${org.slug}-kosmos.oikos.host`;
39
+ }
40
+ /** The env names `desiredKosmosEnv` can write, so the live reader knows which to read back. */
41
+ export const KOSMOS_MANAGED_ENV_KEYS = [
42
+ 'APP_URL',
43
+ 'AUTH_ISSUER',
44
+ 'AUTH_ORGANIZATION_ID',
45
+ 'AUTH_CLIENT_ID',
46
+ ];
47
+ /**
48
+ * The env vars an org's kosmos app should carry, and every input still missing
49
+ * to build them. Callers that write must refuse on a non-empty `missing`: an
50
+ * kosmos with a blank client id fails every sign-in. `issuer` is the host's
51
+ * issuer address (`AgoraHostValues.issuer`), the same one Agora and Boule sign
52
+ * in at.
53
+ *
54
+ * Not here, and written by the executor instead: the database URLs
55
+ * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
56
+ * (generated once, never rotated) -- the same split `desiredBouleEnv` keeps.
57
+ */
58
+ export function desiredKosmosEnv(org, recorded, issuer) {
59
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
60
+ const clientId = recorded[KOSMOS_CLIENT_ID_SCOPE_KEY];
61
+ const missing = [];
62
+ if (!issuer)
63
+ missing.push('issuer');
64
+ if (!zitadelOrgId)
65
+ missing.push('zitadelOrgId');
66
+ if (!clientId)
67
+ missing.push('clientId');
68
+ const env = {
69
+ APP_URL: `https://${kosmosHostnameFor(org)}`,
70
+ };
71
+ if (issuer)
72
+ env.AUTH_ISSUER = issuer;
73
+ if (zitadelOrgId)
74
+ env.AUTH_ORGANIZATION_ID = zitadelOrgId;
75
+ if (clientId)
76
+ env.AUTH_CLIENT_ID = clientId;
77
+ return { env, missing };
78
+ }
79
+ /** The desired env keys the live app lacks or holds a different value for. */
80
+ export function kosmosEnvDrift(desired, live) {
81
+ return Object.entries(desired)
82
+ .filter(([key, value]) => live[key] !== value)
83
+ .map(([key]) => key);
84
+ }
@@ -1,8 +1,9 @@
1
- import { ownsServices } from '../org.js';
1
+ import { kosmosOn, ownsServices } from '../org.js';
2
2
  import { OWN_SERVICE_LABELS } from '../service-labels.js';
3
3
  import { agoraHostnameFor } from './agora.js';
4
4
  import { bouleHostnameFor } from './boule.js';
5
5
  import { cmsStudioHostnameFor, siteHostnameFor } from './hostnames.js';
6
+ import { kosmosHostnameFor } from './kosmos.js';
6
7
  import { loginHostnameFor } from './login-host.js';
7
8
  import { ownHostnames } from './own-hostnames.js';
8
9
  export { OWN_SERVICE_LABELS };
@@ -31,6 +32,8 @@ export function lockedHostnames(org, platformHostnames) {
31
32
  }
32
33
  locked.add(agoraHostnameFor(org));
33
34
  locked.add(bouleHostnameFor(org));
35
+ if (kosmosOn(org))
36
+ locked.add(kosmosHostnameFor(org));
34
37
  if (org.site.enabled)
35
38
  locked.add(cmsStudioHostnameFor(org));
36
39
  if (org.calls.enabled && org.calls.usesDomain)
@@ -58,6 +61,6 @@ export function hostingNames(org, platformHostnames) {
58
61
  locked: lockedHostnames(org, platformHostnames),
59
62
  slug: org.slug,
60
63
  siteOwnsApex: org.site.enabled && org.site.usesDomain && org.domain !== null,
61
- builtinBlueGreen: org.boule?.blueGreen !== undefined || org.agora?.blueGreen !== undefined,
64
+ builtinBlueGreen: org.boule?.blueGreen !== undefined || org.agora?.blueGreen !== undefined || kosmosOn(org),
62
65
  };
63
66
  }
@@ -6,13 +6,13 @@ export type OrgNameSource = 'own' | 'hosting';
6
6
  * hosting entries: Agora, Boule, the tunnel and its connector, and chat,
7
7
  * calls and the cms site while they are on.
8
8
  */
9
- export declare function companyServiceNames(org: Pick<DeclaredOrg, 'slug' | 'chat' | 'calls' | 'site'>): string[];
9
+ export declare function companyServiceNames(org: Pick<DeclaredOrg, 'slug' | 'chat' | 'calls' | 'site' | 'kosmos'>): string[];
10
10
  /**
11
11
  * Every container name, network alias, database, tunnel and bucket name that
12
12
  * foundry derives for an org: its own-service names when it owns services,
13
13
  * and its hosting-entry and database names.
14
14
  */
15
- export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>): {
15
+ export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos'>): {
16
16
  name: string;
17
17
  source: OrgNameSource;
18
18
  }[];
@@ -26,5 +26,5 @@ export interface NameClash {
26
26
  source: OrgNameSource;
27
27
  }
28
28
  /** Every name `org` derives that another org or the platform also derives. */
29
- export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>, others: readonly Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>[]): NameClash[];
29
+ export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos'>, others: readonly Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos'>[]): NameClash[];
30
30
  export declare function nameClashMessage(slug: string, clash: NameClash): string;
@@ -1,4 +1,4 @@
1
- import { ownsServices } from '../org.js';
1
+ import { kosmosOn, ownsServices } from '../org.js';
2
2
  import { entryHasDatabase } from '../worker-app-config.js';
3
3
  import { chatInternalNames, chatRekeyedInternalNames } from './chat-matrix.js';
4
4
  import { workerAppHostname, workspaceHost, workspaceInboxHost } from './hostnames.js';
@@ -20,6 +20,8 @@ export function companyServiceNames(org) {
20
20
  `${s}-tunnel`,
21
21
  `${s}-cloudflared`,
22
22
  ];
23
+ if (kosmosOn(org))
24
+ names.push(`${s}-kosmos`, `${s}-kosmos-db`);
23
25
  if (org.chat?.enabled) {
24
26
  names.push(...Object.values(chatInternalNames(s)), ...Object.values(chatRekeyedInternalNames(s)));
25
27
  }
@@ -64,6 +64,7 @@ export function parseMigrationConfirmation(raw) {
64
64
  const MIGRATION_ORDER = [
65
65
  'app',
66
66
  'cms',
67
+ 'kosmos',
67
68
  'boule',
68
69
  'chat_service',
69
70
  'calls_service',
@@ -107,6 +107,12 @@ export declare const LIMITS: {
107
107
  autoDeploy: false | "declared";
108
108
  capabilities: CapabilityTier;
109
109
  };
110
+ readonly kosmos: {
111
+ memory: string;
112
+ dbMemory: string;
113
+ autoDeploy: false | "declared";
114
+ capabilities: CapabilityTier;
115
+ };
110
116
  readonly companyOther: {
111
117
  memory: string;
112
118
  dbMemory: string;
@@ -29,6 +29,7 @@ export const LIMITS = {
29
29
  calls: profile('1g', '512m'),
30
30
  agora: profile('1g', '512m'),
31
31
  boule: profile('512m'),
32
+ kosmos: profile('512m'),
32
33
  companyOther: profile('1g', '512m'),
33
34
  platformArchon: profile('1g'),
34
35
  platformAi: profile('1g'),
@@ -3905,6 +3905,9 @@ async function waitForMailInstance(url, runtime, sleep) {
3905
3905
  for (;;) {
3906
3906
  try {
3907
3907
  const response = await fetchImpl(url);
3908
+ // #760: an unread body left undici's parser paused when the socket
3909
+ // ended, throwing an uncaught assertion in the host.
3910
+ await response.body?.cancel();
3908
3911
  if (response.ok)
3909
3912
  return;
3910
3913
  }
@@ -7035,6 +7038,9 @@ async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
7035
7038
  const response = await fetchImpl(url, {
7036
7039
  signal: AbortSignal.timeout(runtime.readyRequestTimeoutMs ?? 10_000),
7037
7040
  });
7041
+ // #760: an unread body left undici's parser paused when the socket
7042
+ // ended, throwing an uncaught assertion in the host.
7043
+ await response.body?.cancel();
7038
7044
  if (response.ok)
7039
7045
  return;
7040
7046
  }
@@ -16,7 +16,7 @@ export declare function zitadelAppPostLogoutAppliedScopeKey(appKey: string): str
16
16
  /**
17
17
  * The post-logout addresses company app `appKey` is created with and kept at.
18
18
  * Agora signs out locally and has no signed-out page, so it holds only its
19
- * root; every `@wtfalch/auth` app (cms, boule, the app, entries with
19
+ * root; every `@wtfalch/auth` app (cms, boule, kosmos, the app, entries with
20
20
  * `uses.auth`) holds both on each of its hosts.
21
21
  */
22
22
  export declare function companyAppPostLogoutUris(org: DeclaredOrg, appKey: string): string[];
@@ -1,6 +1,7 @@
1
1
  import { agoraHostnameFor } from './agora.js';
2
2
  import { bouleHostnameFor } from './boule.js';
3
3
  import { appHostname, cmsStudioHostnameFor, entryHostnames } from './hostnames.js';
4
+ import { kosmosHostnameFor } from './kosmos.js';
4
5
  import { workspaceAuthEntryOf, workspaceClientHosts } from './workspace-client.js';
5
6
  /** The page `@wtfalch/auth` apps land on after ZITADEL ends the session. */
6
7
  export const SIGNED_OUT_PATH = '/auth/signed-out';
@@ -27,7 +28,7 @@ export function zitadelAppPostLogoutAppliedScopeKey(appKey) {
27
28
  /**
28
29
  * The post-logout addresses company app `appKey` is created with and kept at.
29
30
  * Agora signs out locally and has no signed-out page, so it holds only its
30
- * root; every `@wtfalch/auth` app (cms, boule, the app, entries with
31
+ * root; every `@wtfalch/auth` app (cms, boule, kosmos, the app, entries with
31
32
  * `uses.auth`) holds both on each of its hosts.
32
33
  */
33
34
  export function companyAppPostLogoutUris(org, appKey) {
@@ -44,6 +45,8 @@ export function companyAppPostLogoutUris(org, appKey) {
44
45
  ? cmsStudioHostnameFor(org)
45
46
  : appKey === 'boule'
46
47
  ? bouleHostnameFor(org)
47
- : appHostname(org);
48
+ : appKey === 'kosmos'
49
+ ? kosmosHostnameFor(org)
50
+ : appHostname(org);
48
51
  return postLogoutUrisFor(hosts ?? [hostname]);
49
52
  }