@wtfalch/foundry 0.33.1 → 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 (65) hide show
  1. package/dist/bootstrap/versions.d.ts +4 -0
  2. package/dist/calls-sfu.d.ts +10 -0
  3. package/dist/calls-sfu.js +41 -0
  4. package/dist/index.d.ts +14 -6
  5. package/dist/index.js +7 -3
  6. package/dist/migrations/0051_org_services.sql +13 -0
  7. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  8. package/dist/org.d.ts +18 -0
  9. package/dist/org.js +26 -0
  10. package/dist/plan/calls-matrix.d.ts +9 -0
  11. package/dist/plan/calls-matrix.js +17 -0
  12. package/dist/plan/cloudflared-connector.d.ts +7 -1
  13. package/dist/plan/cloudflared-connector.js +14 -1
  14. package/dist/plan/diff.d.ts +29 -0
  15. package/dist/plan/diff.js +259 -18
  16. package/dist/plan/executor.d.ts +112 -0
  17. package/dist/plan/executor.js +594 -33
  18. package/dist/plan/health-check-settings.d.ts +28 -0
  19. package/dist/plan/health-check-settings.js +36 -0
  20. package/dist/plan/health-gaps.d.ts +33 -0
  21. package/dist/plan/health-gaps.js +89 -0
  22. package/dist/plan/held-key-refs.d.ts +10 -0
  23. package/dist/plan/held-key-refs.js +10 -0
  24. package/dist/plan/index.d.ts +14 -6
  25. package/dist/plan/index.js +7 -3
  26. package/dist/plan/live.d.ts +5 -2
  27. package/dist/plan/live.js +15 -2
  28. package/dist/plan/locked-hostnames.d.ts +5 -2
  29. package/dist/plan/locked-hostnames.js +9 -1
  30. package/dist/plan/login-host.d.ts +62 -2
  31. package/dist/plan/login-host.js +104 -1
  32. package/dist/plan/mail-relay.d.ts +120 -0
  33. package/dist/plan/mail-relay.js +323 -0
  34. package/dist/plan/org-names.d.ts +24 -0
  35. package/dist/plan/org-names.js +52 -0
  36. package/dist/plan/own-diff.d.ts +169 -0
  37. package/dist/plan/own-diff.js +432 -0
  38. package/dist/plan/own-dns.d.ts +6 -0
  39. package/dist/plan/own-dns.js +15 -0
  40. package/dist/plan/own-limits.d.ts +22 -0
  41. package/dist/plan/own-limits.js +101 -0
  42. package/dist/plan/own-services.d.ts +193 -0
  43. package/dist/plan/own-services.js +167 -0
  44. package/dist/plan/own-sfu.d.ts +69 -0
  45. package/dist/plan/own-sfu.js +255 -0
  46. package/dist/plan/plan-apply.d.ts +78 -7
  47. package/dist/plan/plan-apply.js +205 -12
  48. package/dist/plan/platform-diff.d.ts +28 -0
  49. package/dist/plan/platform-diff.js +45 -3
  50. package/dist/plan/platform-org.d.ts +34 -0
  51. package/dist/plan/platform-org.js +94 -2
  52. package/dist/plan/platform-providers.d.ts +356 -8
  53. package/dist/plan/platform-providers.js +661 -353
  54. package/dist/plan/sign-in-org.d.ts +1 -1
  55. package/dist/plan/sign-in-org.js +20 -2
  56. package/dist/plan/zitadel-login-policy.js +1 -1
  57. package/dist/plan/zitadel-system-api.d.ts +22 -1
  58. package/dist/plan/zitadel-system-api.js +49 -8
  59. package/dist/platform.d.ts +8 -0
  60. package/dist/slug.js +1 -0
  61. package/dist/store/postgres.js +51 -2
  62. package/dist/store/schema.d.ts +51 -0
  63. package/dist/store/schema.js +5 -0
  64. package/package.json +1 -1
  65. package/versions.json +3 -1
@@ -1,12 +1,12 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
4
4
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
5
- 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';
6
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';
7
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';
8
8
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
9
- 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';
10
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';
11
11
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
12
12
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
@@ -21,12 +21,18 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
21
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';
22
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';
23
23
  import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
24
- import { healthCheckSettings } from './health-check-settings.js';
24
+ import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
25
25
  import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
26
- 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';
27
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';
28
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';
29
- 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';
30
36
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
31
37
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
32
38
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
@@ -633,11 +639,6 @@ const CHAT_SECRET_ENV_KEYS = {
633
639
  masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
634
640
  masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
635
641
  };
636
- /** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
637
- const CALLS_MAS_DOCKERFILE = [
638
- `FROM ${MAS_IMAGE}`,
639
- 'CMD ["server", "--config", "/config/config.yaml"]',
640
- ].join('\n');
641
642
  /**
642
643
  * `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
643
644
  * of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
@@ -665,6 +666,12 @@ const CALLS_HEALTH_CHECK_PATH = '/healthz';
665
666
  * {@link CALLS_HEALTH_CHECK_PATH} above.
666
667
  */
