@wtfalch/foundry 0.32.0 → 0.34.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 (74) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +18 -1
  2. package/dist/bootstrap/bootstrap.js +167 -28
  3. package/dist/bootstrap/declare.d.ts +5 -1
  4. package/dist/bootstrap/declare.js +3 -1
  5. package/dist/bootstrap/index.d.ts +1 -1
  6. package/dist/bootstrap/versions.d.ts +4 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +41 -0
  9. package/dist/index.d.ts +18 -8
  10. package/dist/index.js +8 -3
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/org.d.ts +24 -2
  14. package/dist/org.js +42 -0
  15. package/dist/plan/boule.d.ts +24 -1
  16. package/dist/plan/boule.js +29 -1
  17. package/dist/plan/calls-matrix.d.ts +9 -0
  18. package/dist/plan/calls-matrix.js +17 -0
  19. package/dist/plan/cloudflared-connector.d.ts +7 -1
  20. package/dist/plan/cloudflared-connector.js +14 -1
  21. package/dist/plan/diff.d.ts +32 -1
  22. package/dist/plan/diff.js +383 -26
  23. package/dist/plan/executor.d.ts +177 -7
  24. package/dist/plan/executor.js +738 -51
  25. package/dist/plan/health-check-settings.d.ts +28 -0
  26. package/dist/plan/health-check-settings.js +36 -0
  27. package/dist/plan/health-gaps.d.ts +33 -0
  28. package/dist/plan/health-gaps.js +89 -0
  29. package/dist/plan/health-targets.js +1 -1
  30. package/dist/plan/held-key-refs.d.ts +10 -0
  31. package/dist/plan/held-key-refs.js +10 -0
  32. package/dist/plan/index.d.ts +16 -6
  33. package/dist/plan/index.js +8 -3
  34. package/dist/plan/live.d.ts +5 -2
  35. package/dist/plan/live.js +15 -2
  36. package/dist/plan/locked-hostnames.d.ts +5 -2
  37. package/dist/plan/locked-hostnames.js +9 -1
  38. package/dist/plan/login-host.d.ts +62 -2
  39. package/dist/plan/login-host.js +104 -1
  40. package/dist/plan/mail-relay.d.ts +120 -0
  41. package/dist/plan/mail-relay.js +323 -0
  42. package/dist/plan/org-names.d.ts +24 -0
  43. package/dist/plan/org-names.js +52 -0
  44. package/dist/plan/own-diff.d.ts +169 -0
  45. package/dist/plan/own-diff.js +432 -0
  46. package/dist/plan/own-dns.d.ts +6 -0
  47. package/dist/plan/own-dns.js +15 -0
  48. package/dist/plan/own-limits.d.ts +22 -0
  49. package/dist/plan/own-limits.js +101 -0
  50. package/dist/plan/own-services.d.ts +193 -0
  51. package/dist/plan/own-services.js +167 -0
  52. package/dist/plan/own-sfu.d.ts +69 -0
  53. package/dist/plan/own-sfu.js +255 -0
  54. package/dist/plan/plan-apply.d.ts +101 -7
  55. package/dist/plan/plan-apply.js +216 -12
  56. package/dist/plan/platform-diff.d.ts +61 -12
  57. package/dist/plan/platform-diff.js +140 -34
  58. package/dist/plan/platform-org.d.ts +36 -0
  59. package/dist/plan/platform-org.js +96 -2
  60. package/dist/plan/platform-providers.d.ts +407 -12
  61. package/dist/plan/platform-providers.js +947 -422
  62. package/dist/plan/sign-in-org.d.ts +99 -0
  63. package/dist/plan/sign-in-org.js +151 -0
  64. package/dist/plan/zitadel-login-policy.js +1 -1
  65. package/dist/plan/zitadel-system-api.d.ts +22 -1
  66. package/dist/plan/zitadel-system-api.js +49 -8
  67. package/dist/plan.d.ts +18 -0
  68. package/dist/platform.d.ts +8 -0
  69. package/dist/slug.js +1 -0
  70. package/dist/store/postgres.js +51 -2
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +5 -0
  73. package/package.json +1 -1
  74. package/versions.json +3 -1
@@ -1,11 +1,12 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
+ import { redactSecrets } from '../calls-sfu-deploy-support.js';
3
4
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
4
- import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
+ import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
5
6
  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, 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, entryClientIdScopeKey, } from './agora.js';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
+ import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
8
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
- import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
+ import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
10
  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';
10
11
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
12
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
@@ -20,13 +21,20 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
20
21
  import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
21
22
  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';
22
23
  import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
23
- import { healthCheckSettings } from './health-check-settings.js';
24
+ import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
24
25
  import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
25
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
26
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
26
27
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
28
+ import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY } from './mail-relay.js';
29
+ import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
30
+ import { limited } from './own-limits.js';
31
+ import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, scopeFromOrg, } from './own-services.js';
32
+ import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
33
+ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
27
34
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
28
- import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
35
+ import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
29
36
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
37
+ import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
30
38
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
31
39
  /**
32
40
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -51,7 +59,7 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
51
59
  */
52
60
  /** #569: the description foundry gives the Coolify project it creates for an org. */
53
61
  const COOLIFY_PROJECT_MARK_PREFIX = 'foundry org ';
