@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,255 @@
1
+ import { mintLivekitCredentials } from '../calls-sfu-keys.js';
2
+ import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
3
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
4
+ import { waitForNewDeployment } from './deploy-health.js';
5
+ import { healthCheckSettings } from './health-check-settings.js';
6
+ import { recordOwnDnsHostname } from './own-dns.js';
7
+ /** The LiveKit image, the same repository the shared SFU runs. */
8
+ export const OWN_SFU_IMAGE = 'livekit/livekit-server';
9
+ export const OWN_SFU_APP_SCOPE_KEY = 'coolify_own_sfu_app';
10
+ export const OWN_SFU_TUNNEL_SCOPE_KEY = 'cloudflare_own_sfu_tunnel';
11
+ export const OWN_SFU_CONNECTOR_APP_SCOPE_KEY = 'coolify_own_sfu_connector_app';
12
+ /** Set to the app id before the key pair leaves, cleared after the pair is delivered and written to the app. */
13
+ export const OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY = 'own_sfu_keys_undelivered';
14
+ /** The app id once the company's key pair is delivered and in the app's env. */
15
+ export const OWN_SFU_KEYS_SCOPE_KEY = 'own_sfu_keys_set';
16
+ export const OWN_SFU_DEPLOY_PENDING_SCOPE_KEY = 'own_sfu_deploy_pending';
17
+ export const OWN_SFU_APPLIED_VERSION_SCOPE_KEY = 'own_sfu_applied_version';
18
+ export const OWN_SFU_APPLIED_SLOT_SCOPE_KEY = 'own_sfu_applied_slot';
19
+ export const OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY = 'own_sfu_applied_hostname';
20
+ export const SFU_INTERNAL_URL_SCOPE_KEY = 'sfu_internal_url';
21
+ export const SFU_PUBLIC_URL_SCOPE_KEY = 'sfu_public_url';
22
+ const KEYS_DELIVERY_ATTEMPTS = 3;
23
+ const CONFIG_ENV = 'LIVEKIT_CONFIG';
24
+ /** The Docker `ports_mappings` string for a slot: the UDP range and the TCP fallback port, one to one. */
25
+ export function sfuPortsMappings(slot) {
26
+ const { udpStart, udpEnd, tcp } = sfuSlotPorts(slot);
27
+ return `${udpStart}-${udpEnd}:${udpStart}-${udpEnd}/udp,${tcp}:${tcp}`;
28
+ }
29
+ /**
30
+ * LiveKit takes its whole config from one env value. `node_ip` is the
31
+ * server's address: the app is not on the host network, so the media ports
32
+ * reach it through the published mappings.
33
+ */
34
+ export function sfuConfig(input) {
35
+ const { udpStart, udpEnd, tcp } = sfuSlotPorts(input.slot);
36
+ return [
37
+ `port: ${LIVEKIT_SIGNALLING_PORT}`,
38
+ 'rtc:',
39
+ ` tcp_port: ${tcp}`,
40
+ ` port_range_start: ${udpStart}`,
41
+ ` port_range_end: ${udpEnd}`,
42
+ ' use_external_ip: false',
43
+ ` node_ip: ${input.nodeIp}`,
44
+ 'keys:',
45
+ ` ${input.keys.apiKey}: ${input.keys.apiSecret}`,
46
+ '',
47
+ ].join('\n');
48
+ }
49
+ function heldPairOf(value) {
50
+ if (!value)
51
+ return null;
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(value);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ const { apiKey, apiSecret } = (parsed ?? {});
60
+ return typeof apiKey === 'string' &&
61
+ typeof apiSecret === 'string' &&
62
+ KEY_PAIR_SHAPE.test(apiKey) &&
63
+ KEY_PAIR_SHAPE.test(apiSecret)
64
+ ? { apiKey, apiSecret }
65
+ : null;
66
+ }
67
+ function keysOf(config) {
68
+ const match = /^keys:\n {2}([A-Za-z0-9]+): ([A-Za-z0-9]+)$/m.exec(config ?? '');
69
+ return match ? { apiKey: match[1], apiSecret: match[2] } : null;
70
+ }
71
+ const KEY_PAIR_SHAPE = /^[A-Za-z0-9]{8,128}$/;
72
+ /**
73
+ * One company's LiveKit media server as a Coolify app: the image, its slot's
74
+ * ports published to the server, and a tunnel for signalling at `sfu.<domain>`.
75
+ * The company's single key pair is minted once, delivered to
76
+ * `target.refs.livekitKeys` as JSON, and written to the app's env; it is never
77
+ * recorded. Rules 1 to 6 of `CLAUDE.md` "Platform steps".
78
+ */
79
+ export async function provisionSfuApp(scope, deps, target, input) {
80
+ const { coolify, network, deliverSecret, runtime } = deps;
81
+ const label = `own step coolify:own-sfu for ${target.id}`;
82
+ sfuSlotPorts(input.slot);
83
+ const zoneId = await network.zoneId(input.apex);
84
+ if (!zoneId)
85
+ throw new Error(`${label}: no Cloudflare zone for ${input.apex}`);
86
+ const projectUuid = await input.project.ensure(coolify, scope);
87
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
88
+ const servers = await coolify.listServers();
89
+ const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
90
+ if (!server)
91
+ throw new Error(`${label}: no usable Coolify server is registered`);
92
+ if (!server.ip)
93
+ throw new Error(`${label}: the Coolify server has no address to give LiveKit`);
94
+ const existingApp = await coolify.findApplicationByName(target.names.sfuApp, {
95
+ environmentIds: [environment.id],
96
+ });
97
+ let appId;
98
+ if (existingApp) {
99
+ appId = existingApp.uuid;
100
+ }
101
+ else {
102
+ appId = (await coolify.createDockerImageApplication({
103
+ project_uuid: projectUuid,
104
+ server_uuid: server.uuid,
105
+ environment_name: environment.name,
106
+ environment_uuid: environment.uuid,
107
+ docker_registry_image_name: OWN_SFU_IMAGE,
108
+ docker_registry_image_tag: input.version,
109
+ ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
110
+ ports_mappings: sfuPortsMappings(input.slot),
111
+ name: target.names.sfuApp,
112
+ description: `LiveKit media server for ${target.id}`,
113
+ instant_deploy: false,
114
+ })).uuid;
115
+ }
116
+ scope.recordScopeId(OWN_SFU_APP_SCOPE_KEY, appId);
117
+ const recorded = scope.recordedIds();
118
+ const changed = recorded[OWN_SFU_APPLIED_VERSION_SCOPE_KEY] !== input.version ||
119
+ recorded[OWN_SFU_APPLIED_SLOT_SCOPE_KEY] !== String(input.slot) ||
120
+ recorded[OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY] !== input.hostname;
121
+ // Set on every run: a declared version or slot reaches an existing app here.
122
+ await coolify.updateApplication(appId, {
123
+ custom_internal_name: target.internal.sfu,
124
+ docker_registry_image_tag: input.version,
125
+ ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
126
+ ports_mappings: sfuPortsMappings(input.slot),
127
+ ...healthCheckSettings('/', LIVEKIT_SIGNALLING_PORT),
128
+ });
129
+ const liveConfig = (await coolify.listEnvs(appId)).find((e) => e.key === CONFIG_ENV && !e.is_preview)?.value;
130
+ let keys;
131
+ let keysJustSet = false;
132
+ if (scope.recordedIds()[OWN_SFU_KEYS_SCOPE_KEY]) {
133
+ const held = keysOf(liveConfig);
134
+ if (!held) {
135
+ throw new Error(`${label}: the app's ${CONFIG_ENV} no longer holds the company's key`);
136
+ }
137
+ keys = held;
138
+ deps.onSecret?.(keys.apiSecret);
139
+ }
140
+ else {
141
+ const lost = scope.recordedIds()[OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY];
142
+ const resumed = lost
143
+ ? heldPairOf((await deps.readSecret?.(target.refs.livekitKeys)) ?? null)
144
+ : null;
145
+ if (lost && !resumed) {
146
+ throw new Error(`${label}: a LiveKit key pair (${lost}) was minted but never confirmed delivered; remove the recorded key ${OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY} to mint a new one`);
147
+ }
148
+ if (resumed) {
149
+ keys = resumed;
150
+ deps.onSecret?.(keys.apiSecret);
151
+ keysJustSet = true;
152
+ }
153
+ else {
154
+ keys = (deps.mintLivekitCredentials ?? mintLivekitCredentials)();
155
+ if (!KEY_PAIR_SHAPE.test(keys.apiKey) || !KEY_PAIR_SHAPE.test(keys.apiSecret)) {
156
+ throw new Error(`${label}: the minted LiveKit key has characters the config cannot hold`);
157
+ }
158
+ deps.onSecret?.(keys.apiSecret);
159
+ scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, appId);
160
+ await scope.persist();
161
+ let delivered = false;
162
+ const payload = JSON.stringify({ apiKey: keys.apiKey, apiSecret: keys.apiSecret });
163
+ for (let attempt = 1; attempt <= KEYS_DELIVERY_ATTEMPTS && !delivered; attempt++) {
164
+ try {
165
+ await deliverSecret(target.refs.livekitKeys, payload);
166
+ delivered = true;
167
+ }
168
+ catch {
169
+ // Retried; the error is dropped in case it echoes the key.
170
+ }
171
+ }
172
+ if (!delivered) {
173
+ throw new Error(`${label}: the LiveKit key pair could not be delivered after ${KEYS_DELIVERY_ATTEMPTS} attempts`);
174
+ }
175
+ keysJustSet = true;
176
+ }
177
+ }
178
+ const config = sfuConfig({ slot: input.slot, nodeIp: server.ip, keys });
179
+ deps.onSecret?.(config);
180
+ const envChanged = liveConfig !== config;
181
+ const pending = Boolean(scope.recordedIds()[OWN_SFU_DEPLOY_PENDING_SCOPE_KEY]) || changed || envChanged;
182
+ if (pending) {
183
+ scope.recordScopeId(OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, '1');
184
+ await scope.persist();
185
+ }
186
+ if (envChanged) {
187
+ await coolify.bulkUpsertEnvs(appId, [{ key: CONFIG_ENV, value: config, literal: true }]);
188
+ }
189
+ if (keysJustSet) {
190
+ scope.recordScopeId(OWN_SFU_KEYS_SCOPE_KEY, appId);
191
+ scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, '');
192
+ await scope.persist();
193
+ }
194
+ const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
195
+ if (!existingApp || pending || !running) {
196
+ await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), label, 'the media server keeps its old version');
197
+ }
198
+ if (pending) {
199
+ scope.recordScopeId(OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, '');
200
+ await scope.persist();
201
+ }
202
+ const tunnels = await network.tunnels();
203
+ const tunnel = (await tunnels.findTunnelByName(target.names.sfuTunnel)) ??
204
+ (await tunnels.createTunnel(target.names.sfuTunnel));
205
+ scope.recordScopeId(OWN_SFU_TUNNEL_SCOPE_KEY, tunnel.id);
206
+ await tunnels.putTunnelConfig(tunnel.id, {
207
+ ingress: [
208
+ {
209
+ hostname: input.hostname,
210
+ service: `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`,
211
+ },
212
+ { service: 'http_status:404' },
213
+ ],
214
+ });
215
+ await ensureCloudflaredConnector({
216
+ coolify,
217
+ tunnels,
218
+ tunnelId: tunnel.id,
219
+ appName: target.names.sfuCloudflared,
220
+ projectUuid,
221
+ environment,
222
+ resolveServer: async () => server,
223
+ recordedConnectorAppId: scope.recordedIds()[OWN_SFU_CONNECTOR_APP_SCOPE_KEY] ?? null,
224
+ recordAppId: async (id) => {
225
+ scope.recordScopeId(OWN_SFU_CONNECTOR_APP_SCOPE_KEY, id);
226
+ await scope.persist();
227
+ },
228
+ });
229
+ const dns = await network.dns();
230
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
231
+ const [record] = await dns.findDnsRecord(input.apex, input.hostname, 'CNAME');
232
+ const cname = { type: 'CNAME', name: input.hostname, content: dnsTarget, proxied: true };
233
+ if (!record)
234
+ await dns.createDnsRecord(input.apex, cname);
235
+ else if (record.content !== dnsTarget)
236
+ await dns.updateDnsRecord(input.apex, record.id, cname);
237
+ await recordOwnDnsHostname(scope, input.hostname);
238
+ scope.recordScopeId(OWN_SFU_APPLIED_VERSION_SCOPE_KEY, input.version);
239
+ scope.recordScopeId(OWN_SFU_APPLIED_SLOT_SCOPE_KEY, String(input.slot));
240
+ scope.recordScopeId(OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
241
+ scope.recordScopeId(SFU_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`);
242
+ scope.recordScopeId(SFU_PUBLIC_URL_SCOPE_KEY, `https://${input.hostname}`);
243
+ return { resourceId: appId };
244
+ }
245
+ /**
246
+ * The addresses of the company's media server, from its recorded ids. Before
247
+ * the step is recorded the pair is empty, which `resolveCallsSfuUrls` refuses:
248
+ * an own org's calls service never falls back to the shared SFU's addresses.
249
+ */
250
+ export function ownSfuUrls(ids) {
251
+ return {
252
+ privateUrl: ids[SFU_INTERNAL_URL_SCOPE_KEY] ?? '',
253
+ publicUrl: ids[SFU_PUBLIC_URL_SCOPE_KEY] ?? '',
254
+ };
255
+ }
@@ -10,6 +10,7 @@ import { type AgoraHostPort } from './agora.js';
10
10
  import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
11
11
  import { type StepAction } from './diff.js';
12
12
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
13
+ import { type LoginHostDiscoveryPort } from './login-host.js';
13
14
  import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
14
15
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
15
16
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
@@ -172,6 +173,38 @@ export interface StepExecutor {
172
173
  * an executor without it supplies none.
173
174
  */
174
175
  ownEdgePorts?(): Partial<Record<EdgePortName, boolean>>;
176
+ /**
177
+ * What the executor's own files dependencies supply (`ownServices.provision.files`):
178
+ * valid keys worker settings, and a bucket port. The planner blocks an own
179
+ * org's files host until both are true. Optional: an executor without it supplies neither.
180
+ */
181
+ ownFilesDeps?(): {
182
+ keys: boolean;
183
+ bucket: boolean;
184
+ };
185
+ /**
186
+ * Which ports an own org's files storage key step needs this executor has: the R2 key port, and
187
+ * `storeR2Credentials` on the org's files-organisation port. Optional: an executor without it
188
+ * leaves the step unplanned.
189
+ */
190
+ ownFilesR2Ports?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<{
191
+ r2Keys: boolean;
192
+ storeR2Credentials: boolean;
193
+ }>;
194
+ /** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
195
+ ownMailKeyMintable?(): boolean;
196
+ /**
197
+ * Whether the host's scoped ports for this own org include a files-organisation
198
+ * port. The planner plans no `files:organisation` step for an own org while
199
+ * this says false. Optional: an executor without it is taken to have the port.
200
+ */
201
+ ownFilesOrganisationPort?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
202
+ /**
203
+ * Every secret this executor minted or read so far (peer password, bridge
204
+ * secret, invite code and link). The host's run adds them to its redactor
205
+ * after each step, so an error or progress line that quotes one is masked.
206
+ */
207
+ secretsHandled?(): readonly string[];
175
208
  }
176
209
  /**
177
210
  * #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
@@ -232,6 +265,8 @@ export interface PlatformStepExecutor {
232
265
  ai?: string;
233
266
  archon?: string;
234
267
  mail?: string;
268
+ /** See `PlanPlatformOptions.blocked.relay` (`platform-diff.ts`). */
269
+ relay?: string;
235
270
  /** #604: see `PlanPlatformOptions.blocked.accessProvider` (`platform-diff.ts`). */
236
271
  accessProvider?: string;
237
272
  /** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
@@ -376,6 +411,14 @@ export interface FoundryPlanApplyOptions {
376
411
  * before zitadel-login's binding can be written.
377
412
  */
378
413
  loginHostBindings?: boolean;
414
+ /**
415
+ * Reads `GET https://<login host>/.well-known/openid-configuration` for a
416
+ * company's login host (5 s limit). Optional, and without it every company's
417
+ * Agora keeps the platform issuer and the plan carries a blocked line for the
418
+ * switch. The host must route the company's login host to a login app that
419
+ * serves discovery (https://github.com/wtfalch/auth/pull/233).
420
+ */
421
+ loginHostDiscovery?: LoginHostDiscoveryPort;
379
422
  /**
380
423
  * True when the host's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`
381
424
  * (`@wtfalch/providers` at the version that has it). Optional, and false
@@ -395,6 +438,12 @@ export interface FoundryPlanApplyOptions {
395
438
  * reported blocked instead of planned.
396
439
  */
397
440
  edgeWorkers?: boolean;
441
+ /**
442
+ * True when the host's executor was built with `ownerInviteMail`. Optional,
443
+ * and false without it: each owner's invite is then reported blocked
444
+ * instead of planned.
445
+ */
446
+ ownerInviteMail?: boolean;
398
447
  /**
399
448
  * #177: true when the host's executor was built with its own `filesOrganisation`
400
449
  * / `aiOrganisation` port. Without one, the org step needs the platform's
@@ -408,6 +457,32 @@ export interface FoundryPlanApplyOptions {
408
457
  * reported blocked instead of planned to fail.
409
458
  */
410
459
  edge?: boolean;
460
+ /**
461
+ * The executor was built with an `ownServices` port. Without it, an org
462
+ * that runs its own services (`services.own`) is reported blocked and none of
463
+ * its steps run.
464
+ */
465
+ ownServices?: boolean;
466
+ /**
467
+ * Says whether an own org's media server slot has its ports open on the
468
+ * Coolify server. Foundry opens none, so without this the plan keeps a
469
+ * fixed line naming them. A call that fails or takes over five seconds
470
+ * counts as not open.
471
+ */
472
+ sfuPortsOpen?: (range: {
473
+ udpStart: number;
474
+ udpEnd: number;
475
+ tcp: number;
476
+ }) => Promise<boolean>;
477
+ /**
478
+ * Narrows every org plan to the steps `step` accepts, and its blocked items
479
+ * to the keys `blocked` accepts, so a plan, its stale check and its apply
480
+ * all see the same slice. Absent: the whole plan.
481
+ */
482
+ onlySteps?: {
483
+ step(action: StepAction): boolean;
484
+ blocked(key: string): boolean;
485
+ };
411
486
  /** Overridable for tests; defaults to `() => new Date()`. */
412
487
  now?: () => Date;
413
488
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -466,12 +541,6 @@ export interface FoundryPlanApplyOptions {
466
541
  heldKeys?: HeldKeyResolver;
467
542
  };
468
543
  }