667
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;
668
675
  /**
669
676
  * Whether an application's own Coolify `status` (`` `running:healthy`,
670
677
  * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
@@ -755,6 +762,44 @@ export const databaseStartWait = {
755
762
  // ---------------------------------------------------------------------------
756
763
  // Executor
757
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
+ }
758
803
  export class FoundryStepExecutor {
759
804
  ports;
760
805
  edgeDns;
@@ -771,6 +816,85 @@ export class FoundryStepExecutor {
771
816
  secretsHandled() {
772
817
  return [...this.handledSecrets];
773
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
+ }
774
898
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
775
899
  ownEdgePorts() {
776
900
  return {
@@ -782,6 +906,13 @@ export class FoundryStepExecutor {
782
906
  };
783
907
  }
784
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) {
785
916
  const { org } = context;
786
917
  switch (`${action.system}:${action.kind}`) {
787
918
  case 'zitadel:issuer':
@@ -860,6 +991,18 @@ export class FoundryStepExecutor {
860
991
  return this.bouleBrokerEntry(org, context);
861
992
  case 'jobs:registration':
862
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);
863
1006
  default:
864
1007
  throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
865
1008
  }
@@ -2925,6 +3068,40 @@ export class FoundryStepExecutor {
2925
3068
  }
2926
3069
  }
2927
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
+ }
2928
3105
  /**
2929
3106
  * Deletes a just-minted project from inside a `catch`. A failed delete is
2930
3107
  * dropped so it never replaces the error being rethrown; the orphan is
@@ -2971,6 +3148,7 @@ export class FoundryStepExecutor {
2971
3148
  async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
2972
3149
  const appName = `${org.slug}-app`;
2973
3150
  const relayName = `${org.slug}-relay`;
3151
+ const authName = `${org.slug}-auth`;
2974
3152
  const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
2975
3153
  if (isPlatformApexDomain && platformLettermintProjectId) {
2976
3154
  if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
@@ -2982,7 +3160,7 @@ export class FoundryStepExecutor {
2982
3160
  ? await this.ports.lettermint.getDomain(existing.id)
2983
3161
  : await this.ports.lettermint.createDomain(mailDomain);
2984
3162
  const scopedProjectIds = allProjects
2985
- .filter((p) => p.name === appName || p.name === relayName)
3163
+ .filter((p) => p.name === appName || p.name === relayName || p.name === authName)
2986
3164
  .map((p) => p.id);
2987
3165
  if (isPlatformApexDomain &&
2988
3166
  platformLettermintProjectId &&
@@ -3411,12 +3589,14 @@ export class FoundryStepExecutor {
3411
3589
  description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
3412
3590
  instant_deploy: false,
3413
3591
  }));
3414
- if (!synapse.existed) {
3415
- await this.ports.coolify.updateApplication(synapse.appId, {
3416
- health_check_enabled: false,
3417
- custom_internal_name: names.synapse,
3418
- });
3419
- }
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
+ });
3420
3600
  context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
3421
3601
  // #251: only on create -- nothing here patches `docker_registry_image_tag`
3422
3602
  // on an app `findOrCreateCallsApp` found already running, so that is the
@@ -3431,14 +3611,14 @@ export class FoundryStepExecutor {
3431
3611
  server_uuid: server.uuid,
3432
3612
  environment_name: environment.name,
3433
3613
  environment_uuid: environment.uuid,
3434
- dockerfile: CALLS_MAS_DOCKERFILE,
3614
+ dockerfile: masDockerfile(),
3435
3615
  name: names.mas,
3436
3616
  description: `matrix-authentication-service for ${org.slug}'s calls stack`,
3437
3617
  instant_deploy: false,
3438
3618
  }));
3439
3619
  if (!mas.existed) {
3440
3620
  await this.ports.coolify.updateApplication(mas.appId, {
3441
- health_check_enabled: false,
3621
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3442
3622
  custom_internal_name: names.mas,
3443
3623
  });
3444
3624
  }
@@ -3875,7 +4055,7 @@ export class FoundryStepExecutor {
3875
4055
  server_uuid: server.uuid,
3876
4056
  environment_name: environment.name,
3877
4057
  environment_uuid: environment.uuid,
3878
- dockerfile: CALLS_MAS_DOCKERFILE,
4058
+ dockerfile: masDockerfile(),
3879
4059
  name: names.mas,
3880
4060
  description: `matrix-authentication-service for ${org.slug}'s chat`,
3881
4061
  instant_deploy: false,
@@ -3895,21 +4075,26 @@ export class FoundryStepExecutor {
3895
4075
  autogenerate_domain: false,
3896
4076
  }), { startIfStopped: false });
3897
4077
  // Fixed internal names, set on every run: the three apps and Agora
3898
- // address each other by them. Neither Synapse's nor MAS's health check
3899
- // can pass before the other is up, so both are off (the calls stack's own
3900
- // 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.
3901
4083
  await this.ports.coolify.updateApplication(synapse.appId, {
3902
4084
  custom_internal_name: names.synapse,
3903
- ...(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,
3904
4087
  });
3905
4088
  await this.ports.coolify.updateApplication(mas.appId, {
3906
4089
  custom_internal_name: names.mas,
3907
- ...(mas.existed ? {} : { health_check_enabled: false }),
4090
+ // Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
4091
+ ...IMAGE_HEALTHCHECK_SETTINGS,
3908
4092
  });
3909
4093
  await this.ports.coolify.updateApplication(service.appId, {
3910
4094
  custom_internal_name: names.service,
3911
- // No Coolify health check on the chat service (#543 review): its image
3912
- // 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)),
3913
4098
  });
3914
4099
  context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
3915
4100
  context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
@@ -4084,18 +4269,20 @@ export class FoundryStepExecutor {
4084
4269
  server_uuid: server.uuid,
4085
4270
  environment_name: environment.name,
4086
4271
  environment_uuid: environment.uuid,
4087
- dockerfile: CALLS_MAS_DOCKERFILE,
4272
+ dockerfile: masDockerfile(),
4088
4273
  name: names.mas,
4089
4274
  description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
4090
4275
  instant_deploy: false,
4091
4276
  }), { startIfStopped: false });
4092
4277
  await this.ports.coolify.updateApplication(synapse.appId, {
4093
4278
  custom_internal_name: names.synapse,
4094
- ...(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,
4095
4281
  });
4096
4282
  await this.ports.coolify.updateApplication(mas.appId, {
4097
4283
  custom_internal_name: names.mas,
4098
- ...(mas.existed ? {} : { health_check_enabled: false }),
4284
+ // Its Dockerfile's HEALTHCHECK (`masDockerfile`) is the live check.
4285
+ ...IMAGE_HEALTHCHECK_SETTINGS,
4099
4286
  });
4100
4287
  context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
4101
4288
  context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
@@ -4289,13 +4476,14 @@ export class FoundryStepExecutor {
4289
4476
  // step again for a live app whose marker is missing or stale, so an app
4290
4477
  // made before this existed gets its name here. The marker, recorded last,
4291
4478
  // tells `desiredAgoraEnv` that the name and the env are both in place.
4292
- // No Coolify health check on the calls service (#543 review): its image
4293
- // 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.
4294
4481
  // A found app may predate `ports_exposes` on create; patch a differing one.
4295
4482
  const portDiffers = app.existed &&
4296
4483
  (await this.ports.coolify.getApplication(app.appId)).ports_exposes !== CALLS_SERVICE_PORT;
4297
4484
  await this.ports.coolify.updateApplication(app.appId, {
4298
4485
  custom_internal_name: name,
4486
+ ...commandHealthCheckSettings(wgetProbe(CALLS_SERVICE_PORT, CALLS_HEALTH_CHECK_PATH)),
4299
4487
  ...(portDiffers ? { ports_exposes: CALLS_SERVICE_PORT } : {}),
4300
4488
  });
4301
4489
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
@@ -5022,6 +5210,349 @@ export class FoundryStepExecutor {
5022
5210
  }
5023
5211
  return port;
5024
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
+ }
5025
5556
  async jobsRegistration(action, org, context) {
5026
5557
  const scheduler = await this.jobsPort(org);
5027
5558
  const name = action.desired.name;
@@ -5759,6 +6290,11 @@ export class FoundryStepExecutor {
5759
6290
  * #491: when `mailDomain` is the platform's own apex, this must never
5760
6291
  * delete the platform's own project (the delete loop skips it by id) or
5761
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.
5762
6298
  */
