@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
@@ -0,0 +1,231 @@
1
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
2
+ import { COOLIFY_PROXY_ORIGIN } from './executor.js';
3
+ /**
4
+ * The shared `oikos.host` ingress: one Cloudflare tunnel, its connector app on
5
+ * Coolify, one proxied wildcard record `*.oikos.host` and one zone-wide rate
6
+ * limiting rule for the sign-in paths. Every company without a domain of its
7
+ * own is reached through it, so a fresh platform needs it before such a company
8
+ * can be planned. It is the platform step that `scripts/shared-ingress.mjs`
9
+ * used to be run by hand for. The script keeps its own copy of the rate limit
10
+ * constants (it runs outside this package) and a test here keeps them equal.
11
+ *
12
+ * The three names below are found by name, and the tunnel's whole ingress is
13
+ * replaced. They differ from the script's (`oikos-shared`) on purpose: a hand-made
14
+ * project or tunnel of that name may carry other traffic, and this step must only
15
+ * ever find its own.
16
+ *
17
+ * One `coolify:app` step keyed {@link PLATFORM_OIKOS_INGRESS_KEY}. Its applied
18
+ * id (the connector app) is recorded by the runner only when the whole step
19
+ * returns, so a run that fails halfway is planned again and finds what it made
20
+ * by name.
21
+ */
22
+ export const PLATFORM_OIKOS_INGRESS_KEY = 'platform:oikos-ingress';
23
+ export const OIKOS_INGRESS_ZONE = 'oikos.host';
24
+ export const OIKOS_INGRESS_TUNNEL_NAME = 'platform-oikos-ingress';
25
+ export const OIKOS_INGRESS_WILDCARD = `*.${OIKOS_INGRESS_ZONE}`;
26
+ export const OIKOS_INGRESS_CONNECTOR_APP_NAME = 'platform-oikos-ingress-cloudflared';
27
+ export const OIKOS_INGRESS_PROJECT = 'platform-oikos-ingress';
28
+ export const OIKOS_INGRESS_ENVIRONMENT = 'production';
29
+ /** Bookkeeping of what the last run finished; none of these is the applied marker. */
30
+ export const OIKOS_INGRESS_PROJECT_SCOPE_KEY = 'oikos_ingress_project';
31
+ export const OIKOS_INGRESS_TUNNEL_SCOPE_KEY = 'oikos_ingress_tunnel';
32
+ export const OIKOS_INGRESS_CONNECTOR_APP_SCOPE_KEY = 'oikos_ingress_connector_app';
33
+ export const OIKOS_INGRESS_DNS_SCOPE_KEY = 'oikos_ingress_dns';
34
+ export const OIKOS_INGRESS_RATELIMIT_SCOPE_KEY = 'oikos_ingress_ratelimit';
35
+ /** The key the runner records the step's result under (`system:kind:key`). */
36
+ const APPLIED_KEY = `coolify:app:${PLATFORM_OIKOS_INGRESS_KEY}`;
37
+ /** True once the step has finished: the readiness a company without a domain waits for. */
38
+ export function oikosIngressApplied(recordedIds) {
39
+ return Boolean(recordedIds[APPLIED_KEY]);
40
+ }
41
+ /** Fixed texts. None carries a provider's error. */
42
+ export const OIKOS_INGRESS_BLOCKED = {
43
+ edgeNotApplied: 'the shared oikos.host ingress is blocked, not planned: every DNS record and tunnel is written through edge, and edge is not applied yet',
44
+ zoneMissing: `the shared oikos.host ingress is blocked, not planned: the ${OIKOS_INGRESS_ZONE} zone is not on the Cloudflare account the platform's edge reaches; add the zone to that account`,
45
+ zoneUnchecked: `the shared oikos.host ingress is blocked, not planned: the ${OIKOS_INGRESS_ZONE} zone could not be looked up through edge just now; apply again`,
46
+ executorMissing: 'the shared oikos.host ingress is blocked, not planned: the host built the platform executor without its oikos ingress dependencies',
47
+ };
48
+ /**
49
+ * The step, or the reason it cannot be planned. Nothing when the platform has
50
+ * no edge (it could not write a record) or the step is already applied.
51
+ * `edgeApplied` is whether edge has recorded its address.
52
+ */
53
+ export function planOikosIngress(platform, recordedIds, input) {
54
+ if (!platform.edge.enabled)
55
+ return { step: null, blocked: null };
56
+ if (oikosIngressApplied(recordedIds))
57
+ return { step: null, blocked: null };
58
+ if (!input.edgeApplied)
59
+ return { step: null, blocked: OIKOS_INGRESS_BLOCKED.edgeNotApplied };
60
+ if (input.executorBlocked)
61
+ return { step: null, blocked: input.executorBlocked };
62
+ return {
63
+ step: {
64
+ action: { system: 'coolify', kind: 'app', key: PLATFORM_OIKOS_INGRESS_KEY, desired: {} },
65
+ step: {
66
+ description: `set up the shared ${OIKOS_INGRESS_ZONE} ingress: its tunnel and connector, the ${OIKOS_INGRESS_WILDCARD} record and the sign-in rate limit`,
67
+ destroys: false,
68
+ migrates: false,
69
+ },
70
+ },
71
+ blocked: null,
72
+ };
73
+ }
74
+ // ---- the decisions of scripts/shared-ingress.mjs, as pure functions --------
75
+ /** The tunnel's ingress: the wildcard to Coolify's proxy, then the mandatory catch-all. */
76
+ export function oikosIngressTunnelIngress() {
77
+ return [
78
+ { hostname: OIKOS_INGRESS_WILDCARD, service: COOLIFY_PROXY_ORIGIN },
79
+ { service: 'http_status:404' },
80
+ ];
81
+ }
82
+ /** What the wildcard record needs: nothing, a create, or an update. */
83
+ export function planWildcardRecord(live, tunnelId) {
84
+ if (!live)
85
+ return 'create';
86
+ return live.content === `${tunnelId}.cfargotunnel.com` && live.proxied === true
87
+ ? 'exists'
88
+ : 'update';
89
+ }
90
+ export const OIKOS_INGRESS_RATE_LIMIT_DESCRIPTION = 'foundry: rate limit sign-in paths (oikos.host, shared)';
91
+ /** The Free plan allows one rule per zone, with only "Path" in the expression, a 10 second period and IP counting. */
92
+ export const OIKOS_INGRESS_RATE_LIMIT_PERIOD_SECONDS = 10;
93
+ export const OIKOS_INGRESS_RATE_LIMIT_REQUESTS = 10;
94
+ export const OIKOS_INGRESS_SIGN_IN_EXPRESSION = 'starts_with(http.request.uri.path, "/auth/")';
95
+ export function oikosIngressRateLimitRule() {
96
+ return {
97
+ description: OIKOS_INGRESS_RATE_LIMIT_DESCRIPTION,
98
+ expression: OIKOS_INGRESS_SIGN_IN_EXPRESSION,
99
+ action: 'block',
100
+ ratelimit: {
101
+ characteristics: ['cf.colo.id', 'ip.src'],
102
+ period: OIKOS_INGRESS_RATE_LIMIT_PERIOD_SECONDS,
103
+ requests_per_period: OIKOS_INGRESS_RATE_LIMIT_REQUESTS,
104
+ mitigation_timeout: OIKOS_INGRESS_RATE_LIMIT_PERIOD_SECONDS,
105
+ },
106
+ };
107
+ }
108
+ /** Ours is there, nothing is there, or a rule that is not ours holds the zone's one Free-plan slot. */
109
+ export function planOikosRateLimit(rules) {
110
+ const own = rules.find((r) => r.description === OIKOS_INGRESS_RATE_LIMIT_DESCRIPTION);
111
+ if (own)
112
+ return { status: 'exists', ruleId: own.id };
113
+ return { status: rules.length > 0 ? 'conflict' : 'create', ruleId: null };
114
+ }
115
+ const DEFAULT_CALL_TIMEOUT_MS = 60_000;
116
+ class CallTimeout extends Error {
117
+ }
118
+ /**
119
+ * One outside call, with a limit. A failure is reported by name only: a provider
120
+ * error can echo a token or a request body, so it is never carried on.
121
+ */
122
+ async function call(label, ms, work) {
123
+ let timer;
124
+ try {
125
+ return await Promise.race([
126
+ work(),
127
+ new Promise((_, reject) => {
128
+ timer = setTimeout(() => reject(new CallTimeout()), ms);
129
+ }),
130
+ ]);
131
+ }
132
+ catch (error) {
133
+ const how = error instanceof CallTimeout ? 'timed out' : 'failed';
134
+ throw new Error(`platform:oikos-ingress: ${label} ${how}`);
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+ /**
141
+ * Why the step cannot be planned for want of its zone, or `null` when the zone
142
+ * is on the account edge reaches. The texts are fixed; a lookup error is never carried.
143
+ */
144
+ export async function oikosIngressZoneBlocked(zones, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS) {
145
+ try {
146
+ const zone = await call('zone lookup', callTimeoutMs, () => zones.findZoneByApex(OIKOS_INGRESS_ZONE));
147
+ return zone && zone.name === OIKOS_INGRESS_ZONE ? null : OIKOS_INGRESS_BLOCKED.zoneMissing;
148
+ }
149
+ catch {
150
+ // The error is dropped on purpose: it may carry provider detail, and the fixed text says enough.
151
+ return OIKOS_INGRESS_BLOCKED.zoneUnchecked;
152
+ }
153
+ }
154
+ /**
155
+ * Makes the ingress, in this order: tunnel, its ingress, the connector app, the
156
+ * wildcard record, the rate limit rule. Each is found by name or content before
157
+ * it is made, so a second run makes nothing twice. Returns the connector app id,
158
+ * which the runner records as the step's applied marker only on success.
159
+ */
160
+ export async function provisionOikosIngress(context, ports) {
161
+ const ms = ports.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
162
+ const record = async (key, id) => {
163
+ context.recordScopeId(key, id);
164
+ await context.persist?.();
165
+ };
166
+ const recorded = context.recordedIds();
167
+ const zone = await call('zone lookup', ms, () => ports.zones.findZoneByApex(OIKOS_INGRESS_ZONE));
168
+ if (!zone || zone.name !== OIKOS_INGRESS_ZONE) {
169
+ throw new Error(`platform:oikos-ingress: ${OIKOS_INGRESS_BLOCKED.zoneMissing}`);
170
+ }
171
+ let projectUuid = recorded[OIKOS_INGRESS_PROJECT_SCOPE_KEY];
172
+ if (!projectUuid) {
173
+ const found = await call('project lookup', ms, () => ports.coolify.findProjectByName(OIKOS_INGRESS_PROJECT));
174
+ projectUuid = found
175
+ ? found.uuid
176
+ : (await call('project create', ms, () => ports.coolify.createProject(OIKOS_INGRESS_PROJECT)))
177
+ .uuid;
178
+ await record(OIKOS_INGRESS_PROJECT_SCOPE_KEY, projectUuid);
179
+ }
180
+ const projectId = projectUuid;
181
+ const { environment } = await call('environment', ms, () => ports.coolify.findOrCreateEnvironment(projectId, OIKOS_INGRESS_ENVIRONMENT));
182
+ const found = await call('tunnel lookup', ms, () => ports.tunnels.findTunnelByName(OIKOS_INGRESS_TUNNEL_NAME));
183
+ const tunnel = found ??
184
+ (await call('tunnel create', ms, () => ports.tunnels.createTunnel(OIKOS_INGRESS_TUNNEL_NAME)));
185
+ await record(OIKOS_INGRESS_TUNNEL_SCOPE_KEY, tunnel.id);
186
+ await call('tunnel ingress', ms, () => ports.tunnels.putTunnelConfig(tunnel.id, { ingress: oikosIngressTunnelIngress() }));
187
+ const connector = await call('connector app', ms * 5, () => ensureCloudflaredConnector({
188
+ coolify: ports.coolify,
189
+ tunnels: ports.tunnels,
190
+ tunnelId: tunnel.id,
191
+ appName: OIKOS_INGRESS_CONNECTOR_APP_NAME,
192
+ projectUuid: projectId,
193
+ environment,
194
+ resolveServer: ports.resolveServer,
195
+ recordedConnectorAppId: recorded[OIKOS_INGRESS_CONNECTOR_APP_SCOPE_KEY] ?? null,
196
+ recordAppId: (appId) => record(OIKOS_INGRESS_CONNECTOR_APP_SCOPE_KEY, appId),
197
+ }));
198
+ const [live] = await call('wildcard lookup', ms, () => ports.dns.findDnsRecord(OIKOS_INGRESS_ZONE, OIKOS_INGRESS_WILDCARD, 'CNAME'));
199
+ const wanted = {
200
+ type: 'CNAME',
201
+ name: OIKOS_INGRESS_WILDCARD,
202
+ content: `${tunnel.id}.cfargotunnel.com`,
203
+ proxied: true,
204
+ };
205
+ const dnsPlan = planWildcardRecord(live, tunnel.id);
206
+ if (dnsPlan === 'create') {
207
+ const made = await call('wildcard create', ms, () => ports.dns.createDnsRecord(OIKOS_INGRESS_ZONE, wanted));
208
+ await record(OIKOS_INGRESS_DNS_SCOPE_KEY, made.id);
209
+ }
210
+ else if (dnsPlan === 'update' && live) {
211
+ const id = live.id;
212
+ await call('wildcard update', ms, () => ports.dns.updateDnsRecord(OIKOS_INGRESS_ZONE, id, wanted));
213
+ await record(OIKOS_INGRESS_DNS_SCOPE_KEY, id);
214
+ }
215
+ else if (live) {
216
+ await record(OIKOS_INGRESS_DNS_SCOPE_KEY, live.id);
217
+ }
218
+ const rules = await call('rate limit lookup', ms, () => ports.ratelimits.listRateLimitRules(zone.id));
219
+ const limit = planOikosRateLimit(rules);
220
+ if (limit.status === 'conflict') {
221
+ throw new Error(`platform:oikos-ingress: the ${OIKOS_INGRESS_ZONE} zone already has a rate limiting rule foundry did not make, and the Free plan allows one; it is not adopted or replaced`);
222
+ }
223
+ if (limit.status === 'create') {
224
+ const made = await call('rate limit create', ms, () => ports.ratelimits.createRateLimitRule(zone.id, oikosIngressRateLimitRule()));
225
+ await record(OIKOS_INGRESS_RATELIMIT_SCOPE_KEY, made.id);
226
+ }
227
+ else if (limit.ruleId) {
228
+ await record(OIKOS_INGRESS_RATELIMIT_SCOPE_KEY, limit.ruleId);
229
+ }
230
+ return { resourceId: connector.appId };
231
+ }
@@ -0,0 +1,30 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ /** Which side of an org a derived name belongs to: its own services or its hosting entries. */
3
+ export type OrgNameSource = 'own' | 'hosting';
4
+ /**
5
+ * The names foundry gives the services every company can run beside its own
6
+ * hosting entries: Agora, Boule, the tunnel and its connector, and chat,
7
+ * calls and the cms site while they are on.
8
+ */
9
+ export declare function companyServiceNames(org: Pick<DeclaredOrg, 'slug' | 'chat' | 'calls' | 'site'>): string[];
10
+ /**
11
+ * Every container name, network alias, database, tunnel and bucket name that
12
+ * foundry derives for an org: its own-service names when it owns services,
13
+ * and its hosting-entry and database names.
14
+ */
15
+ export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>): {
16
+ name: string;
17
+ source: OrgNameSource;
18
+ }[];
19
+ /** Every name the platform's shared services take. */
20
+ export declare function platformDerivedNames(): string[];
21
+ export interface NameClash {
22
+ name: string;
23
+ /** The slug of the other org, or `platform`. */
24
+ other: string;
25
+ /** Which side of `org` holds the clashing name. */
26
+ source: OrgNameSource;
27
+ }
28
+ /** Every name `org` derives that another org or the platform also derives. */
29
+ export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>, others: readonly Pick<DeclaredOrg, 'slug' | 'services' | 'hosting' | 'chat' | 'calls' | 'site'>[]): NameClash[];
30
+ export declare function nameClashMessage(slug: string, clash: NameClash): string;
@@ -0,0 +1,108 @@
1
+ import { ownsServices } from '../org.js';
2
+ import { chatInternalNames, chatRekeyedInternalNames } from './chat-matrix.js';
3
+ import { orgTarget, platformTarget } from './own-services.js';
4
+ import { appNameFor } from './platform-diff.js';
5
+ /**
6
+ * The names foundry gives the services every company can run beside its own
7
+ * hosting entries: Agora, Boule, the tunnel and its connector, and chat,
8
+ * calls and the cms site while they are on.
9
+ */
10
+ export function companyServiceNames(org) {
11
+ const s = org.slug;
12
+ const names = [`${s}-agora`, `${s}-boule`, `${s}-boule-db`, `${s}-tunnel`, `${s}-cloudflared`];
13
+ if (org.chat?.enabled) {
14
+ names.push(...Object.values(chatInternalNames(s)), ...Object.values(chatRekeyedInternalNames(s)));
15
+ }
16
+ if (org.calls?.enabled) {
17
+ names.push(`${s}-calls`, `${s}-calls-synapse`, `${s}-calls-mas`, `${s}-calls-synapse-db`, `${s}-calls-mas-db`);
18
+ }
19
+ if (org.site?.enabled)
20
+ names.push(`${s}-site`, `${s}-cms`, `${s}-cms-db`);
21
+ return names;
22
+ }
23
+ /**
24
+ * Every container name, network alias, database, tunnel and bucket name that
25
+ * foundry derives for an org: its own-service names when it owns services,
26
+ * and its hosting-entry and database names.
27
+ */
28
+ export function orgDerivedNames(org) {
29
+ const names = [];
30
+ for (const name of companyServiceNames(org))
31
+ names.push({ name, source: 'own' });
32
+ if (ownsServices(org)) {
33
+ const target = orgTarget(org);
34
+ for (const name of [...Object.values(target.names), ...Object.values(target.internal)]) {
35
+ names.push({ name, source: 'own' });
36
+ }
37
+ }
38
+ for (const entry of org.hosting.entries) {
39
+ names.push({ name: appNameFor(org, entry), source: 'hosting' });
40
+ }
41
+ if (org.hosting.entries.some((entry) => entry.database)) {
42
+ names.push({ name: `${org.slug}-db`, source: 'hosting' });
43
+ }
44
+ return names;
45
+ }
46
+ /** Names the platform's own steps give apps, tunnels, connectors, databases and projects outside `platformTarget()`. */
47
+ const PLATFORM_STEP_NAMES = [
48
+ 'platform',
49
+ 'platform-archon',
50
+ 'platform-archon-db',
51
+ 'platform-archon-tunnel',
52
+ 'platform-archon-cloudflared',
53
+ 'platform-hc',
54
+ 'platform-hc-db',
55
+ 'platform-hc-tunnel',
56
+ 'platform-hc-cloudflared',
57
+ 'platform-edge',
58
+ 'platform-edge-db',
59
+ 'platform-edge-tunnel',
60
+ 'platform-edge-cloudflared',
61
+ 'platform-ai',
62
+ 'platform-ai-migrate',
63
+ 'platform-ai-worker',
64
+ 'platform-backups',
65
+ 'platform-oikos-ingress',
66
+ ];
67
+ /** Every name the platform's shared services take. */
68
+ export function platformDerivedNames() {
69
+ const target = platformTarget();
70
+ return [
71
+ ...Object.values(target.names),
72
+ ...Object.values(target.internal),
73
+ ...PLATFORM_STEP_NAMES,
74
+ ];
75
+ }
76
+ /** Every name `org` derives that another org or the platform also derives. */
77
+ export function findNameClashes(org, others) {
78
+ const mine = orgDerivedNames(org);
79
+ const platform = new Set(platformDerivedNames());
80
+ const clashes = [];
81
+ for (const { name, source } of mine) {
82
+ if (platform.has(name))
83
+ clashes.push({ name, other: 'platform', source });
84
+ }
85
+ // A hosting entry may not take a name foundry runs a service under for the same company.
86
+ const ownServices = new Set(companyServiceNames(org));
87
+ for (const { name, source } of mine) {
88
+ if (source === 'hosting' && ownServices.has(name)) {
89
+ clashes.push({ name, other: org.slug, source });
90
+ }
91
+ }
92
+ for (const other of others) {
93
+ if (other.slug === org.slug)
94
+ continue;
95
+ const theirs = new Set(orgDerivedNames(other).map((n) => n.name));
96
+ for (const { name, source } of mine) {
97
+ if (theirs.has(name))
98
+ clashes.push({ name, other: other.slug, source });
99
+ }
100
+ }
101
+ return clashes;
102
+ }
103
+ export function nameClashMessage(slug, clash) {
104
+ if (clash.other === slug) {
105
+ return `name '${clash.name}' is taken by both a hosting entry and a service foundry runs for '${slug}'`;
106
+ }
107
+ return `name '${clash.name}' is taken by both '${slug}' and '${clash.other}'`;
108
+ }
@@ -0,0 +1,170 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY } from './login-host.js';
4
+ import { type OrgPlatformReadiness } from './platform-org.js';
5
+ /** `unmanaged` key of the one line `planOwnJobs` reports. */
6
+ export declare const OWN_JOBS_BLOCKED_KEY = "own-jobs";
7
+ export interface PlanOwnJobsOptions {
8
+ org: DeclaredOrg;
9
+ recordedIds: Readonly<Record<string, string>>;
10
+ readiness: OrgPlatformReadiness;
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
+ export { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY };
30
+ /** The auth git ref an own org's sign-in last deployed, so a changed declared ref plans the step again. */
31
+ export declare const OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY = "own_auth_applied_git_ref";
32
+ export interface PlanOwnAuthOptions {
33
+ org: DeclaredOrg;
34
+ recordedIds: Readonly<Record<string, string>>;
35
+ readiness: OrgPlatformReadiness;
36
+ /** The executor was built with an `ownServices` port. */
37
+ ownServices: boolean;
38
+ /** The org's Cloudflare zone as the live read shows it, or null when none exists yet. */
39
+ zone: {
40
+ active: boolean;
41
+ } | null;
42
+ /** The platform's edge has the `oikos.host` zone: the zone gate of a company without a domain. */
43
+ oikosZoneReady?: boolean;
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
+ /** The executor was built with an `ownServices` port. */
69
+ ownServices: boolean;
70
+ /** `platform.mail.relay` is declared and the relay recorded a route list as applied. */
71
+ mailRelayApplied: boolean;
72
+ /** The org's zone is live and active. */
73
+ zoneActive: boolean;
74
+ /**
75
+ * The host port the company's Stalwart publishes for the relay (`plan-apply.ts` gives the one the
76
+ * company already has, else the lowest free). Absent: the first port of the range.
77
+ */
78
+ smtpPort?: number;
79
+ }
80
+ /**
81
+ * An own org (`services.own`) with mail on gets its own Stalwart in its own
82
+ * Coolify project, behind the shared relay. It waits for the org's own
83
+ * sign-in (its directory trusts that issuer), the relay (port 25 has no other
84
+ * way in) and the zone. Applied once, and again when the hostname or the
85
+ * image tag changed. Every blocked line is fixed text: it is part of the
86
+ * stale-plan comparison.
87
+ */
88
+ export declare function planOwnMail(options: PlanOwnMailOptions): {
89
+ step: DraftStep | null;
90
+ blocked: string | null;
91
+ };
92
+ /** `unmanaged` key of the one blocked line `planOwnSfu` reports. */
93
+ export declare const OWN_SFU_BLOCKED_KEY = "own-sfu";
94
+ /** `unmanaged` key of the firewall line: the server's ports foundry cannot open itself. */
95
+ export declare const OWN_SFU_FIREWALL_KEY = "own-sfu:firewall";
96
+ export interface PlanOwnSfuOptions {
97
+ org: DeclaredOrg;
98
+ recordedIds: Readonly<Record<string, string>>;
99
+ /** The executor was built with an `ownServices` port. */
100
+ ownServices: boolean;
101
+ /** The org's zone is live and active: none is planned and none is waiting. */
102
+ zoneActive: boolean;
103
+ readiness: OrgPlatformReadiness;
104
+ /** The host confirmed the slot's ports are open on the server. */
105
+ sfuPortsOpen: boolean;
106
+ /** The key pair's held-key ref holds a value, so an undelivered marker is resumed, not blocked. */
107
+ keysHeld?: boolean;
108
+ }
109
+ /**
110
+ * An own org (`services.own`) with calls on runs its own LiveKit media server
111
+ * as a Coolify app. Applied once, and again when the pinned LiveKit version,
112
+ * the slot or the hostname changed, or an earlier run died before the deploy
113
+ * finished. `firewall` is the one line that says which ports the server must
114
+ * have open: foundry opens none, so it stays until the host confirms them.
115
+ * Every line is fixed text: it is part of the stale-plan comparison.
116
+ */
117
+ export declare function planOwnSfu(options: PlanOwnSfuOptions): {
118
+ step: DraftStep | null;
119
+ blocked: string | null;
120
+ firewall: string | null;
121
+ };
122
+ /** `unmanaged` key of the one line `planOwnFiles` reports. */
123
+ export declare const OWN_FILES_BLOCKED_KEY = "own-files";
124
+ export interface PlanOwnFilesOptions {
125
+ org: DeclaredOrg;
126
+ recordedIds: Readonly<Record<string, string>>;
127
+ readiness: OrgPlatformReadiness;
128
+ /** The executor was built with an `ownServices` port. */
129
+ ownServices: boolean;
130
+ /** What the host's own files dependencies supply: valid keys worker settings, and a bucket port. */
131
+ filesDeps: {
132
+ keys: boolean;
133
+ bucket: boolean;
134
+ };
135
+ }
136
+ /**
137
+ * An own org (`services.own`) runs its own files host: a database, a migrate
138
+ * app, a runner app, a ZITADEL project in the company's own instance and a
139
+ * bucket. Applied once, and again when the declared domain, the platform's
140
+ * files ref or the org's share domains changed or an earlier run died before
141
+ * it finished. A secret minted but not confirmed delivered never plans the
142
+ * step again. Every blocked line is fixed text: it is part of the stale-plan
143
+ * comparison.
144
+ */
145
+ export declare function planOwnFiles(options: PlanOwnFilesOptions): {
146
+ step: DraftStep | null;
147
+ blocked: string | null;
148
+ };
149
+ /** `unmanaged` key of the one line `planOwnFilesR2` reports. */
150
+ export declare const OWN_FILES_R2_BLOCKED_KEY = "own-files-r2";
151
+ export interface PlanOwnFilesR2Options {
152
+ org: DeclaredOrg;
153
+ recordedIds: Readonly<Record<string, string>>;
154
+ /** The executor was built with an `ownServices` port. */
155
+ ownServices: boolean;
156
+ /** Which ports the step needs the host supplied: the R2 key port, and the files-organisation `storeR2Credentials`. */
157
+ ports: {
158
+ r2Keys: boolean;
159
+ storeR2Credentials: boolean;
160
+ };
161
+ }
162
+ /**
163
+ * The storage key of an own org's files bucket: planned once its files host is applied (the host must be
164
+ * up to receive the key) and until `files_r2_id` is recorded. A token minted and not confirmed delivered
165
+ * does not block the step: the next run deletes it first. The blocked line is fixed text.
166
+ */
167
+ export declare function planOwnFilesR2(options: PlanOwnFilesR2Options): {
168
+ step: DraftStep | null;
169
+ blocked: string | null;
170
+ };