469
- /**
470
- * The #19 implementation of {@link PlanApply}: computes a plan against live
471
- * state, and refuses to apply one that has gone stale, one that touches a
472
- * destructive or migrating step without the org's name typed, or one the
473
- * actor is not authorized to run.
474
- */
475
544
  export declare class FoundryPlanApply implements PlanApply {
476
545
  private readonly store;
477
546
  private readonly readers;
@@ -483,12 +552,17 @@ export declare class FoundryPlanApply implements PlanApply {
483
552
  private readonly agora;
484
553
  private readonly jobsScheduler;
485
554
  private readonly loginHostBindings;
555
+ private readonly loginHostDiscovery;
486
556
  private readonly zitadelIdTokenUserinfo;
487
557
  private readonly broker;
488
558
  private readonly edgeWorkers;
559
+ private readonly ownerInviteMail;
489
560
  private readonly hostFiles;
490
561
  private readonly hostAi;
491
562
  private readonly hostEdge;
563
+ private readonly hostOwnServices;
564
+ private readonly sfuPortsOpen;
565
+ private readonly onlySteps;
492
566
  private readonly now;
493
567
  private readonly leaseTtlMs;
494
568
  private readonly appliedVersions;
@@ -630,6 +704,8 @@ export declare class FoundryPlanApply implements PlanApply {
630
704
  private refusePlatform;
631
705
  private refuseDestroy;
632
706
  private runSteps;
707
+ /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
708
+ private readSfuPortsOpen;
633
709
  private computeFreshPlan;
634
710
  /**
635
711
  * An entry may not take a name foundry owns (`locked-hostnames.ts`). The
@@ -646,12 +722,20 @@ export declare class FoundryPlanApply implements PlanApply {
646
722
  * org step that needs one is blocked.
647
723
  */
648
724
  private orgPlatformReadiness;
725
+ /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
726
+ private ownMailKeyHeld;
727
+ /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
728
+ private ownResumableRefs;
729
+ /** The relay is declared and has recorded the routes it last applied: port 25 reaches the mail servers. */
730
+ private mailRelayApplied;
731
+ private sharedPlatformReadiness;
649
732
  /**
650
733
  * #177: `AgoraHostValues` from the applied platform, the same
651
734
  * `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
652
735
  * host supplies no `agora` port of its own. An explicit `agora` (#164)
653
736
  * always wins, unchanged, so a host that already has its own values
654
- * (perhaps not yet wired to this platform) keeps them.
737
+ * (perhaps not yet wired to this platform) keeps them. An org that owns its
738
+ * services never takes the host's values: they belong to the shared services.
655
739
  */
656
740
  private orgAgoraHost;
657
741
  /**
@@ -667,6 +751,16 @@ export declare class FoundryPlanApply implements PlanApply {
667
751
  /** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
668
752
  private requirePlatformDeps;
669
753
  private computeFreshPlatformPlan;
754
+ /**
755
+ * What the shared mail relay needs from the orgs. A route exists for an org
756
+ * that owns its services, serves mail on its own domain and has recorded its
757
+ * mail server's SMTP address (`mail_smtp_internal`, written by the company
758
+ * mail step). Every other org with mail on its own domain, the platform
759
+ * apex and the shared no-domain apex are platform domains: the platform's Stalwart receives them. An org
760
+ * that owns its services but has no mail server yet appears in neither list,
761
+ * so the relay refuses its mail until the server exists.
762
+ */
763
+ private mailRelayInputs;
670
764
  private sfuBlockedReason;
671
765
  /**
672
766
  * {@link runSteps}, for the platform. The ids the steps return are recorded