@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
@@ -0,0 +1,169 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import { type OrgPlatformReadiness } from './platform-org.js';
4
+ /** `unmanaged` key of the one line `planOwnJobs` reports. */
5
+ export declare const OWN_JOBS_BLOCKED_KEY = "own-jobs";
6
+ export interface PlanOwnJobsOptions {
7
+ org: DeclaredOrg;
8
+ recordedIds: Readonly<Record<string, string>>;
9
+ readiness: OrgPlatformReadiness;
10
+ isPlatformOrg: boolean;
11
+ /** The executor was built with an `ownServices` port. */
12
+ ownServices: boolean;
13
+ /** The admin token's held-key ref holds a value, so an undelivered marker is resumed, not blocked. */
14
+ tokenHeld?: boolean;
15
+ }
16
+ /**
17
+ * An own org (`services.own`) runs its own jobs scheduler: a database, an app
18
+ * and an admin token in the org's own Coolify project. Applied once, and
19
+ * again when the platform's declared jobs ref changed, an earlier run died
20
+ * before the deploy finished, or the address was never recorded. Every
21
+ * blocked line is fixed text: it is part of the stale-plan comparison.
22
+ */
23
+ export declare function planOwnJobs(options: PlanOwnJobsOptions): {
24
+ step: DraftStep | null;
25
+ blocked: string | null;
26
+ };
27
+ /** `unmanaged` key of the one line `planOwnAuth` reports. */
28
+ export declare const OWN_AUTH_BLOCKED_KEY = "own-auth";
29
+ /** The hostname an own org's sign-in last applied at, so a changed domain plans the step again. */
30
+ export declare const OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY = "own_auth_applied_hostname";
31
+ /** The auth git ref an own org's sign-in last deployed, so a changed declared ref plans the step again. */
32
+ export declare const OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY = "own_auth_applied_git_ref";
33
+ export interface PlanOwnAuthOptions {
34
+ org: DeclaredOrg;
35
+ recordedIds: Readonly<Record<string, string>>;
36
+ readiness: OrgPlatformReadiness;
37
+ isPlatformOrg: boolean;
38
+ /** The executor was built with an `ownServices` port. */
39
+ ownServices: boolean;
40
+ /** The org's Cloudflare zone as the live read shows it, or null when none exists yet. */
41
+ zone: {
42
+ active: boolean;
43
+ } | null;
44
+ /** The org's own Lettermint key (`orgTarget(org).refs.brokerMailKey`) is held. */
45
+ mailKeyHeld: boolean;
46
+ /** The host can mint that key itself (a Lettermint port and a held-key reader): the step plans without it. */
47
+ mailKeyMintable?: boolean;
48
+ }
49
+ /**
50
+ * An own org (`services.own`) runs its own sign-in: ZITADEL, the login app
51
+ * and the broker in the org's own Coolify project, at `auth.<domain>`.
52
+ * Applied once, and again when the declared domain or the platform's auth ref
53
+ * changed or an earlier run died before it finished. A login client that is
54
+ * not minted yet plans the step again only once the broker holds an entry:
55
+ * until then it waits on `AUTH_APPS`, which no re-run of this step supplies. Every blocked line is fixed
56
+ * text: it is part of the stale-plan comparison.
57
+ */
58
+ export declare function planOwnAuth(options: PlanOwnAuthOptions): {
59
+ step: DraftStep | null;
60
+ blocked: string | null;
61
+ };
62
+ /** `unmanaged` key of the one line `planOwnMail` reports. */
63
+ export declare const OWN_MAIL_BLOCKED_KEY = "own-mail";
64
+ export interface PlanOwnMailOptions {
65
+ org: DeclaredOrg;
66
+ recordedIds: Readonly<Record<string, string>>;
67
+ readiness: OrgPlatformReadiness;
68
+ isPlatformOrg: boolean;
69
+ /** The executor was built with an `ownServices` port. */
70
+ ownServices: boolean;
71
+ /** `platform.mail.relay` is declared and the relay recorded a route list as applied. */
72
+ mailRelayApplied: boolean;
73
+ /** The org's zone is live and active. */
74
+ zoneActive: boolean;
75
+ }
76
+ /**
77
+ * An own org (`services.own`) with mail on gets its own Stalwart in its own
78
+ * Coolify project, behind the shared relay. It waits for the org's own
79
+ * sign-in (its directory trusts that issuer), the relay (port 25 has no other
80
+ * way in) and the zone. Applied once, and again when the hostname or the
81
+ * image tag changed. Every blocked line is fixed text: it is part of the
82
+ * stale-plan comparison.
83
+ */
84
+ export declare function planOwnMail(options: PlanOwnMailOptions): {
85
+ step: DraftStep | null;
86
+ blocked: string | null;
87
+ };
88
+ /** `unmanaged` key of the one blocked line `planOwnSfu` reports. */
89
+ export declare const OWN_SFU_BLOCKED_KEY = "own-sfu";
90
+ /** `unmanaged` key of the firewall line: the server's ports foundry cannot open itself. */
91
+ export declare const OWN_SFU_FIREWALL_KEY = "own-sfu:firewall";
92
+ export interface PlanOwnSfuOptions {
93
+ org: DeclaredOrg;
94
+ recordedIds: Readonly<Record<string, string>>;
95
+ isPlatformOrg: boolean;
96
+ /** The executor was built with an `ownServices` port. */
97
+ ownServices: boolean;
98
+ /** The org's zone is live and active: none is planned and none is waiting. */
99
+ zoneActive: boolean;
100
+ readiness: OrgPlatformReadiness;
101
+ /** The host confirmed the slot's ports are open on the server. */
102
+ sfuPortsOpen: boolean;
103
+ /** The key pair's held-key ref holds a value, so an undelivered marker is resumed, not blocked. */
104
+ keysHeld?: boolean;
105
+ }
106
+ /**
107
+ * An own org (`services.own`) with calls on runs its own LiveKit media server
108
+ * as a Coolify app. Applied once, and again when the pinned LiveKit version,
109
+ * the slot or the hostname changed, or an earlier run died before the deploy
110
+ * finished. `firewall` is the one line that says which ports the server must
111
+ * have open: foundry opens none, so it stays until the host confirms them.
112
+ * Every line is fixed text: it is part of the stale-plan comparison.
113
+ */
114
+ export declare function planOwnSfu(options: PlanOwnSfuOptions): {
115
+ step: DraftStep | null;
116
+ blocked: string | null;
117
+ firewall: string | null;
118
+ };
119
+ /** `unmanaged` key of the one line `planOwnFiles` reports. */
120
+ export declare const OWN_FILES_BLOCKED_KEY = "own-files";
121
+ export interface PlanOwnFilesOptions {
122
+ org: DeclaredOrg;
123
+ recordedIds: Readonly<Record<string, string>>;
124
+ readiness: OrgPlatformReadiness;
125
+ isPlatformOrg: boolean;
126
+ /** The executor was built with an `ownServices` port. */
127
+ ownServices: boolean;
128
+ /** What the host's own files dependencies supply: valid keys worker settings, and a bucket port. */
129
+ filesDeps: {
130
+ keys: boolean;
131
+ bucket: boolean;
132
+ };
133
+ }
134
+ /**
135
+ * An own org (`services.own`) runs its own files host: a database, a migrate
136
+ * app, a runner app, a ZITADEL project in the company's own instance and a
137
+ * bucket. Applied once, and again when the declared domain, the platform's
138
+ * files ref or the org's share domains changed or an earlier run died before
139
+ * it finished. A secret minted but not confirmed delivered never plans the
140
+ * step again. Every blocked line is fixed text: it is part of the stale-plan
141
+ * comparison.
142
+ */
143
+ export declare function planOwnFiles(options: PlanOwnFilesOptions): {
144
+ step: DraftStep | null;
145
+ blocked: string | null;
146
+ };
147
+ /** `unmanaged` key of the one line `planOwnFilesR2` reports. */
148
+ export declare const OWN_FILES_R2_BLOCKED_KEY = "own-files-r2";
149
+ export interface PlanOwnFilesR2Options {
150
+ org: DeclaredOrg;
151
+ recordedIds: Readonly<Record<string, string>>;
152
+ isPlatformOrg: boolean;
153
+ /** The executor was built with an `ownServices` port. */
154
+ ownServices: boolean;
155
+ /** Which ports the step needs the host supplied: the R2 key port, and the files-organisation `storeR2Credentials`. */
156
+ ports: {
157
+ r2Keys: boolean;
158
+ storeR2Credentials: boolean;
159
+ };
160
+ }
161
+ /**
162
+ * The storage key of an own org's files bucket: planned once its files host is applied (the host must be
163
+ * up to receive the key) and until `files_r2_id` is recorded. A token minted and not confirmed delivered
164
+ * does not block the step: the next run deletes it first. The blocked line is fixed text.
165
+ */
166
+ export declare function planOwnFilesR2(options: PlanOwnFilesR2Options): {
167
+ step: DraftStep | null;
168
+ blocked: string | null;
169
+ };
@@ -0,0 +1,432 @@
1
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
+ import { sfuSlotPorts } from '../calls-sfu.js';
3
+ import { SFU_SLOT_MAX, ownsServices } from '../org.js';
4
+ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
5
+ import { brokerEntryAppliedScopeKey } from './broker-entries.js';
6
+ import { ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
7
+ import { ownAuthApplied } from './login-host.js';
8
+ import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY } from './mail-relay.js';
9
+ import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
10
+ import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
11
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
12
+ import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
13
+ import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
14
+ /** `unmanaged` key of the one line `planOwnJobs` reports. */
15
+ export const OWN_JOBS_BLOCKED_KEY = 'own-jobs';
16
+ /**
17
+ * An own org (`services.own`) runs its own jobs scheduler: a database, an app
18
+ * and an admin token in the org's own Coolify project. Applied once, and
19
+ * again when the platform's declared jobs ref changed, an earlier run died
20
+ * before the deploy finished, or the address was never recorded. Every
21
+ * blocked line is fixed text: it is part of the stale-plan comparison.
22
+ */
23
+ export function planOwnJobs(options) {
24
+ const { org, recordedIds, readiness, isPlatformOrg, ownServices } = options;
25
+ if (!ownsServices(org))
26
+ return { step: null, blocked: null };
27
+ const blocked = (line) => ({ step: null, blocked: line });
28
+ if (isPlatformOrg) {
29
+ return blocked(`${org.slug} cannot run its own services: it is the platform's own org`);
30
+ }
31
+ const undelivered = recordedIds[JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY];
32
+ if (undelivered && !options.tokenHeld) {
33
+ return blocked(`${org.slug}'s own jobs scheduler admin token for app ${undelivered} was minted but not confirmed delivered; remove the recorded key ${JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
34
+ }
35
+ if (!ownServices) {
36
+ return blocked(`${org.slug}'s own jobs scheduler is blocked, not planned: the host supplies no ownServices port`);
37
+ }
38
+ const gitRef = readiness.serviceGitRefs?.jobs;
39
+ if (!gitRef) {
40
+ return blocked(`${org.slug}'s own jobs scheduler is blocked, not planned: the platform declares no jobs git ref`);
41
+ }
42
+ const applied = Boolean(recordedIds[`coolify:own-jobs:${org.id}`]);
43
+ if (applied &&
44
+ recordedIds[JOBS_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
45
+ !recordedIds[JOBS_DEPLOY_PENDING_SCOPE_KEY] &&
46
+ recordedIds[JOBS_INTERNAL_URL_SCOPE_KEY]) {
47
+ return { step: null, blocked: null };
48
+ }
49
+ return {
50
+ blocked: null,
51
+ step: {
52
+ action: {
53
+ system: 'coolify',
54
+ kind: 'own-jobs',
55
+ key: org.id,
56
+ desired: { gitRef },
57
+ },
58
+ step: {
59
+ description: `provision ${org.slug}'s own jobs scheduler`,
60
+ destroys: false,
61
+ migrates: false,
62
+ },
63
+ },
64
+ };
65
+ }
66
+ const BROKER_ENTRY_APPLIED_PREFIX = brokerEntryAppliedScopeKey('');
67
+ /** `unmanaged` key of the one line `planOwnAuth` reports. */
68
+ export const OWN_AUTH_BLOCKED_KEY = 'own-auth';
69
+ /** The hostname an own org's sign-in last applied at, so a changed domain plans the step again. */
70
+ export const OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY = 'own_auth_applied_hostname';
71
+ /** The auth git ref an own org's sign-in last deployed, so a changed declared ref plans the step again. */
72
+ export const OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY = 'own_auth_applied_git_ref';
73
+ /**
74
+ * An own org (`services.own`) runs its own sign-in: ZITADEL, the login app
75
+ * and the broker in the org's own Coolify project, at `auth.<domain>`.
76
+ * Applied once, and again when the declared domain or the platform's auth ref
77
+ * changed or an earlier run died before it finished. A login client that is
78
+ * not minted yet plans the step again only once the broker holds an entry:
79
+ * until then it waits on `AUTH_APPS`, which no re-run of this step supplies. Every blocked line is fixed
80
+ * text: it is part of the stale-plan comparison.
81
+ */
82
+ export function planOwnAuth(options) {
83
+ const { org, recordedIds, readiness, isPlatformOrg, ownServices } = options;
84
+ // `planOwnJobs` reports the platform's own org once.
85
+ if (!ownsServices(org) || isPlatformOrg || !org.domain)
86
+ return { step: null, blocked: null };
87
+ const blocked = (reason) => ({
88
+ step: null,
89
+ blocked: `${org.slug}'s own sign-in is blocked, not planned: ${reason}`,
90
+ });
91
+ if (recordedIds.zitadel_org && !recordedIds[OWN_SERVICES_APPLIED_SCOPE_KEY]) {
92
+ return {
93
+ step: null,
94
+ blocked: `${org.slug} already signs in on the shared service; moving it is a later phase`,
95
+ };
96
+ }
97
+ const instanceLost = recordedIds[PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY];
98
+ if (instanceLost &&
99
+ !recordedIds[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
100
+ !recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
101
+ return blocked(`a ZITADEL instance (${instanceLost}) may exist without a delivered owner token; delete it with the system API or redeploy its database, then remove the recorded key ${PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY}`);
102
+ }
103
+ const adminLost = recordedIds[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
104
+ if (adminLost && !recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
105
+ return blocked(`the admin key of ZITADEL machine user ${adminLost} was minted but not confirmed delivered; delete that user and remove the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
106
+ }
107
+ const loginClientLost = recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
108
+ if (loginClientLost && !recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) {
109
+ return blocked(`the login-client token of ZITADEL machine user ${loginClientLost} was minted but not confirmed delivered; remove the recorded key ${PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY} to mint a new one`);
110
+ }
111
+ if (!ownServices)
112
+ return blocked('the host supplies no ownServices port');
113
+ const gitRef = readiness.serviceGitRefs?.auth;
114
+ if (!gitRef)
115
+ return blocked('the platform declares no auth git ref');
116
+ if (!options.zone?.active)
117
+ return blocked(`the zone for ${org.domain} is not active yet`);
118
+ const ownEdge = readiness.ownEdgePorts;
119
+ if (readiness.dnsBlocked && !(ownEdge?.dns && ownEdge.tunnels)) {
120
+ return blocked(readiness.dnsBlocked);
121
+ }
122
+ if (!ownEdge?.tunnels && !readiness.edgeReady) {
123
+ return blocked('the host supplies no tunnel port and edge is not ready');
124
+ }
125
+ if (!options.mailKeyHeld && !options.mailKeyMintable) {
126
+ return blocked(`the held key ${orgTarget(org).refs.brokerMailKey} is not held yet`);
127
+ }
128
+ const hostname = `auth.${org.domain}`;
129
+ // The broker starts only on a valid `AUTH_APPS`, and the login client is minted only once it does:
130
+ // the first broker entry is what lets this step finish, as the platform's first registration does.
131
+ const brokerHoldsAnEntry = Object.keys(recordedIds).some((key) => key.startsWith(BROKER_ENTRY_APPLIED_PREFIX));
132
+ const applied = Boolean(recordedIds[`coolify:own-auth:${org.id}`]) &&
133
+ Boolean(recordedIds[OWN_SERVICES_APPLIED_SCOPE_KEY]);
134
+ if (applied &&
135
+ recordedIds[OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
136
+ recordedIds[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
137
+ recordedIds[PLATFORM_AUTH_ZITADEL_SCOPE_KEY] &&
138
+ recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY] &&
139
+ recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
140
+ recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] &&
141
+ recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
142
+ recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
143
+ (Boolean(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) || !brokerHoldsAnEntry)) {
144
+ return { step: null, blocked: null };
145
+ }
146
+ return {
147
+ blocked: null,
148
+ step: {
149
+ action: {
150
+ system: 'coolify',
151
+ kind: 'own-auth',
152
+ key: org.id,
153
+ desired: { hostname, gitRef },
154
+ },
155
+ step: {
156
+ description: `provision ${org.slug}'s own sign-in at ${hostname}`,
157
+ destroys: false,
158
+ migrates: false,
159
+ },
160
+ },
161
+ };
162
+ }
163
+ /** `unmanaged` key of the one line `planOwnMail` reports. */
164
+ export const OWN_MAIL_BLOCKED_KEY = 'own-mail';
165
+ /**
166
+ * An own org (`services.own`) with mail on gets its own Stalwart in its own
167
+ * Coolify project, behind the shared relay. It waits for the org's own
168
+ * sign-in (its directory trusts that issuer), the relay (port 25 has no other
169
+ * way in) and the zone. Applied once, and again when the hostname or the
170
+ * image tag changed. Every blocked line is fixed text: it is part of the
171
+ * stale-plan comparison.
172
+ */
173
+ export function planOwnMail(options) {
174
+ const { org, recordedIds, readiness, isPlatformOrg, ownServices, mailRelayApplied, zoneActive } = options;
175
+ if (!ownsServices(org) || org.mail.mode === 'off' || isPlatformOrg) {
176
+ return { step: null, blocked: null };
177
+ }
178
+ const blocked = (reason) => ({
179
+ step: null,
180
+ blocked: `${org.slug}'s own mail server is blocked, not planned: ${reason}`,
181
+ });
182
+ const undelivered = recordedIds[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
183
+ if (undelivered && !recordedIds[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
184
+ return blocked(`its admin API key ("${undelivered}") was minted but not confirmed delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
185
+ }
186
+ if (!ownServices)
187
+ return blocked('the host supplies no ownServices port');
188
+ const gitRef = readiness.serviceGitRefs?.mail;
189
+ if (!gitRef)
190
+ return blocked('the platform declares no mail git ref');
191
+ const floating = floatingTagBlocked('Stalwart', gitRef, 'v0.16.24');
192
+ if (floating)
193
+ return blocked(`${floating} as the mail gitRef`);
194
+ if (!org.domain)
195
+ return blocked('the org declares no domain');
196
+ if (!ownAuthApplied(org, recordedIds)) {
197
+ return blocked('its own sign-in is not applied yet');
198
+ }
199
+ if (!mailRelayApplied || !readiness.platformApex) {
200
+ return blocked('the shared mail relay is not applied yet');
201
+ }
202
+ if (!zoneActive)
203
+ return blocked(`the zone for ${org.domain} is not active yet`);
204
+ const platformApex = readiness.platformApex;
205
+ const hostname = `mail.${org.domain}`;
206
+ if (recordedIds[`coolify:own-mail:${org.id}`] &&
207
+ recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
208
+ recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
209
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
210
+ recordedIds[OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY]) {
211
+ return { step: null, blocked: null };
212
+ }
213
+ return {
214
+ blocked: null,
215
+ step: {
216
+ action: {
217
+ system: 'coolify',
218
+ kind: 'own-mail',
219
+ key: org.id,
220
+ desired: { hostname, gitRef, mailHost: platformMailMxHost(platformApex) },
221
+ },
222
+ step: {
223
+ description: `provision ${org.slug}'s own mail server`,
224
+ destroys: false,
225
+ migrates: false,
226
+ },
227
+ },
228
+ };
229
+ }
230
+ /** `unmanaged` key of the one blocked line `planOwnSfu` reports. */
231
+ export const OWN_SFU_BLOCKED_KEY = 'own-sfu';
232
+ /** `unmanaged` key of the firewall line: the server's ports foundry cannot open itself. */
233
+ export const OWN_SFU_FIREWALL_KEY = 'own-sfu:firewall';
234
+ /**
235
+ * An own org (`services.own`) with calls on runs its own LiveKit media server
236
+ * as a Coolify app. Applied once, and again when the pinned LiveKit version,
237
+ * the slot or the hostname changed, or an earlier run died before the deploy
238
+ * finished. `firewall` is the one line that says which ports the server must
239
+ * have open: foundry opens none, so it stays until the host confirms them.
240
+ * Every line is fixed text: it is part of the stale-plan comparison.
241
+ */
242
+ export function planOwnSfu(options) {
243
+ const { org, recordedIds, isPlatformOrg, ownServices, zoneActive, sfuPortsOpen, readiness } = options;
244
+ const none = { step: null, blocked: null, firewall: null };
245
+ // The platform's own org is reported once, by `planOwnJobs`.
246
+ if (!ownsServices(org) || !org.calls.enabled || isPlatformOrg || !org.domain)
247
+ return none;
248
+ const blocked = (line) => ({ ...none, blocked: line });
249
+ const slot = org.services?.sfuSlot;
250
+ if (typeof slot !== 'number' || !Number.isInteger(slot) || slot < 1 || slot > SFU_SLOT_MAX) {
251
+ return blocked(`${org.slug}'s own media server is blocked, not planned: no sfuSlot is declared`);
252
+ }
253
+ const ports = sfuSlotPorts(slot);
254
+ const firewall = sfuPortsOpen
255
+ ? null
256
+ : `${org.slug}'s own media server needs UDP ${ports.udpStart}-${ports.udpEnd} and TCP ${ports.tcp} open on the Coolify server's firewall; foundry does not open them`;
257
+ const result = (step, line = null) => ({
258
+ step,
259
+ blocked: line,
260
+ firewall,
261
+ });
262
+ const undelivered = recordedIds[OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY];
263
+ if (undelivered && !options.keysHeld) {
264
+ return result(null, `${org.slug}'s own media server LiveKit key for app ${undelivered} was minted but not confirmed delivered; remove the recorded key ${OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY} to mint a new one`);
265
+ }
266
+ if (!ownServices) {
267
+ return result(null, `${org.slug}'s own media server is blocked, not planned: the host supplies no ownServices port`);
268
+ }
269
+ const waiting = (reason) => result(null, `${org.slug}'s own media server is blocked, not planned: ${reason}`);
270
+ if (!ownAuthApplied(org, recordedIds))
271
+ return waiting('its own sign-in is not applied yet');
272
+ if (!zoneActive)
273
+ return waiting(`the zone for ${org.domain} is not active yet`);
274
+ const ownEdge = readiness.ownEdgePorts;
275
+ if (readiness.dnsBlocked && !(ownEdge?.dns && ownEdge.tunnels)) {
276
+ return waiting(readiness.dnsBlocked);
277
+ }
278
+ if (!ownEdge?.tunnels && !readiness.edgeReady) {
279
+ return waiting('the host supplies no tunnel port and edge is not ready');
280
+ }
281
+ const hostname = `sfu.${org.domain}`;
282
+ const version = VERSIONS.livekit;
283
+ const applied = Boolean(recordedIds[`coolify:own-sfu:${org.id}`]) &&
284
+ recordedIds[OWN_SFU_APPLIED_VERSION_SCOPE_KEY] === version &&
285
+ recordedIds[OWN_SFU_APPLIED_SLOT_SCOPE_KEY] === String(slot) &&
286
+ recordedIds[OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
287
+ !recordedIds[OWN_SFU_DEPLOY_PENDING_SCOPE_KEY] &&
288
+ Boolean(recordedIds[SFU_INTERNAL_URL_SCOPE_KEY]) &&
289
+ Boolean(recordedIds[SFU_PUBLIC_URL_SCOPE_KEY]);
290
+ if (applied)
291
+ return result(null);
292
+ return result({
293
+ action: {
294
+ system: 'coolify',
295
+ kind: 'own-sfu',
296
+ key: org.id,
297
+ desired: { slot, hostname, version },
298
+ },
299
+ step: {
300
+ description: `provision ${org.slug}'s own media server`,
301
+ destroys: false,
302
+ migrates: false,
303
+ },
304
+ });
305
+ }
306
+ /** `unmanaged` key of the one line `planOwnFiles` reports. */
307
+ export const OWN_FILES_BLOCKED_KEY = 'own-files';
308
+ /**
309
+ * An own org (`services.own`) runs its own files host: a database, a migrate
310
+ * app, a runner app, a ZITADEL project in the company's own instance and a
311
+ * bucket. Applied once, and again when the declared domain, the platform's
312
+ * files ref or the org's share domains changed or an earlier run died before
313
+ * it finished. A secret minted but not confirmed delivered never plans the
314
+ * step again. Every blocked line is fixed text: it is part of the stale-plan
315
+ * comparison.
316
+ */
317
+ export function planOwnFiles(options) {
318
+ const { org, recordedIds, readiness, isPlatformOrg, ownServices, filesDeps } = options;
319
+ // `planOwnJobs` reports the platform's own org once.
320
+ if (!ownsServices(org) || isPlatformOrg || !org.domain)
321
+ return { step: null, blocked: null };
322
+ const blocked = (reason) => ({
323
+ step: null,
324
+ blocked: `${org.slug}'s own files host is blocked, not planned: ${reason}`,
325
+ });
326
+ const managementLost = recordedIds[PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY];
327
+ if (managementLost && !recordedIds[PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY]) {
328
+ return blocked(`the files management key ${managementLost} was minted but not confirmed delivered; remove the recorded key ${PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY} to mint a new one`);
329
+ }
330
+ const introspectionLost = recordedIds[PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
331
+ if (introspectionLost && !recordedIds[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
332
+ return blocked(`the introspection client secret of ZITADEL app ${introspectionLost} was minted but not confirmed delivered; remove the recorded key ${PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY} to mint a new one`);
333
+ }
334
+ if (!ownServices)
335
+ return blocked('the host supplies no ownServices port');
336
+ const gitRef = readiness.serviceGitRefs?.files;
337
+ if (!gitRef)
338
+ return blocked('the platform declares no files git ref');
339
+ if (!ownAuthApplied(org, recordedIds))
340
+ return blocked('its own sign-in service is not applied yet');
341
+ if (!recordedIds[ZITADEL_ORG_SCOPE_KEY])
342
+ return blocked('its ZITADEL organisation is not recorded yet');
343
+ if (!filesDeps.keys) {
344
+ return blocked('the host supplies no keys mint port (keysMint) for the files host keyring');
345
+ }
346
+ if (!filesDeps.bucket)
347
+ return blocked('the host supplies no bucket port');
348
+ const hostname = `files.${org.domain}`;
349
+ const applied = Boolean(recordedIds[`coolify:own-files:${org.id}`]);
350
+ if (applied &&
351
+ recordedIds[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
352
+ recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
353
+ (recordedIds[PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY] ?? '') ===
354
+ filesShareDomains([org]).join(',') &&
355
+ recordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] &&
356
+ recordedIds[OWN_FILES_RUNNER_SCOPE_KEY] &&
357
+ recordedIds[ZITADEL_FILES_PROJECT_SCOPE_KEY] &&
358
+ recordedIds[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY] &&
359
+ recordedIds[PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY]) {
360
+ return { step: null, blocked: null };
361
+ }
362
+ // The platform administers a company's files host: its operator is the platform's owner, never the company's.
363
+ const platformOwner = readiness.platformOwnerUserId;
364
+ if (!platformOwner) {
365
+ return blocked("the platform has not recorded its owner's sign-in id yet");
366
+ }
367
+ return {
368
+ blocked: null,
369
+ step: {
370
+ action: {
371
+ system: 'coolify',
372
+ kind: 'own-files',
373
+ key: org.id,
374
+ desired: {
375
+ hostname,
376
+ gitRef,
377
+ ...(platformOwner === 'none' ? {} : { bootstrapOperator: platformOwner }),
378
+ },
379
+ },
380
+ step: {
381
+ description: `provision ${org.slug}'s own files host at ${hostname}`,
382
+ destroys: false,
383
+ migrates: false,
384
+ },
385
+ },
386
+ };
387
+ }
388
+ /** `unmanaged` key of the one line `planOwnFilesR2` reports. */
389
+ export const OWN_FILES_R2_BLOCKED_KEY = 'own-files-r2';
390
+ /**
391
+ * The storage key of an own org's files bucket: planned once its files host is applied (the host must be
392
+ * up to receive the key) and until `files_r2_id` is recorded. A token minted and not confirmed delivered
393
+ * does not block the step: the next run deletes it first. The blocked line is fixed text.
394
+ */
395
+ export function planOwnFilesR2(options) {
396
+ const { org, recordedIds, isPlatformOrg, ownServices, ports } = options;
397
+ if (!ownsServices(org) || isPlatformOrg || !org.domain || !ownServices) {
398
+ return { step: null, blocked: null };
399
+ }
400
+ if (!recordedIds[`coolify:own-files:${org.id}`])
401
+ return { step: null, blocked: null };
402
+ if (recordedIds[OWN_FILES_R2_SCOPE_KEY])
403
+ return { step: null, blocked: null };
404
+ const missing = [
405
+ ...(ports.r2Keys ? [] : ['R2 key port (r2Keys)']),
406
+ ...(ports.storeR2Credentials
407
+ ? []
408
+ : ["storeR2Credentials on the company's files-organisation port"]),
409
+ ];
410
+ if (missing.length > 0) {
411
+ return {
412
+ step: null,
413
+ blocked: `${org.slug}'s own files storage key is blocked, not planned: the host supplies no ${missing.join(' and no ')}`,
414
+ };
415
+ }
416
+ return {
417
+ blocked: null,
418
+ step: {
419
+ action: {
420
+ system: 'coolify',
421
+ kind: 'own-files-r2',
422
+ key: org.id,
423
+ desired: { bucket: orgTarget(org).names.filesBucket },
424
+ },
425
+ step: {
426
+ description: `make and deliver ${org.slug}'s storage key for bucket ${orgTarget(org).names.filesBucket}`,
427
+ destroys: false,
428
+ migrates: false,
429
+ },
430
+ },
431
+ };
432
+ }
@@ -0,0 +1,6 @@
1
+ import type { StepScope } from './own-services.js';
2
+ /** Comma-separated hostnames of the tunnel CNAMEs an own org's service steps wrote. */
3
+ export declare const OWN_SERVICES_DNS_HOSTNAMES_SCOPE_KEY = "own_services_dns_hostnames";
4
+ /** Records a hostname once, and writes the record out now: called after the DNS write, so a crash between the two is repaired by the rerun. */
5
+ export declare function recordOwnDnsHostname(scope: StepScope, hostname: string): Promise<void>;
6
+ export declare function ownDnsHostnames(recordedIds: Readonly<Record<string, string>>): string[];
@@ -0,0 +1,15 @@
1
+ /** Comma-separated hostnames of the tunnel CNAMEs an own org's service steps wrote. */
2
+ export const OWN_SERVICES_DNS_HOSTNAMES_SCOPE_KEY = 'own_services_dns_hostnames';
3
+ /** Records a hostname once, and writes the record out now: called after the DNS write, so a crash between the two is repaired by the rerun. */
4
+ export async function recordOwnDnsHostname(scope, hostname) {
5
+ const known = (scope.recordedIds()[OWN_SERVICES_DNS_HOSTNAMES_SCOPE_KEY] ?? '')
6
+ .split(',')
7
+ .filter(Boolean);
8
+ if (known.includes(hostname))
9
+ return;
10
+ scope.recordScopeId(OWN_SERVICES_DNS_HOSTNAMES_SCOPE_KEY, [...known, hostname].join(','));
11
+ await scope.persist();
12
+ }
13
+ export function ownDnsHostnames(recordedIds) {
14
+ return (recordedIds[OWN_SERVICES_DNS_HOSTNAMES_SCOPE_KEY] ?? '').toLowerCase().split(',');
15
+ }
@@ -0,0 +1,22 @@
1
+ import type { CoolifyPort } from './executor.js';
2
+ /**
3
+ * The hard limits every container of a company that runs its own services
4
+ * gets: one flat default. Field names are Coolify's (`PATCH /applications/{uuid}`
5
+ * and `PATCH /databases/{uuid}`: `limits_memory`, `limits_cpus`). Coolify has
6
+ * no field for a process limit, and its `custom_docker_run_options` parser drops
7
+ * `--pids-limit`, so 512 processes cannot be set through Coolify.
8
+ */
9
+ export declare const OWN_LIMITS_MEMORY = "1g";
10
+ export declare const OWN_LIMITS_CPUS = "1";
11
+ /** The same bound the neighbouring Coolify calls in this package use for a call that may hang. */
12
+ export declare const OWN_LIMITS_TIMEOUT_MS = 60000;
13
+ type LimitsPort = Partial<Pick<CoolifyPort, 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'findApplicationByName' | 'getApplication' | 'getDatabase' | 'updateApplication' | 'raw'>>;
14
+ /**
15
+ * The Coolify port an own-services step uses, with the limits set on every
16
+ * application and database it creates, and set again on one it finds without
17
+ * them, so a company provisioned before the limits existed converges on its
18
+ * next apply. A failed limits call fails the calling step before it records
19
+ * its applied marker; the next run finds the resource and sets them.
20
+ */
21
+ export declare function limited<C extends LimitsPort>(coolify: C): C;
22
+ export {};