5763
6299
  async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
5764
6300
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
@@ -5768,7 +6304,13 @@ export class FoundryStepExecutor {
5768
6304
  // #345: `mailOrg.domain` is absent for a level-1 org -- the previously
5769
6305
  // recorded mail domain is the one `MailPort.removeOrgRelay` needs.
5770
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());
5771
6311
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
6312
+ if (!ownSignIn)
6313
+ names.add(`${org.slug}-auth`);
5772
6314
  const projects = await this.ports.lettermint.listProjects();
5773
6315
  for (const project of projects) {
5774
6316
  if (names.has(project.name) && project.id !== platformLettermintProjectId) {
@@ -5777,6 +6319,8 @@ export class FoundryStepExecutor {
5777
6319
  }
5778
6320
  if (isPlatformApexOrg)
5779
6321
  return;
6322
+ if (ownSignIn && mailDomain.toLowerCase() === org.domain?.toLowerCase())
6323
+ return;
5780
6324
  const domain = await this.findExactLettermintDomain(mailDomain);
5781
6325
  if (domain)
5782
6326
  await this.ports.lettermint.deleteDomain(domain.id);
@@ -6779,6 +7323,23 @@ export class FoundryStepExecutor {
6779
7323
  case 'coolify:database':
6780
7324
  await this.ports.coolify.deleteDatabase(target.id);
6781
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
+ }
6782
7343
  case 'zitadel:app': {
6783
7344
  const zitadelOrgId = recordedIds.zitadel_org;
6784
7345
  const projectId = recordedIds.zitadel_project;