@wtfalch/foundry 0.33.1 → 0.35.1

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 (106) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -1,25 +1,46 @@
1
+ import { ownsServices } from '../org.js';
1
2
  import { agoraHostnameFor } from './agora.js';
2
3
  import { bouleHostnameFor } from './boule.js';
4
+ import { cmsStudioHostnameFor, siteHostnameFor } from './hostnames.js';
3
5
  import { loginHostnameFor } from './login-host.js';
6
+ import { ownHostnames } from './own-hostnames.js';
7
+ /** The labels an own org's services answer on, so no hosting entry may take them. */
8
+ export const OWN_SERVICE_LABELS = ['auth', 'mail', 'files', 'sfu'];
4
9
  /**
5
10
  * The names under the org's domain that foundry owns, so no hosting entry may
6
11
  * take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
7
- * domain, the sign-in host when the issuer does, and every platform service
8
- * hostname that falls under the domain. Empty for an org with no domain.
12
+ * domain, the sign-in host when the issuer does, `auth.`, `mail.`, `files.` and
13
+ * `sfu.` for an org that runs its own services, and every platform service
14
+ * hostname that falls under the domain. An own org with no domain holds its
15
+ * oikos.host service names; any other org with no domain holds none.
9
16
  */
10
17
  export function lockedHostnames(org, platformHostnames) {
11
18
  const { domain } = org;
12
19
  const locked = new Set();
13
- if (!domain)
20
+ if (!domain) {
21
+ if (ownsServices(org)) {
22
+ const own = ownHostnames(org);
23
+ for (const hostname of [own.auth, own.mail, own.files, own.sfu])
24
+ locked.add(hostname);
25
+ }
26
+ if (org.site.enabled) {
27
+ locked.add(siteHostnameFor(org));
28
+ locked.add(cmsStudioHostnameFor(org));
29
+ }
14
30
  return locked;
31
+ }
15
32
  locked.add(agoraHostnameFor(org));
16
33
  locked.add(bouleHostnameFor(org));
17
34
  if (org.site.enabled)
18
- locked.add(`cms.${domain}`);
35
+ locked.add(cmsStudioHostnameFor(org));
19
36
  if (org.calls.enabled && org.calls.usesDomain)
20
37
  locked.add(`calls.${domain}`);
21
38
  if (org.issuer.usesDomain)
22
39
  locked.add(loginHostnameFor(org));
40
+ if (ownsServices(org)) {
41
+ for (const label of OWN_SERVICE_LABELS)
42
+ locked.add(`${label}.${domain}`);
43
+ }
23
44
  for (const hostname of platformHostnames) {
24
45
  if (hostname.endsWith(`.${domain}`))
25
46
  locked.add(hostname);
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg } from '../org.js';
1
+ import { type DeclaredOrg } from '../org.js';
2
2
  /**
3
3
  * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
4
  * need to agree on for an org's own company login host -- the hostname and
@@ -22,16 +22,65 @@ import type { DeclaredOrg } from '../org.js';
22
22
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
23
  * way `AGORA_SUFFIX` reserves `-agora`.
24
24
  *
25
- * #488: the platform's own org is not handled here: it signs in on the shared
26
- * ZITADEL instance's own host (`platformAuthHostname`), whatever
27
- * `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
28
- * that host itself. That host must never get a trusted
29
- * domain or a login binding of its own:
30
- * Archon's login-host write throws on a duplicate host, and `auth`'s own
31
- * `company-host.ts` denies every request through one, the shared one
32
- * included (`planLoginHost` and `zitadelLoginHost` skip both for it).
25
+ * Every company, the platform owner included, signs in on its own sign-in
26
+ * server at this host (`ownHostnames(org).auth` is this function): a domain is
27
+ * never required.
33
28
  */
34
- export declare function loginHostnameFor(org: DeclaredOrg): string;
29
+ export declare function loginHostnameFor(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'issuer'>): string;
30
+ /**
31
+ * The login host Agora may take as its issuer, and whether the org's pair is
32
+ * one Agora refuses. Agora accepts an issuer host on its own address's
33
+ * registrable domain, or `<slug>-auth.oikos.host` for `<slug>-agora.oikos.host`.
34
+ * `host` is null for a domain with `issuer.usesDomain` off, where the login host
35
+ * `<slug>-auth.oikos.host` and the Agora address `agora.<domain>` are
36
+ * refused together (`mismatch`).
37
+ */
38
+ export declare function agoraIssuerHostFor(org: DeclaredOrg): {
39
+ host: string | null;
40
+ mismatch: boolean;
41
+ };
42
+ /** Milliseconds the discovery read may take before the host counts as not serving. */
43
+ export declare const LOGIN_HOST_DISCOVERY_TIMEOUT_MS = 5000;
44
+ /**
45
+ * Host-supplied read of `GET https://<host>/.well-known/openid-configuration`:
46
+ * the status and the body text. Optional on `FoundryPlanApplyOptions`; absent
47
+ * means "not read", so Agora keeps the platform issuer. The port may throw.
48
+ */
49
+ export interface LoginHostDiscoveryPort {
50
+ discovery(host: string): Promise<{
51
+ status: number;
52
+ body: string;
53
+ }>;
54
+ }
55
+ /** True when the answer is a 200 whose JSON `issuer` is exactly `https://<host>`. */
56
+ export declare function discoveryNamesHost(host: string, answer: {
57
+ status: number;
58
+ body: string;
59
+ }): boolean;
60
+ /**
61
+ * Reads the port once, with a {@link LOGIN_HOST_DISCOVERY_TIMEOUT_MS} limit.
62
+ * An absent port, a throw, a timeout or any answer that is not a discovery
63
+ * document for `host` is false. Never throws; nothing is logged, so no value
64
+ * leaves.
65
+ */
66
+ export declare function readLoginHostDiscovery(port: LoginHostDiscoveryPort | undefined, host: string, timeoutMs?: number): Promise<boolean>;
67
+ /** The hostname an own org's sign-in last applied at, so a changed domain plans the step again. */
68
+ export declare const OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY = "own_auth_applied_hostname";
69
+ /**
70
+ * True once the org's own sign-in step has applied (`coolify:own-auth:<org.id>`
71
+ * in its recorded ids). An own org has no login-host step: its issuer host is
72
+ * the sign-in service's own, so this marker is what Agora and every ZITADEL
73
+ * step of the org wait for.
74
+ */
75
+ export declare function ownAuthApplied(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
76
+ /**
77
+ * True once the login-host step has applied `host` for the org, and, when
78
+ * Agora's client is already made, the binding it wrote names that client.
79
+ * The binding admits client ids only, so a host whose binding lacks Agora's
80
+ * client cannot sign Agora in. An org with no Agora client yet has nothing
81
+ * to wait for beyond the host itself.
82
+ */
83
+ export declare function loginHostServesAgora(host: string, org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
35
84
  /**
36
85
  * Scope id set to `'1'` once an org's recorded login host is the platform's
37
86
  * shared instance host: that host's trusted domain belongs to the shared
@@ -113,6 +162,6 @@ export declare function pendingRemovalState(hosts: readonly string[]): string;
113
162
  * it: `auth.<domain>`, only when it is under the org's own domain
114
163
  * (`issuer.usesDomain`) and the org is not the platform's own, whose host is
115
164
  * the shared instance's and is routed by the platform auth tunnel. Otherwise
116
- * `null`. The tier 1 host rides the shared `*.oikos.host` tunnel.
165
+ * `null`, and always `null` for an own org. The tier 1 host rides the shared `*.oikos.host` tunnel.
117
166
  */
118
- export declare function companyLoginTunnelHost(org: DeclaredOrg, isPlatformOrg: boolean): string | null;
167
+ export declare function companyLoginTunnelHost(org: DeclaredOrg): string | null;
@@ -1,3 +1,4 @@
1
+ import { ownsServices } from '../org.js';
1
2
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
2
3
  import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
3
4
  /**
@@ -23,14 +24,9 @@ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
23
24
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
24
25
  * way `AGORA_SUFFIX` reserves `-agora`.
25
26
  *
26
- * #488: the platform's own org is not handled here: it signs in on the shared
27
- * ZITADEL instance's own host (`platformAuthHostname`), whatever
28
- * `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
29
- * that host itself. That host must never get a trusted
30
- * domain or a login binding of its own:
31
- * Archon's login-host write throws on a duplicate host, and `auth`'s own
32
- * `company-host.ts` denies every request through one, the shared one
33
- * included (`planLoginHost` and `zitadelLoginHost` skip both for it).
27
+ * Every company, the platform owner included, signs in on its own sign-in
28
+ * server at this host (`ownHostnames(org).auth` is this function): a domain is
29
+ * never required.
34
30
  */
35
31
  export function loginHostnameFor(org) {
36
32
  // `issuer.usesDomain` is the issuer's opt-in into the org's domain
@@ -41,6 +37,95 @@ export function loginHostnameFor(org) {
41
37
  }
42
38
  return `${org.slug}-auth.oikos.host`;
43
39
  }
40
+ /**
41
+ * The login host Agora may take as its issuer, and whether the org's pair is
42
+ * one Agora refuses. Agora accepts an issuer host on its own address's
43
+ * registrable domain, or `<slug>-auth.oikos.host` for `<slug>-agora.oikos.host`.
44
+ * `host` is null for a domain with `issuer.usesDomain` off, where the login host
45
+ * `<slug>-auth.oikos.host` and the Agora address `agora.<domain>` are
46
+ * refused together (`mismatch`).
47
+ */
48
+ export function agoraIssuerHostFor(org) {
49
+ if (org.domain && !org.issuer.usesDomain)
50
+ return { host: null, mismatch: true };
51
+ return { host: loginHostnameFor(org), mismatch: false };
52
+ }
53
+ /** Milliseconds the discovery read may take before the host counts as not serving. */
54
+ export const LOGIN_HOST_DISCOVERY_TIMEOUT_MS = 5_000;
55
+ /** True when the answer is a 200 whose JSON `issuer` is exactly `https://<host>`. */
56
+ export function discoveryNamesHost(host, answer) {
57
+ if (answer.status !== 200)
58
+ return false;
59
+ try {
60
+ const parsed = JSON.parse(answer.body);
61
+ return (typeof parsed === 'object' &&
62
+ parsed !== null &&
63
+ parsed.issuer === `https://${host}`);
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /**
70
+ * Reads the port once, with a {@link LOGIN_HOST_DISCOVERY_TIMEOUT_MS} limit.
71
+ * An absent port, a throw, a timeout or any answer that is not a discovery
72
+ * document for `host` is false. Never throws; nothing is logged, so no value
73
+ * leaves.
74
+ */
75
+ export async function readLoginHostDiscovery(port, host, timeoutMs = LOGIN_HOST_DISCOVERY_TIMEOUT_MS) {
76
+ if (!port)
77
+ return false;
78
+ let timer;
79
+ try {
80
+ const answer = await Promise.race([
81
+ port.discovery(host),
82
+ new Promise((_, reject) => {
83
+ timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
84
+ }),
85
+ ]);
86
+ return discoveryNamesHost(host, answer);
87
+ }
88
+ catch {
89
+ // Any failure is "not serving": the plan keeps the platform issuer and says so in a fixed line.
90
+ return false;
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+ /** The hostname an own org's sign-in last applied at, so a changed domain plans the step again. */
97
+ export const OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY = 'own_auth_applied_hostname';
98
+ /**
99
+ * True once the org's own sign-in step has applied (`coolify:own-auth:<org.id>`
100
+ * in its recorded ids). An own org has no login-host step: its issuer host is
101
+ * the sign-in service's own, so this marker is what Agora and every ZITADEL
102
+ * step of the org wait for.
103
+ */
104
+ export function ownAuthApplied(org, recorded) {
105
+ return Boolean(recorded[`coolify:own-auth:${org.id}`]);
106
+ }
107
+ /**
108
+ * True once the login-host step has applied `host` for the org, and, when
109
+ * Agora's client is already made, the binding it wrote names that client.
110
+ * The binding admits client ids only, so a host whose binding lacks Agora's
111
+ * client cannot sign Agora in. An org with no Agora client yet has nothing
112
+ * to wait for beyond the host itself.
113
+ */
114
+ export function loginHostServesAgora(host, org, recorded) {
115
+ if (ownsServices(org))
116
+ return ownAuthApplied(org, recorded);
117
+ if (recorded[`zitadel:login-host:${org.id}`] !== host)
118
+ return false;
119
+ const bound = recorded[LOGIN_BINDING_SCOPE_KEY];
120
+ if (!bound?.startsWith(`${host} `))
121
+ return false;
122
+ const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
123
+ return (!clientId ||
124
+ bound
125
+ .slice(host.length + 1)
126
+ .split(',')
127
+ .includes(clientId));
128
+ }
44
129
  /**
45
130
  * Scope id set to `'1'` once an org's recorded login host is the platform's
46
131
  * shared instance host: that host's trusted domain belongs to the shared
@@ -109,8 +194,11 @@ export function pendingRemovalState(hosts) {
109
194
  * it: `auth.<domain>`, only when it is under the org's own domain
110
195
  * (`issuer.usesDomain`) and the org is not the platform's own, whose host is
111
196
  * the shared instance's and is routed by the platform auth tunnel. Otherwise
112
- * `null`. The tier 1 host rides the shared `*.oikos.host` tunnel.
197
+ * `null`, and always `null` for an own org. The tier 1 host rides the shared `*.oikos.host` tunnel.
113
198
  */
114
- export function companyLoginTunnelHost(org, isPlatformOrg) {
115
- return org.domain && org.issuer.usesDomain && !isPlatformOrg ? loginHostnameFor(org) : null;
199
+ export function companyLoginTunnelHost(org) {
200
+ // An own org's sign-in stack publishes its host through its own tunnel.
201
+ if (ownsServices(org))
202
+ return null;
203
+ return org.domain && org.issuer.usesDomain ? loginHostnameFor(org) : null;
116
204
  }
@@ -0,0 +1,139 @@
1
+ import type { DeclaredPlatform } from '../platform.js';
2
+ import type { DeployHealthRuntime } from './deploy-health.js';
3
+ import type { DraftStep } from './diff.js';
4
+ import type { CoolifyPort } from './executor.js';
5
+ /**
6
+ * The shared mail relay: one Postfix container that owns host port 25 and
7
+ * hands each message to the mail server that serves its recipient's domain,
8
+ * on the Docker network. One server address has one inbound mail port, so a
9
+ * company's own mail server cannot bind it; this container does, for all.
10
+ *
11
+ * It is a platform step (`coolify:app` keyed {@link PLATFORM_MAIL_RELAY_KEY}),
12
+ * planned only when `platform.mail.relay` is true. It mints no secret.
13
+ * Everything it knows reaches the container as env the step writes.
14
+ */
15
+ export declare const PLATFORM_MAIL_RELAY_KEY = "platform:mail-relay";
16
+ /** Fingerprint of the sorted routes the last successful apply put live. */
17
+ export declare const PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY = "mail_relay_routes_applied";
18
+ /**
19
+ * Fingerprint of what forces an image rebuild (the Dockerfile text, which holds
20
+ * the base image pin) and the spool volume the last successful apply put live.
21
+ * A routes change leaves it alone, so it restarts the container and rebuilds nothing.
22
+ */
23
+ export declare const PLATFORM_MAIL_RELAY_BUILD_SCOPE_KEY = "mail_relay_build_applied";
24
+ /** Postfix's queue. On a volume, so mail not yet handed on survives a restart and a rebuild. */
25
+ export declare const MAIL_RELAY_SPOOL_DIR = "/var/spool/postfix";
26
+ export declare const MAIL_RELAY_SPOOL_VOLUME = "platform-mail-relay-spool";
27
+ /**
28
+ * The org-applied-state key the company mail step records its server's SMTP address under: `<server address>:<port>`,
29
+ * the port the company's Stalwart publishes on the server for the relay ({@link OWN_MAIL_SMTP_PORT_SCOPE_KEY}).
30
+ * The relay is on the platform's network and a company's mail server on the company's own, so the relay
31
+ * reaches it through the server, not by its container name.
32
+ */
33
+ export declare const OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY = "mail_smtp_internal";
34
+ /** The host port a company's Stalwart publishes (mapped to its port 25) for the relay. */
35
+ export declare const OWN_MAIL_SMTP_PORT_SCOPE_KEY = "mail_smtp_port";
36
+ /** The host ports companies' mail servers take, one each: the lowest free one in this range. */
37
+ export declare const OWN_MAIL_SMTP_PORT_BASE = 25000;
38
+ export declare const OWN_MAIL_SMTP_PORT_MAX = 25999;
39
+ /** Whether `port` is one of the ports a company's mail server may publish. */
40
+ export declare function isOwnMailSmtpPort(port: unknown): port is number;
41
+ export declare const MAIL_RELAY_APP_NAME = "platform-mail-relay";
42
+ /** One recipient domain and the mail server that receives it: `target` is `<host>:<port>`. */
43
+ export interface MailRoute {
44
+ domain: string;
45
+ target: string;
46
+ }
47
+ /** Lower-cases and checks a recipient domain; `null` for anything that is not a plain DNS name. */
48
+ export declare function normalizeMailDomain(domain: string): string | null;
49
+ /** Lower-cases and checks `<host>:<port>`; `null` for whitespace, metacharacters, a port outside 1..65535 or no host. */
50
+ export declare function normalizeMailTarget(target: string): string | null;
51
+ /**
52
+ * The routes, checked and sorted by domain. `null` when any entry is invalid or
53
+ * one domain has two targets (a message could not know where to go).
54
+ */
55
+ export declare function normalizeMailRoutes(routes: readonly MailRoute[]): MailRoute[] | null;
56
+ /**
57
+ * What the relay was last built to do, as one value. Order of the inputs does
58
+ * not matter; a changed target or a new route does.
59
+ * Never empty, so a platform with no company route still records one.
60
+ */
61
+ export declare function mailRelayFingerprint(routes: readonly MailRoute[]): string;
62
+ /**
63
+ * The relay's container start script. It renders Postfix's config from env
64
+ * only and refuses to start on a value that is not a plain domain or host, so
65
+ * nothing from env reaches a config line unchecked. The relay accepts mail for
66
+ * the listed domains and nothing else (`reject_unauth_destination`), so it
67
+ * cannot be used to send to a third party.
68
+ *
69
+ * Mail for a domain no company routes is refused: there is no platform mail
70
+ * server to fall back on, so no default target exists.
71
+ *
72
+ * Env: `MAIL_RELAY_HOSTNAME`, `MAIL_RELAY_ROUTES` (`domain=host:port`, comma
73
+ * separated). `MAIL_RELAY_RENDER_ONLY=1` writes the files and stops, and
74
+ * `MAIL_RELAY_CONF_DIR` moves them; both are for tests.
75
+ */
76
+ export declare const MAIL_RELAY_ENTRYPOINT = "#!/bin/sh\nset -eu\nset -f\nconf=\"${MAIL_RELAY_CONF_DIR:-/etc/postfix}\"\nfail() { echo \"mail relay: $1\" >&2; exit 1; }\nok_name() {\n case \"$1\" in\n ''|*[!a-z0-9.-]*|.*|*.|*..*|-*|*.-*|*-.*) return 1 ;;\n esac\n return 0\n}\nok_domain() { ok_name \"$1\" && case \"$1\" in *.*) return 0 ;; esac; return 1; }\nok_target() {\n h=\"${1%:*}\"\n p=\"${1##*:}\"\n [ \"$h\" != \"$1\" ] || return 1\n case \"$p\" in ''|0*|*[!0-9]*) return 1 ;; esac\n [ \"${#p}\" -le 5 ] || return 1\n [ \"$p\" -le 65535 ] || return 1\n ok_name \"$h\"\n}\nok_name \"${MAIL_RELAY_HOSTNAME:-}\" || fail \"MAIL_RELAY_HOSTNAME is not a host name\"\n: > \"$conf/relay_domains\"\n: > \"$conf/transport\"\nIFS=','\nfor entry in ${MAIL_RELAY_ROUTES:-}; do\n d=\"${entry%%=*}\"\n t=\"${entry#*=}\"\n ok_domain \"$d\" || fail \"a route domain is not a plain domain\"\n ok_target \"$t\" || fail \"a route target is not <host>:<port>\"\n printf '%s OK\\n' \"$d\" >> \"$conf/relay_domains\"\n printf '%s smtp:[%s]:%s\\n' \"$d\" \"${t%:*}\" \"${t##*:}\" >> \"$conf/transport\"\ndone\nunset IFS\ncat > \"$conf/main.cf\" <<CONF\nmyhostname = $MAIL_RELAY_HOSTNAME\nmyorigin = \\$myhostname\ninet_interfaces = all\ninet_protocols = ipv4\nmydestination =\nmynetworks = 127.0.0.0/8\nlocal_recipient_maps =\nalias_maps =\nalias_database =\nrelay_domains = texthash:$conf/relay_domains\nparent_domain_matches_subdomains =\ntransport_maps = texthash:$conf/transport\nrelay_recipient_maps =\nsmtpd_relay_restrictions = reject_unauth_destination\nsmtpd_recipient_restrictions = reject_unauth_destination\ndisable_vrfy_command = yes\nmaillog_file = /dev/stdout\nCONF\n[ \"${MAIL_RELAY_RENDER_ONLY:-}\" = \"1\" ] && exit 0\nexec postfix start-fg\n";
77
+ /** The inline Dockerfile Coolify builds. The start script travels base64-encoded so no quoting can alter it. */
78
+ export declare function mailRelayDockerfile(): string;
79
+ /** What a rebuild depends on: the Dockerfile and the spool mount. Routes, domains and hostname are not in it. */
80
+ export declare function mailRelayBuildFingerprint(): string;
81
+ /** The env the step writes; the only input the container reads. */
82
+ export declare function mailRelayEnv(input: {
83
+ hostname: string;
84
+ routes: readonly MailRoute[];
85
+ }): Array<{
86
+ key: string;
87
+ value: string;
88
+ literal: boolean;
89
+ }>;
90
+ export declare const MAIL_RELAY_BLOCKED: {
91
+ readonly executorMissing: "the platform's mail relay is blocked, not planned: the host built the platform executor without its mail dependencies";
92
+ readonly invalidRoutes: "the platform's mail relay is blocked, not planned: a mail route is not valid (a domain must be a plain DNS name, a target must be <host>:25, one domain needs one target)";
93
+ };
94
+ /**
95
+ * Plans the relay: nothing unless `mail.relay` is true. Planned when it was
96
+ * never applied, when the fingerprint of its routes differs from the recorded
97
+ * one, or when the base image pin changed. Blocked (reported, not planned) while
98
+ * the platform mail server is not applied, the executor cannot run it, or an
99
+ * input is invalid.
100
+ */
101
+ export declare function planMailRelay(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>, input: {
102
+ routes: readonly MailRoute[];
103
+ executorBlocked: string | undefined;
104
+ }): {
105
+ step: DraftStep | null;
106
+ blocked: string | null;
107
+ };
108
+ export interface MailRelayDeps {
109
+ coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'findOrCreateEnvironment' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'listStorages' | 'createPersistentStorage' | 'restart' | 'getDeployment' | 'deployAndWait' | 'raw'>;
110
+ runtime?: DeployHealthRuntime;
111
+ }
112
+ /**
113
+ * Makes the relay true: finds or creates the app, sets its internal name and
114
+ * host port 25, mounts the queue volume once, writes env, and then does the
115
+ * least that makes it true (decided from the live app):
116
+ * - a rebuild (stop, deploy) when the app was just created, the Dockerfile or
117
+ * volume changed, or the volume was just added. The old container is stopped
118
+ * first, because two containers cannot both bind port 25;
119
+ * - a deploy when the container is not running;
120
+ * - a restart of the same container when only the routes, domains or hostname
121
+ * changed. Postfix reads its routes at start, nothing else is needed.
122
+ * The two fingerprints are recorded last, so a run that fails halfway (env
123
+ * written, restart failed) leaves the step planned and the next apply
124
+ * finishes it. No secret exists here.
125
+ */
126
+ export declare function provisionMailRelay(action: {
127
+ desired: Record<string, unknown>;
128
+ }, context: {
129
+ recordedIds(): Readonly<Record<string, string>>;
130
+ recordScopeId(key: string, id: string): void;
131
+ persist?(): Promise<void>;
132
+ }, deps: MailRelayDeps, input: {
133
+ projectUuid: string;
134
+ serverUuid: string;
135
+ hostname: string;
136
+ appScopeKey: string;
137
+ }): Promise<{
138
+ resourceId: string;
139
+ }>;