54
- function coolifyProjectMark(org) {
62
+ export function coolifyProjectMark(org) {
55
63
  return `${COOLIFY_PROJECT_MARK_PREFIX}${org.id}`;
56
64
  }
57
65
  /**
@@ -631,11 +639,6 @@ const CHAT_SECRET_ENV_KEYS = {
631
639
  masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
632
640
  masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
633
641
  };
634
- /** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
635
- const CALLS_MAS_DOCKERFILE = [
636
- `FROM ${MAS_IMAGE}`,
637
- 'CMD ["server", "--config", "/config/config.yaml"]',
638
- ].join('\n');
639
642
  /**
640
643
  * `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
641
644
  * of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
@@ -663,6 +666,12 @@ const CALLS_HEALTH_CHECK_PATH = '/healthz';
663
666
  * {@link CALLS_HEALTH_CHECK_PATH} above.
664
667
  */
665
668
  const CHAT_SERVICE_HEALTH_CHECK_PATH = '/_chat/health';
669
+ /**
670
+ * Synapse's start period for its Coolify health check. It runs its database
671
+ * migrations before it listens, which is slower than the default start period
672
+ * of {@link healthCheckSettings} allows, and the image's own check waits 5 s.
673
+ */
674
+ const SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS = 120;
666
675
  /**
667
676
  * Whether an application's own Coolify `status` (`` `running:healthy`,
668
677
  * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
@@ -753,6 +762,44 @@ export const databaseStartWait = {
753
762
  // ---------------------------------------------------------------------------
754
763
  // Executor
755
764
  // ---------------------------------------------------------------------------
765
+ /** How long the host's bucket port may take to make or find a bucket. */
766
+ const BUCKET_TIMEOUT_MS = 60_000;
767
+ /** How long the keys worker may take to mint an own files host's credential. */
768
+ const KEYS_MINT_TIMEOUT_MS = 15_000;
769
+ /** How long one call to the R2 key port or the files route that stores the key may take. */
770
+ const R2_KEY_TIMEOUT_MS = 30_000;
771
+ /** Clears a recorded id; a context that cannot forget one records it empty, which reads as unset. */
772
+ function forgetScopeId(context, key) {
773
+ if (context.forgetId)
774
+ context.forgetId(key);
775
+ else
776
+ context.recordScopeId(key, '');
777
+ }
778
+ async function withTimeout(work, ms) {
779
+ let timer;
780
+ try {
781
+ return await Promise.race([
782
+ work,
783
+ new Promise((_, reject) => {
784
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
785
+ }),
786
+ ]);
787
+ }
788
+ finally {
789
+ clearTimeout(timer);
790
+ }
791
+ }
792
+ /** How long one Lettermint call of an own company's sign-in mint may take. */
793
+ const LETTERMINT_TIMEOUT_MS = 60_000;
794
+ const OWN_SERVICES_PORT_ABSENT = 'this org runs its own services and the host has no ownServices port for it';
795
+ /** A port that refuses every call, naming the held key the org's service waits for. */
796
+ function blockedPort(service, ref) {
797
+ return new Proxy({}, {
798
+ get: () => () => {
799
+ throw new OwnServicesBlockedError(service, ref);
800
+ },
801
+ });
802
+ }
756
803
  export class FoundryStepExecutor {
757
804
  ports;
758
805
  edgeDns;
@@ -761,9 +808,93 @@ export class FoundryStepExecutor {
761
808
  edgeRateLimits;
762
809
  edgeZones;
763
810
  edgeWriteSlot;
811
+ /** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
812
+ handledSecrets = new Set();
764
813
  constructor(ports) {
765
814
  this.ports = ports;
766
815
  }
816
+ secretsHandled() {
817
+ return [...this.handledSecrets];
818
+ }
819
+ /**
820
+ * An own org's step runs on a copy of this executor whose ZITADEL, mail,
821
+ * broker, files, jobs and calls ports are the org's own. A port the org has
822
+ * not got yet is absent or throws, never the shared one.
823
+ */
824
+ async executeScoped(action, context) {
825
+ const own = this.ports.ownServices;
826
+ if (!own)
827
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
828
+ const scoped = await own.portsFor(context.org, context.recordedIds());
829
+ if (!scoped)
830
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
831
+ const refs = orgTarget(context.org).refs;
832
+ const copy = new FoundryStepExecutor({
833
+ ...this.ports,
834
+ ownServices: undefined,
835
+ zitadel: scoped.zitadel ?? blockedPort('zitadel', refs.instanceBearer),
836
+ mail: scoped.mail ?? blockedPort('mail', refs.mailAdminKey),
837
+ callsSfu: scoped.callsSfu ?? blockedPort('calls', refs.livekitKeys),
838
+ broker: scoped.broker,
839
+ filesOrganisation: scoped.filesOrganisation ??
840
+ blockedPort('files', refs.filesManagement),
841
+ jobsScheduler: scoped.jobsScheduler ?? blockedPort('jobs', refs.jobsAdminToken),
842
+ callsSfuUrls: ownSfuUrls(context.recordedIds()),
843
+ });
844
+ try {
845
+ return await copy.run(action, context);
846
+ }
847
+ finally {
848
+ for (const secret of copy.secretsHandled())
849
+ this.handledSecrets.add(secret);
850
+ }
851
+ }
852
+ /** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
853
+ ownMailKeyMintable() {
854
+ return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
855
+ }
856
+ /** What the executor's own files dependencies supply, for the planner's blocked lines. */
857
+ ownFilesDeps() {
858
+ const files = this.ports.ownServices?.provision.files;
859
+ if (!files)
860
+ return { keys: false, bucket: false };
861
+ return {
862
+ keys: Boolean(files.keysMint),
863
+ bucket: Boolean(files.r2Buckets),
864
+ };
865
+ }
866
+ /** Which ports the own files storage key step needs this executor has, for the planner's blocked line. */
867
+ async ownFilesR2Ports(org, recordedIds) {
868
+ const r2Keys = Boolean(this.ports.ownServices?.provision.files.r2Keys);
869
+ // Only an own org whose files host is applied is asked for its scoped ports.
870
+ if (!ownsServices(org) || !recordedIds[`coolify:own-files:${org.id}`]) {
871
+ return { r2Keys, storeR2Credentials: false };
872
+ }
873
+ try {
874
+ const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
875
+ return {
876
+ r2Keys,
877
+ storeR2Credentials: typeof scoped?.filesOrganisation?.storeR2Credentials === 'function',
878
+ };
879
+ }
880
+ catch (e) {
881
+ if (e instanceof OwnServicesBlockedError)
882
+ return { r2Keys, storeR2Credentials: false };
883
+ throw e;
884
+ }
885
+ }
886
+ /** Whether the host's scoped ports for this own org include a files-organisation port. A held key still missing counts as no port. */
887
+ async ownFilesOrganisationPort(org, recordedIds) {
888
+ try {
889
+ const scoped = await this.ports.ownServices?.portsFor(org, recordedIds);
890
+ return Boolean(scoped?.filesOrganisation);
891
+ }
892
+ catch (e) {
893
+ if (e instanceof OwnServicesBlockedError)
894
+ return false;
895
+ throw e;
896
+ }
897
+ }
767
898
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
768
899
  ownEdgePorts() {
769
900
  return {
@@ -775,10 +906,21 @@ export class FoundryStepExecutor {
775
906
  };
776
907
  }
777
908
  async execute(action, context) {
909
+ const { org } = context;
910
+ if (ownsServices(org) && !action.kind.startsWith('own-')) {
911
+ return this.executeScoped(action, context);
912
+ }
913
+ return this.run(action, context);
914
+ }
915
+ async run(action, context) {
778
916
  const { org } = context;
779
917
  switch (`${action.system}:${action.kind}`) {
780
918
  case 'zitadel:issuer':
781
919
  return this.zitadelIssuer(action, org, context);
920
+ case 'zitadel:registration-closed':
921
+ return this.zitadelRegistrationClosed(org, context);
922
+ case 'zitadel:domain':
923
+ return this.zitadelDomain(action, org, context);
782
924
  case 'zitadel:owner':
783
925
  return this.zitadelOwner(action, org, context);
784
926
  case 'zitadel:login-host':
@@ -849,6 +991,18 @@ export class FoundryStepExecutor {
849
991
  return this.bouleBrokerEntry(org, context);
850
992
  case 'jobs:registration':
851
993
  return this.jobsRegistration(action, org, context);
994
+ case 'coolify:own-auth':
995
+ return this.ownAuth(action, org, context);
996
+ case 'coolify:own-jobs':
997
+ return this.ownJobs(action, org, context);
998
+ case 'coolify:own-mail':
999
+ return this.ownMail(action, org, context);
1000
+ case 'coolify:own-sfu':
1001
+ return this.ownSfu(action, org, context);
1002
+ case 'coolify:own-files':
1003
+ return this.ownFiles(action, org, context);
1004
+ case 'coolify:own-files-r2':
1005
+ return this.ownFilesR2(org, context);
852
1006
  default:
853
1007
  throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
854
1008
  }
@@ -1290,17 +1444,60 @@ export class FoundryStepExecutor {
1290
1444
  // their own design" -- not built in v1).
1291
1445
  throw new NotBuiltError(`zitadel:issuer move for ${org.slug}`, 'moving the issuer onto or off the org\'s own domain is not built in v1; see foundry\'s .claude/decisions-2026-09-24-executor.md, "Executor decisions"');
1292
1446
  }
1293
- const { org: zOrg } = await this.ports.zitadel.findOrCreateOrg(org.slug);
1294
- context.recordScopeId('zitadel_org', zOrg.id);
1295
- return { resourceId: zOrg.id };
1447
+ const { orgId } = await ensureSignInOrg({
1448
+ zitadel: this.ports.zitadel,
1449
+ name: org.slug,
1450
+ domain: signInOrgDomain(org),
1451
+ recordScopeId: (key, id) => context.recordScopeId(key, id),
1452
+ persistScopeIds: () => context.persistScopeIds(),
1453
+ registrationClosedScopeKey: ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
1454
+ });
1455
+ return { resourceId: orgId };
1456
+ }
1457
+ /**
1458
+ * `plan/diff.ts`'s `planOrgRegistrationClosed`: closes self-registration on
1459
+ * the recorded ZITADEL org, for an org made before foundry did this or whose
1460
+ * close failed in the issuer step. Recorded only after the close returned.
1461
+ */
1462
+ async zitadelRegistrationClosed(org, context) {
1463
+ const zitadelOrgId = context.recordedIds().zitadel_org;
1464
+ if (!zitadelOrgId) {
1465
+ throw new Error(`zitadel:registration-closed for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1466
+ }
1467
+ await closeSignInOrgRegistration(this.ports.zitadel, zitadelOrgId, (key, id) => context.recordScopeId(key, id));
1468
+ return {};
1469
+ }
1470
+ /**
1471
+ * `plan/diff.ts`'s `planOrgDomain`: registers the org's sign-in domain on
1472
+ * its recorded ZITADEL org and makes it primary, for an org whose ZITADEL
1473
+ * org was made before foundry did this. Records the domain only after both
1474
+ * calls succeeded.
1475
+ */
1476
+ async zitadelDomain(action, org, context) {
1477
+ const zitadelOrgId = context.recordedIds().zitadel_org;
1478
+ if (!zitadelOrgId) {
1479
+ throw new Error(`zitadel:domain for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1480
+ }
1481
+ const domain = action.desired.domain;
1482
+ // A refusal (the sign-in server's domain policy wants proof) is recorded and reported as
1483
+ // blocked by the planner; it does not fail every apply.
1484
+ const registered = await registerSignInDomainOrBlock(this.ports.zitadel, zitadelOrgId, domain, (key, id) => context.recordScopeId(key, id));
1485
+ if (registered)
1486
+ context.recordScopeId(ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
1487
+ return {};
1296
1488
  }
1297
1489
  /**
1298
- * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
1299
- * org by email. Never logs, returns or throws the email itself -- only the
1300
- * invited user id, handed back as `resourceId` so `runSteps` records it
1301
- * under `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1302
- * `plan()` call idempotent (`planOwner` plans nothing once that key is
1303
- * recorded).
1490
+ * `plan/diff.ts`'s `planOwners`: makes the owner's account in the org's
1491
+ * ZITADEL org and mails the first-sign-in link to their personal address.
1492
+ * The account's username is the local part of `mailboxAddress` and its email
1493
+ * is `mailboxAddress`, so the owner signs in as `name@<org domain>`; the
1494
+ * personal address is kept as the `personal_email` metadata. The invite code
1495
+ * ZITADEL returns goes only into that mail. Nothing here logs, returns or
1496
+ * throws the personal address or the code -- only the invited user id, handed
1497
+ * back as `resourceId` so `runSteps` records it under
1498
+ * `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1499
+ * `plan()` call idempotent. A failure after the account exists records
1500
+ * nothing: the next apply finds the account by username and mints a new code.
1304
1501
  */
1305
1502
  async zitadelOwner(action, org, context) {
1306
1503
  const zitadelOrgId = context.recordedIds().zitadel_org;
@@ -1313,11 +1510,31 @@ export class FoundryStepExecutor {
1313
1510
  if (!authIssuer) {
1314
1511
  throw new Error(`zitadel:owner for ${org.slug}: no invite template issuer -- refusing to send ZITADEL's default invite link, which 404s on this platform`);
1315
1512
  }
1316
- const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
1317
- givenName: action.desired.givenName,
1318
- familyName: action.desired.familyName,
1319
- email: action.desired.email,
1320
- }, `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`);
1513
+ const mailboxAddress = action.desired.mailboxAddress;
1514
+ if (!mailboxAddress) {
1515
+ throw new Error(`zitadel:owner for ${org.slug}: no mailboxAddress -- the planner reports this as blocked, so it should not reach the executor`);
1516
+ }
1517
+ const inviteMail = this.ports.ownerInviteMail;
1518
+ if (!inviteMail) {
1519
+ throw new Error(`zitadel:owner for ${org.slug}: no ownerInviteMail port -- the owner's work mailbox cannot be opened yet, so the invite is not sent there`);
1520
+ }
1521
+ // foundry's own record of this owner's account, so a returning owner is not mistaken for a stranger.
1522
+ const invitedBefore = context.recordedIds()[`zitadel:owner:${action.key}`];
1523
+ const { userId } = await inviteOwnerWithLink({
1524
+ zitadel: this.ports.zitadel,
1525
+ mail: inviteMail,
1526
+ orgId: zitadelOrgId,
1527
+ authIssuer,
1528
+ owner: {
1529
+ givenName: action.desired.givenName,
1530
+ familyName: action.desired.familyName,
1531
+ mailboxAddress,
1532
+ personalEmail: action.desired.email,
1533
+ },
1534
+ label: `zitadel:owner for ${org.slug}`,
1535
+ onSecret: (value) => this.handledSecrets.add(value),
1536
+ ...(invitedBefore ? { invitedUserId: invitedBefore } : {}),
1537
+ });
1321
1538
  // An owner invited after chat was built never reaches `coolifyChat`
1322
1539
  // again, so the role is granted here once the messaging project exists.
1323
1540
  const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
@@ -2493,6 +2710,23 @@ export class FoundryStepExecutor {
2493
2710
  // Last: a run that failed above plans the step again.
2494
2711
  context.recordScopeId(brokerEntryAppliedScopeKey(entryKey), JSON.stringify(want));
2495
2712
  }
2713
+ /**
2714
+ * `null` once the app already has `AUTHZ_PEER_PASSWORD_OPERATOR`: minted only
2715
+ * the first time and never rotated, like {@link ensureAuthCookieSecret}.
2716
+ */
2717
+ async ensurePeerPassword(appId) {
2718
+ return this.mintIfAbsent(appId, BOULE_PEER_PASSWORD_ENV, 24);
2719
+ }
2720
+ /** Same rule for the secret Archon's invite form sends to Boule. */
2721
+ async ensureBridgeSecret(appId) {
2722
+ return this.mintIfAbsent(appId, BOULE_BRIDGE_SECRET_ENV, 32);
2723
+ }
2724
+ async mintIfAbsent(appId, key, bytes) {
2725
+ const envs = await this.ports.coolify.listEnvs(appId);
2726
+ if (envs.some((e) => e.key === key && !e.is_preview))
2727
+ return null;
2728
+ return randomBytes(bytes).toString('base64url');
2729
+ }
2496
2730
  /**
2497
2731
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
2498
2732
  * only the first time (task brief), never regenerated or overwritten, so a
@@ -2834,6 +3068,40 @@ export class FoundryStepExecutor {
2834
3068
  }
2835
3069
  }
2836
3070
  }
3071
+ /**
3072
+ * The company's own sign-in mail token: a Lettermint project `<slug>-auth`
3073
+ * whose token goes to `orgTarget(org).refs.brokerMailKey`, where the broker
3074
+ * and ZITADEL's SMTP settings read it. The platform-wide Lettermint key
3075
+ * stays with the executor and never reaches a company. A held ref is left
3076
+ * alone. Otherwise the mint rule of {@link ensureOrgAppMailToken}: create
3077
+ * the project, restrict the domain, deliver its token, and only then
3078
+ * delete every other `<slug>-auth` project (a token cannot be read back,
3079
+ * so a project without a held token is a leftover). The restriction comes
3080
+ * before the delivery so a failure there leaves no held ref: the next run
3081
+ * starts over instead of skipping it. A failure before the delivery leaves
3082
+ * that project for the next run to delete. Every Lettermint call is
3083
+ * bounded; the token is registered for redaction before anything can throw
3084
+ * with it.
3085
+ */
3086
+ async ensureOwnAuthMailToken(org, mailDomain, deps) {
3087
+ const ref = orgTarget(org).refs.brokerMailKey;
3088
+ if (await deps.heldKeys?.resolve(ref))
3089
+ return;
3090
+ const lettermint = this.ports.lettermint;
3091
+ if (!lettermint || !deps.heldKeys)
3092
+ throw new OwnServicesBlockedError('lettermint', ref);
3093
+ const authName = `${org.slug}-auth`;
3094
+ const projects = await withTimeout(lettermint.listProjects(), LETTERMINT_TIMEOUT_MS);
3095
+ const created = await withTimeout(lettermint.createProject(authName), LETTERMINT_TIMEOUT_MS);
3096
+ deps.runtime?.onSecret?.(created.token);
3097
+ await withTimeout(this.ensureLettermintDomainRestriction(org, mailDomain, [...projects, created.project], null, null), LETTERMINT_TIMEOUT_MS);
3098
+ await deps.deliverSecret(ref, created.token);
3099
+ for (const project of projects) {
3100
+ if (project.name === authName && project.id !== created.project.id) {
3101
+ await withTimeout(lettermint.deleteProject(project.id), LETTERMINT_TIMEOUT_MS);
3102
+ }
3103
+ }
3104
+ }
2837
3105
  /**
2838
3106
  * Deletes a just-minted project from inside a `catch`. A failed delete is
2839
3107
  * dropped so it never replaces the error being rethrown; the orphan is
@@ -2880,6 +3148,7 @@ export class FoundryStepExecutor {
2880
3148
  async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
2881
3149
  const appName = `${org.slug}-app`;
2882
3150
  const relayName = `${org.slug}-relay`;
3151
+ const authName = `${org.slug}-auth`;
2883
3152
  const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
2884
3153
  if (isPlatformApexDomain && platformLettermintProjectId) {
2885
3154
  if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
@@ -2891,7 +3160,7 @@ export class FoundryStepExecutor {
2891
3160
  ? await this.ports.lettermint.getDomain(existing.id)
2892
3161
  : await this.ports.lettermint.createDomain(mailDomain);
2893
3162
  const scopedProjectIds = allProjects
2894
- .filter((p) => p.name === appName || p.name === relayName)
3163
+ .filter((p) => p.name === appName || p.name === relayName || p.name === authName)
2895
3164
  .map((p) => p.id);
2896
3165
  if (isPlatformApexDomain &&
2897
3166
  platformLettermintProjectId &&
@@ -3320,12 +3589,14 @@ export class FoundryStepExecutor {
3320
3589
  description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
3321
3590
  instant_deploy: false,
3322
3591
  }));
3323
- if (!synapse.existed) {
3324
- await this.ports.coolify.updateApplication(synapse.appId, {
3325
- health_check_enabled: false,
3326
- custom_internal_name: names.synapse,
3327
- });
3328
- }
3592
+ // The check goes on every run, so a Synapse made without one gets it. Synapse
3593
+ // is deployed before MAS here. Its `/health` answers with MAS unreachable
3594
+ // (shown with a throwaway Synapse of the same image, MAS endpoint a name that
3595
+ // does not resolve: 200, healthy), and the start period covers first-boot migrations.
3596
+ await this.ports.coolify.updateApplication(synapse.appId, {
3597
+ ...(synapse.existed ? {} : { custom_internal_name: names.synapse }),
3598
+ ...healthCheckSettings('/health', 8008, SYNAPSE_HEALTH_CHECK_START_PERIOD_SECONDS),
3599
+ });
3329
3600
  context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
3330
3601
  // #251: only on create -- nothing here patches `docker_registry_image_tag`
3331
3602
  // on an app `findOrCreateCallsApp` found already running, so that is the
@@ -3340,14 +3611,14 @@ export class FoundryStepExecutor {
3340
3611
  server_uuid: server.uuid,
3341
3612
  environment_name: environment.name,
3342
3613
  environment_uuid: environment.uuid,
3343
- dockerfile: CALLS_MAS_DOCKERFILE,
3614
+ dockerfile: masDockerfile(),
3344
3615
  name: names.mas,
3345
3616
  description: `matrix-authentication-service for ${org.slug}'s calls stack`,
3346
3617
  instant_deploy: false,
3347
3618
  }));
3348
3619
  if (!mas.existed) {
3349
3620
  await this.ports.coolify.updateApplication(mas.appId, {
3350
- health_check_enabled: false,
3621
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3351
3622
  custom_internal_name: names.mas,
3352
3623
  });
3353
3624
  }
@@ -3784,7 +4055,7 @@ export class FoundryStepExecutor {
3784
4055
  server_uuid: server.uuid,
3785
4056
  environment_name: environment.name,
3786
4057
  environment_uuid: environment.uuid,
3787
- dockerfile: CALLS_MAS_DOCKERFILE,
4058
+ dockerfile: masDockerfile(),
3788
4059
  name: names.mas,
3789
4060
  description: `matrix-authentication-service for ${org.slug}'s chat`,
3790
4061
  instant_deploy: false,
@@ -3804,21 +4075,26 @@ export class FoundryStepExecutor {
3804
4075
  autogenerate_domain: false,
3805
4076
  }), { startIfStopped: false });
3806
4077
  // Fixed internal names, set on every run: the three apps and Agora
3807
- // address each other by them. Neither Synapse's nor MAS's health check
3808
- // can pass before the other is up, so both are off (the calls stack's own
3809
- // rule).
4078
+ // address each other by them. Both are Dockerfile apps with a HEALTHCHECK
4079
+ // of their own (MAS's has a copied-in busybox, its image has no shell), so
4080
+ // Coolify's check is off and the image's is the live one. MAS is deployed
4081
+ // first and its `/health` needs only its database, so Synapse's check can
4082
+ // pass.
3810
4083
  await this.ports.coolify.updateApplication(synapse.appId, {
3811
4084
  custom_internal_name: names.synapse,
3812
- ...(synapse.existed ? {} : { health_check_enabled: false }),
4085
+ // Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
4086
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3813
4087
  });
3814
4088
  await this.ports.coolify.updateApplication(mas.appId, {
3815
4089
  custom_internal_name: names.mas,
3816
- ...(mas.existed ? {} : { health_check_enabled: false }),
4090
+ // Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
4091
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3817
4092
  });
3818
4093
  await this.ports.coolify.updateApplication(service.appId, {
3819
4094
  custom_internal_name: names.service,
3820
- // No Coolify health check on the chat service (#543 review): its image
3821
- // is node:22-alpine with no curl, which Coolify's container check runs.
4095
+ // A command, not Coolify's HTTP check: the image is node:22-alpine with
4096
+ // no curl, and its busybox `wget` tries `localhost` as `::1` first.
4097
+ ...commandHealthCheckSettings(wgetProbe(CHAT_SERVICE_PORT, CHAT_SERVICE_HEALTH_CHECK_PATH)),
3822
4098
  });
3823
4099
  context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
3824
4100
  context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
@@ -3993,18 +4269,20 @@ export class FoundryStepExecutor {
3993
4269
  server_uuid: server.uuid,
3994
4270
  environment_name: environment.name,
3995
4271
  environment_uuid: environment.uuid,
3996
- dockerfile: CALLS_MAS_DOCKERFILE,
4272
+ dockerfile: masDockerfile(),
3997
4273
  name: names.mas,
3998
4274
  description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
3999
4275
  instant_deploy: false,
4000
4276
  }), { startIfStopped: false });
4001
4277
  await this.ports.coolify.updateApplication(synapse.appId, {
4002
4278
  custom_internal_name: names.synapse,
4003
- ...(synapse.existed ? {} : { health_check_enabled: false }),
4279
+ // Its Dockerfile's HEALTHCHECK (curl on /health) is the live check; MAS is deployed first.
4280
+ ...IMAGE_HEALTHCHECK_SETTINGS,
4004
4281
  });
4005
4282
  await this.ports.coolify.updateApplication(mas.appId, {
4006
4283
  custom_internal_name: names.mas,
4007
- ...(mas.existed ? {} : { health_check_enabled: false }),
4284
+ // Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
4285
+ ...IMAGE_HEALTHCHECK_SETTINGS,
4008
4286
  });
4009
4287
  context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
4010
4288
  context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
@@ -4198,13 +4476,14 @@ export class FoundryStepExecutor {
4198
4476
  // step again for a live app whose marker is missing or stale, so an app
4199
4477
  // made before this existed gets its name here. The marker, recorded last,
4200
4478
  // tells `desiredAgoraEnv` that the name and the env are both in place.
4201
- // No Coolify health check on the calls service (#543 review): its image
4202
- // is node:24-alpine with no curl, which Coolify's container check runs.
4479
+ // A command, not Coolify's HTTP check: the image is node:24-alpine with no
4480
+ // curl, and its busybox `wget` tries `localhost` as `::1` first.
4203
4481
  // A found app may predate `ports_exposes` on create; patch a differing one.
4204
4482
  const portDiffers = app.existed &&
4205
4483
  (await this.ports.coolify.getApplication(app.appId)).ports_exposes !== CALLS_SERVICE_PORT;
4206
4484
  await this.ports.coolify.updateApplication(app.appId, {
4207
4485
  custom_internal_name: name,
4486
+ ...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
4208
4487
  ...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
4209
4488
  });
4210
4489
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
@@ -4707,7 +4986,29 @@ export class FoundryStepExecutor {
4707
4986
  const appKey = await this.ensureAuthAppKey(app.appId);
4708
4987
  if (appKey)
4709
4988
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
4710
- await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
4989
+ // Archon's way into this Boule's people store: only the platform's own
4990
+ // org's Boule gets it, minted once and never rotated (the role Boule's boot
4991
+ // creates from it would otherwise keep the old password).
4992
+ const peerPassword = action.desired.platformOwner === true ? await this.ensurePeerPassword(app.appId) : null;
4993
+ if (peerPassword) {
4994
+ this.handledSecrets.add(peerPassword);
4995
+ envs.push({ key: BOULE_PEER_PASSWORD_ENV, value: peerPassword });
4996
+ }
4997
+ // Archon's invite form calls Boule with this secret; same rule as the password.
4998
+ const bridgeSecret = action.desired.platformOwner === true ? await this.ensureBridgeSecret(app.appId) : null;
4999
+ if (bridgeSecret) {
5000
+ this.handledSecrets.add(bridgeSecret);
5001
+ envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
5002
+ }
5003
+ try {
5004
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
5005
+ }
5006
+ catch (error) {
5007
+ const minted = [peerPassword, bridgeSecret].filter((v) => v !== null);
5008
+ if (minted.length === 0)
5009
+ throw error;
5010
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
5011
+ }
4711
5012
  const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
4712
5013
  if (!dbPush.pushed) {
4713
5014
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
@@ -4723,6 +5024,8 @@ export class FoundryStepExecutor {
4723
5024
  // that a previous apply created but crashed before deploying) sat built
4724
5025
  // but never started. Deployed once this executor has confirmed, never
4725
5026
  // again -- a live app is only ever restarted from here on.
5027
+ const secretsNotRestarted = action.desired.platformOwner === true &&
5028
+ !context.recordedIds()[BOULE_SECRETS_RESTARTED_SCOPE_KEY];
4726
5029
  if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
4727
5030
  // #543: every deploy waits for health -- Boule's own public hostname.
4728
5031
  const runtime = this.ports.runtime ?? {};
@@ -4731,10 +5034,21 @@ export class FoundryStepExecutor {
4731
5034
  }
4732
5035
  else if (app.existed &&
4733
5036
  !mail.restarted &&
4734
- (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
5037
+ (envChanged ||
5038
+ cookieSecret !== null ||
5039
+ appKey !== null ||
5040
+ peerPassword !== null ||
5041
+ bridgeSecret !== null ||
5042
+ secretsNotRestarted ||
5043
+ dbPush.changed ||
5044
+ mail.changed)) {
4735
5045
  // A restart inside the mail rule already came after every env write above.
4736
5046
  await this.ports.coolify.restart(app.appId);
4737
5047
  }
5048
+ // Last, after the restart or deploy: a run that failed before here repeats it, minting nothing.
5049
+ if (action.desired.platformOwner === true) {
5050
+ context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
5051
+ }
4738
5052
  return { resourceId: app.appId };
4739
5053
  }
4740
5054
  /**
@@ -4896,6 +5210,349 @@ export class FoundryStepExecutor {
4896
5210
  }
4897
5211
  return port;
4898
5212
  }
5213
+ /**
5214
+ * `plan/own-diff.ts`'s `planOwnJobs`: the org's own scheduler, in the org's
5215
+ * own project, with the names and the held-key ref of `orgTarget(org)`. The
5216
+ * admin token goes to `jobs:org:<slug>:adminToken` and nowhere else.
5217
+ */
5218
+ async ownJobs(action, org, context) {
5219
+ const own = this.ports.ownServices;
5220
+ if (!own)
5221
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5222
+ return provisionJobsHost(scopeFromOrg(context, {}), {
5223
+ coolify: limited(own.provision.jobs),
5224
+ deliverSecret: this.ports.deliverSecret,
5225
+ readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
5226
+ runtime: this.ports.runtime ?? {},
5227
+ }, orgTarget(org), {
5228
+ hostname: null,
5229
+ gitRef: action.desired.gitRef,
5230
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5231
+ });
5232
+ }
5233
+ /**
5234
+ * `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
5235
+ * org's own project, trusting the company's issuer `https://auth.<domain>`.
5236
+ * Its ZITADEL project `<slug>-files` and introspection app are made in the
5237
+ * company's instance (`ownServices.zitadelFor`), never the shared one. The
5238
+ * bucket comes from the host's bucket port under the target's name. The
5239
+ * management key goes to `files:org:<slug>:managementKey` and the
5240
+ * introspection secret to `zitadel:org:<slug>:files:introspection`, both
5241
+ * once, and neither is recorded or returned.
5242
+ */
5243
+ async ownFiles(action, org, context) {
5244
+ const own = this.ports.ownServices;
5245
+ if (!own)
5246
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5247
+ const at = `coolify:own-files for ${org.slug}`;
5248
+ const domain = org.domain;
5249
+ if (!domain)
5250
+ throw new Error(`${at}: the org declares no domain`);
5251
+ const zitadelOrgId = context.recordedIds()[ZITADEL_ORG_SCOPE_KEY];
5252
+ if (!zitadelOrgId)
5253
+ throw new Error(`${at}: the org's ZITADEL organisation is not recorded yet`);
5254
+ const { r2Buckets, r2Keys: _r2Keys, keysMint, readSecret, ...ownFiles } = own.provision.files;
5255
+ const files = { ...ownFiles, coolify: limited(ownFiles.coolify) };
5256
+ // Never the platform's credential in its place.
5257
+ if (!keysMint)
5258
+ throw new OwnServicesBlockedError('files', 'keysMint');
5259
+ if (!r2Buckets)
5260
+ throw new Error(`${at}: the host supplies no bucket port`);
5261
+ const target = orgTarget(org);
5262
+ let r2;
5263
+ try {
5264
+ r2 = await withTimeout(r2Buckets.ensureBucket(target.names.filesBucket), BUCKET_TIMEOUT_MS);
5265
+ }
5266
+ catch {
5267
+ // The cause is dropped: a host port's message is not ours to repeat.
5268
+ throw new Error(`${at}: the bucket ${target.names.filesBucket} could not be ensured`);
5269
+ }
5270
+ const issuer = `https://auth.${domain}`;
5271
+ const deps = {
5272
+ ...files,
5273
+ // The platform's keys settings stay out of a company's containers; the minted keyring replaces them.
5274
+ keys: undefined,
5275
+ jobsSigningSecrets: undefined,
5276
+ r2,
5277
+ zitadel: own.zitadelFor({ issuerUrl: issuer, bearerRef: target.refs.instanceBearer }),
5278
+ zitadelForInstance: undefined,
5279
+ deliverSecret: this.ports.deliverSecret,
5280
+ runtime: {
5281
+ ...files.runtime,
5282
+ onSecret: (value) => {
5283
+ this.handledSecrets.add(value);
5284
+ },
5285
+ },
5286
+ };
5287
+ const invalid = filesDepsInvalid(deps);
5288
+ if (invalid)
5289
+ throw new Error(`${at}: ${invalid}`);
5290
+ const scope = scopeFromOrg(context, {});
5291
+ const mintOwn = async () => {
5292
+ try {
5293
+ return await withTimeout(keysMint.mint(`files-${org.slug}`, randomUUID()), KEYS_MINT_TIMEOUT_MS);
5294
+ }
5295
+ catch (error) {
5296
+ // The cause is dropped: a mint client's message is not ours to repeat.
5297
+ throw new Error(error instanceof Error && error.message === 'timeout'
5298
+ ? `${at}: the keys mint did not answer within ${KEYS_MINT_TIMEOUT_MS / 1000} seconds`
5299
+ : `${at}: the keys mint failed`);
5300
+ }
5301
+ };
5302
+ const { keys, guardId } = await resolveOwnFilesKeys(scope, {
5303
+ deliverSecret: deps.deliverSecret,
5304
+ readSecret: readSecret ?? (async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null),
5305
+ runtime: deps.runtime,
5306
+ }, target, mintOwn, keysMint.workerUrl, `own step ${at}`);
5307
+ return provisionFilesHost(scope, { ...deps, keys }, target, {
5308
+ keysGuardId: guardId,
5309
+ hostname: action.desired.hostname,
5310
+ gitRef: action.desired.gitRef,
5311
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5312
+ issuer,
5313
+ zitadelOrg: { id: zitadelOrgId },
5314
+ zitadelProject: {
5315
+ name: `${org.slug}-files`,
5316
+ scopeKey: ZITADEL_FILES_PROJECT_SCOPE_KEY,
5317
+ },
5318
+ shareDomains: filesShareDomains([org]),
5319
+ bootstrapOperator: action.desired.bootstrapOperator,
5320
+ });
5321
+ }
5322
+ /**
5323
+ * `plan/own-diff.ts`'s `planOwnFilesR2`: the storage key of the org's own files bucket. A token
5324
+ * recorded as undelivered is deleted first (its secret cannot be read back, so it can never be
5325
+ * finished), then one is minted, registered as a secret, recorded by id as undelivered and
5326
+ * persisted, and only then sent to the files host, the same pair on every retry. The secret is
5327
+ * never recorded and never written to a Coolify env. The id is recorded once the host has it.
5328
+ */
5329
+ async ownFilesR2(org, context) {
5330
+ const own = this.ports.ownServices;
5331
+ if (!own)
5332
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5333
+ const at = `coolify:own-files-r2 for ${org.slug}`;
5334
+ const { r2Keys, runtime } = own.provision.files;
5335
+ if (!r2Keys)
5336
+ throw new OwnServicesBlockedError('files', 'r2Keys');
5337
+ const scoped = await own.portsFor(org, context.recordedIds());
5338
+ const organisation = scoped?.filesOrganisation;
5339
+ if (!organisation?.storeR2Credentials) {
5340
+ throw new Error(`${at}: the host gave the company no storeR2Credentials on its files port`);
5341
+ }
5342
+ const store = organisation.storeR2Credentials.bind(organisation);
5343
+ const bucket = orgTarget(org).names.filesBucket;
5344
+ const timeoutText = `did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`;
5345
+ const failure = (what, error) =>
5346
+ // The cause is dropped: a port's message is not ours to repeat, and may hold the secret.
5347
+ new Error(`${at}: ${what} ${error instanceof Error && error.message === 'timeout' ? timeoutText : 'failed'}`);
5348
+ const recorded = context.recordedIds();
5349
+ if (recorded[OWN_FILES_R2_SCOPE_KEY])
5350
+ return { resourceId: recorded[OWN_FILES_R2_SCOPE_KEY] };
5351
+ // A leftover token is deleted before anything is minted: never two live tokens.
5352
+ const leftover = recorded[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
5353
+ if (leftover) {
5354
+ try {
5355
+ await withTimeout(r2Keys.deleteBucketKey(leftover), R2_KEY_TIMEOUT_MS);
5356
+ }
5357
+ catch (error) {
5358
+ if (!isNotFoundError(error)) {
5359
+ throw failure(`deleting the earlier storage key ${leftover}, so no new one was made:`, error);
5360
+ }
5361
+ }
5362
+ forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
5363
+ await context.persistScopeIds();
5364
+ }
5365
+ let key;
5366
+ try {
5367
+ key = await withTimeout(r2Keys.mintBucketKey(bucket), R2_KEY_TIMEOUT_MS);
5368
+ }
5369
+ catch (error) {
5370
+ throw failure('the storage key mint', error);
5371
+ }
5372
+ const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200;
5373
+ if (text(key?.secretAccessKey))
5374
+ this.handledSecrets.add(key.secretAccessKey);
5375
+ // A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
5376
+ if (text(key?.tokenId)) {
5377
+ context.recordScopeId(OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, key.tokenId);
5378
+ await context.persistScopeIds();
5379
+ }
5380
+ if (!text(key?.accessKeyId) || !text(key?.secretAccessKey) || !text(key?.tokenId)) {
5381
+ throw new Error(`${at}: the storage key mint answered with an unusable key`);
5382
+ }
5383
+ const pair = {
5384
+ accessKeyId: key.accessKeyId,
5385
+ secretAccessKey: key.secretAccessKey,
5386
+ tokenId: key.tokenId,
5387
+ };
5388
+ const attempts = runtime?.deliverAttempts ?? 3;
5389
+ const sleep = runtime?.sleep ?? defaultSleep;
5390
+ for (let attempt = 1;; attempt += 1) {
5391
+ try {
5392
+ await withTimeout(store({ ...pair }), R2_KEY_TIMEOUT_MS);
5393
+ break;
5394
+ }
5395
+ catch (error) {
5396
+ if (attempt >= attempts) {
5397
+ const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
5398
+ pair.secretAccessKey,
5399
+ pair.accessKeyId,
5400
+ ]);
5401
+ throw new Error(`${at}: the storage key ${pair.tokenId} could not be delivered after ${attempts} tries; the next run deletes it and makes a new one (last try: ${cause})`);
5402
+ }
5403
+ await sleep(1000 * attempt);
5404
+ }
5405
+ }
5406
+ context.recordScopeId(OWN_FILES_R2_SCOPE_KEY, pair.tokenId);
5407
+ forgetScopeId(context, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY);
5408
+ await context.persistScopeIds();
5409
+ return { resourceId: pair.tokenId };
5410
+ }
5411
+ /**
5412
+ * `plan/own-diff.ts`'s `planOwnAuth`: the org's own sign-in at
5413
+ * `auth.<domain>`, in the org's own project, through the org's own edge
5414
+ * routed DNS and tunnel ports. The instance owner token goes to
5415
+ * `zitadel:org:<slug>:instanceBearer` and is never recorded. A changed
5416
+ * declared auth ref patches both git apps' branch and redeploys them; the
5417
+ * applied marker comes last, so a run that died halfway is planned again.
5418
+ */
5419
+ async ownAuth(action, org, context) {
5420
+ const own = this.ports.ownServices;
5421
+ if (!own)
5422
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5423
+ const hostname = action.desired.hostname;
5424
+ const gitRef = action.desired.gitRef;
5425
+ const domain = org.domain;
5426
+ if (!domain)
5427
+ throw new Error(`coolify:own-auth for ${org.slug}: the org declares no domain`);
5428
+ const scope = scopeFromOrg(context, {});
5429
+ // The target (host, port, tls, user) is not secret; the platform's SMTP
5430
+ // password is, so the company's own token is the password instead.
5431
+ const { smtp, ...authDeps } = own.provision.auth;
5432
+ const deps = {
5433
+ ...authDeps,
5434
+ ...(smtp
5435
+ ? { smtp: { host: smtp.host, port: smtp.port, tls: smtp.tls, user: smtp.user } }
5436
+ : {}),
5437
+ coolify: limited(own.provision.auth.coolify),
5438
+ runtime: {
5439
+ ...own.provision.auth.runtime,
5440
+ onSecret: (value) => {
5441
+ this.handledSecrets.add(value);
5442
+ },
5443
+ },
5444
+ };
5445
+ const before = scope.recordedIds();
5446
+ const refChanged = before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== undefined &&
5447
+ before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
5448
+ const gitApps = [
5449
+ before[PLATFORM_AUTH_LOGIN_SCOPE_KEY],
5450
+ before[PLATFORM_AUTH_BROKER_SCOPE_KEY],
5451
+ ].filter((id) => Boolean(id));
5452
+ if (refChanged) {
5453
+ for (const id of gitApps)
5454
+ await deps.coolify.updateApplication(id, { git_branch: gitRef });
5455
+ }
5456
+ await this.ensureOwnAuthMailToken(org, domain, deps);
5457
+ const result = await provisionAuthStack(scope, deps, orgTarget(org), {
5458
+ hostname,
5459
+ apex: domain,
5460
+ gitRef,
5461
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5462
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
5463
+ smtpSender: `noreply@${domain}`,
5464
+ defaultRedirectUri: `https://agora.${domain}`,
5465
+ ownerCompany: null,
5466
+ });
5467
+ if (refChanged) {
5468
+ const now = scope.recordedIds();
5469
+ const brokerDeployed = Boolean(now[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
5470
+ for (const id of gitApps) {
5471
+ // The broker crash-loops until its inputs exist; it was never deployed, so it is not now.
5472
+ if (id === now[PLATFORM_AUTH_BROKER_SCOPE_KEY] && !brokerDeployed)
5473
+ continue;
5474
+ await deps.coolify.deploy(id, true);
5475
+ }
5476
+ }
5477
+ scope.recordScopeId(OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5478
+ scope.recordScopeId(OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5479
+ scope.recordScopeId(OWN_SERVICES_APPLIED_SCOPE_KEY, org.id);
5480
+ await scope.persist();
5481
+ return result;
5482
+ }
5483
+ /**
5484
+ * `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
5485
+ * project, with no host port (the shared relay reaches it by internal name)
5486
+ * and a directory that trusts the org's own sign-in. The admin key goes to
5487
+ * `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
5488
+ * routes to is recorded last, and the step's marker after it.
5489
+ */
5490
+ async ownMail(action, org, context) {
5491
+ const own = this.ports.ownServices;
5492
+ if (!own)
5493
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5494
+ if (!org.domain)
5495
+ throw new Error(`own step coolify:own-mail for ${org.slug}: the org has no domain`);
5496
+ const target = orgTarget(org);
5497
+ const scope = scopeFromOrg(context, {});
5498
+ const issuerUrl = `https://auth.${org.domain}`;
5499
+ const result = await provisionMailServer(scope, {
5500
+ ...own.provision.mail,
5501
+ coolify: limited(own.provision.mail.coolify),
5502
+ deliverSecret: (ref, secret) => {
5503
+ this.handledSecrets.add(secret);
5504
+ return this.ports.deliverSecret(ref, secret);
5505
+ },
5506
+ stalwartForInstance: (input) => {
5507
+ this.handledSecrets.add(input.password);
5508
+ return (own.provision.mail.stalwartForInstance ?? defaultStalwartForInstance)(input);
5509
+ },
5510
+ zitadel: own.zitadelFor({ issuerUrl, bearerRef: target.refs.instanceBearer }),
5511
+ zitadelForInstance: undefined,
5512
+ }, target, {
5513
+ hostname: action.desired.hostname,
5514
+ apex: org.domain,
5515
+ mailHost: action.desired.mailHost,
5516
+ imageTag: action.desired.gitRef,
5517
+ hostPorts: '',
5518
+ issuerUrl,
5519
+ mxRecord: null,
5520
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5521
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:mail`),
5522
+ });
5523
+ scope.recordScopeId(OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, `${target.internal.mail}:25`);
5524
+ await scope.persist();
5525
+ return result;
5526
+ }
5527
+ /**
5528
+ * `plan/own-diff.ts`'s `planOwnSfu`: the org's own LiveKit server in the
5529
+ * org's project, on the ports of its slot. The company's key pair goes to
5530
+ * `livekit:org:<slug>:keys` and the app's env, nowhere else.
5531
+ */
5532
+ async ownSfu(action, org, context) {
5533
+ const own = this.ports.ownServices;
5534
+ if (!own)
5535
+ throw new Error(OWN_SERVICES_PORT_ABSENT);
5536
+ const scope = scopeFromOrg(context, {});
5537
+ const { sfu } = own.provision;
5538
+ return provisionSfuApp(scope, {
5539
+ coolify: limited(sfu.coolify),
5540
+ network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:sfu`),
5541
+ deliverSecret: sfu.deliverSecret,
5542
+ readSecret: async (ref) => (await this.ports.heldKeys?.resolve(ref)) ?? null,
5543
+ runtime: this.ports.runtime ?? {},
5544
+ ...(sfu.mintLivekitCredentials
5545
+ ? { mintLivekitCredentials: sfu.mintLivekitCredentials }
5546
+ : {}),
5547
+ onSecret: (value) => this.handledSecrets.add(value),
5548
+ }, orgTarget(org), {
5549
+ slot: action.desired.slot,
5550
+ hostname: action.desired.hostname,
5551
+ apex: org.domain,
5552
+ version: action.desired.version,
5553
+ project: { ensure: () => this.ensureCoolifyProject(org, context) },
5554
+ });
5555
+ }
4899
5556
  async jobsRegistration(action, org, context) {
4900
5557
  const scheduler = await this.jobsPort(org);
4901
5558
  const name = action.desired.name;
@@ -5633,6 +6290,11 @@ export class FoundryStepExecutor {
5633
6290
  * #491: when `mailDomain` is the platform's own apex, this must never
5634
6291
  * delete the platform's own project (the delete loop skips it by id) or
5635
6292
  * the platform's own domain (returns before the domain lookup/delete).
6293
+ *
6294
+ * An org whose own sign-in is applied keeps its `<slug>-auth` project, and
6295
+ * keeps the Lettermint domain when that is the domain the sign-in sends
6296
+ * from. Every other org deletes `<slug>-auth` too, which only a failed
6297
+ * first sign-in run can have left behind.
5636
6298
  */
5637
6299
  async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
5638
6300
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
@@ -5642,7 +6304,13 @@ export class FoundryStepExecutor {
5642
6304
  // #345: `mailOrg.domain` is absent for a level-1 org -- the previously
5643
6305
  // recorded mail domain is the one `MailPort.removeOrgRelay` needs.
5644
6306
  await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
6307
+ // An own org's sign-in sends its mail through `<slug>-auth` and the domain it
6308
+ // applied at; parking the org's mail leaves both, or the sign-in can no
6309
+ // longer send (its held token would still resolve, so nothing re-mints it).
6310
+ const ownSignIn = ownsServices(org) && Boolean(org.domain) && ownAuthApplied(org, context.recordedIds());
5645
6311
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
6312
+ if (!ownSignIn)
6313
+ names.add(`${org.slug}-auth`);
5646
6314
  const projects = await this.ports.lettermint.listProjects();
5647
6315
  for (const project of projects) {
5648
6316
  if (names.has(project.name) && project.id !== platformLettermintProjectId) {
@@ -5651,6 +6319,8 @@ export class FoundryStepExecutor {
5651
6319
  }
5652
6320
  if (isPlatformApexOrg)
5653
6321
  return;
6322
+ if (ownSignIn && mailDomain.toLowerCase() === org.domain?.toLowerCase())
6323
+ return;
5654
6324
  const domain = await this.findExactLettermintDomain(mailDomain);
5655
6325
  if (domain)
5656
6326
  await this.ports.lettermint.deleteDomain(domain.id);
@@ -6653,6 +7323,23 @@ export class FoundryStepExecutor {
6653
7323
  case 'coolify:database':
6654
7324
  await this.ports.coolify.deleteDatabase(target.id);
6655
7325
  return;
7326
+ case 'coolify:own-files-r2': {
7327
+ // An own files bucket's storage key; a 404 is caught below as already gone.
7328
+ const r2Keys = this.ports.ownServices?.provision.files.r2Keys;
7329
+ if (!r2Keys) {
7330
+ throw new Error(`coolify:own-files-r2 for ${org.slug}: this host supplies no R2 key port, cannot delete token ${target.id}`);
7331
+ }
7332
+ try {
7333
+ await withTimeout(r2Keys.deleteBucketKey(target.id), R2_KEY_TIMEOUT_MS);
7334
+ }
7335
+ catch (error) {
7336
+ if (error instanceof Error && error.message === 'timeout') {
7337
+ throw new Error(`coolify:own-files-r2 for ${org.slug}: deleting token ${target.id} did not answer within ${R2_KEY_TIMEOUT_MS / 1000} seconds`);
7338
+ }
7339
+ throw error;
7340
+ }
7341
+ return;
7342
+ }
6656
7343
  case 'zitadel:app': {
6657
7344
  const zitadelOrgId = recordedIds.zitadel_org;
6658
7345
  const projectId = recordedIds.zitadel_project;