@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,101 @@
1
+ import { ownsServices } from '../org.js';
2
+ import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
3
+ import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
4
+ /**
5
+ * F6: the Postgres databases of a company that get a nightly backup, and the
6
+ * recorded id each one's uuid sits under. The keys are second, documented
7
+ * copies of the constants in `executor.ts` and `platform-providers.ts` (the
8
+ * way `PLATFORM_BACKUP_DATABASE_SCOPE_KEYS` copies the platform's): a renamed
9
+ * key shows up as a database the planner no longer sees, and the test in
10
+ * `platform-providers.backup.test.ts` plans every name here from a recorded id.
11
+ * `app` is the hosting entry's database, recorded as a resource id.
12
+ */
13
+ export function companyDatabaseKeys(org) {
14
+ return {
15
+ app: `coolify:database:${org.id}`,
16
+ cms: 'coolify_cms_database',
17
+ auth: 'coolify_platform_auth_database',
18
+ files: 'coolify_platform_files_database',
19
+ jobs: 'coolify_jobs_database',
20
+ chat: 'coolify_chat_service_database',
21
+ calls: 'coolify_calls_service_database',
22
+ synapse: 'coolify_calls_synapse_database',
23
+ mas: 'coolify_calls_mas_database',
24
+ chat_synapse: 'coolify_chat_synapse_database',
25
+ chat_mas: 'coolify_chat_mas_database',
26
+ boule: BOULE_DATABASE_SCOPE_KEY,
27
+ chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
28
+ chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
29
+ };
30
+ }
31
+ /** What every recorded-id key of a company database's backup schedule starts with. */
32
+ export const BACKUP_SCOPE_PREFIX = 'backup_';
33
+ /** The recorded-id key a company database's backup schedule uuid is stored under. */
34
+ export function backupScopeKey(db) {
35
+ return `${BACKUP_SCOPE_PREFIX}${db}`;
36
+ }
37
+ /** The databases an upgrade of `service` can migrate, by the names in {@link companyDatabaseKeys}. Empty: not coverable here. */
38
+ export function databasesForService(service) {
39
+ switch (service) {
40
+ case 'zitadel':
41
+ return ['auth'];
42
+ case 'files':
43
+ return ['files'];
44
+ case 'jobs':
45
+ return ['jobs'];
46
+ case 'synapse':
47
+ return ['synapse', 'chat_synapse'];
48
+ case 'mas':
49
+ return ['mas', 'chat_mas'];
50
+ case 'postgres':
51
+ return 'all';
52
+ default:
53
+ return [];
54
+ }
55
+ }
56
+ /**
57
+ * One `coolify:backup:<orgSlug>:<db>` step per company database that has a
58
+ * recorded uuid and no recorded backup yet. `storageUuid` undefined means the
59
+ * host did not wire backups, so nothing is planned and nothing is said; null
60
+ * means the platform's backup storage is not registered, so each database is
61
+ * reported as blocked instead (rule 2). A company that runs its own mail
62
+ * server carries a note that the Stalwart volume has no Postgres to dump.
63
+ */
64
+ export function planCompanyBackups(org, recordedIds, storageUuid) {
65
+ const steps = [];
66
+ const blocked = [];
67
+ if (storageUuid === undefined)
68
+ return { steps, blocked };
69
+ for (const [db, key] of Object.entries(companyDatabaseKeys(org))) {
70
+ const databaseId = recordedIds[key];
71
+ if (!databaseId || recordedIds[backupScopeKey(db)])
72
+ continue;
73
+ if (!storageUuid) {
74
+ blocked.push({
75
+ description: `${org.slug}'s ${db} database backup is blocked, not planned: the platform's backup storage is not registered yet`,
76
+ key: `backup-blocked:${org.slug}:${db}`,
77
+ });
78
+ continue;
79
+ }
80
+ steps.push({
81
+ action: {
82
+ system: 'coolify',
83
+ kind: 'backup',
84
+ key: `${org.slug}:${db}`,
85
+ desired: { databaseId, database: db, storageUuid },
86
+ },
87
+ step: {
88
+ description: `schedule a nightly backup of ${org.slug}'s ${db} database to the platform's backup bucket`,
89
+ destroys: false,
90
+ migrates: false,
91
+ },
92
+ });
93
+ }
94
+ if (ownsServices(org)) {
95
+ blocked.push({
96
+ description: `${org.slug}'s mail server keeps its data on a volume, not in Postgres: no backup covers it yet`,
97
+ key: 'backup-not-covered:mail',
98
+ });
99
+ }
100
+ return { steps, blocked };
101
+ }
@@ -69,6 +69,12 @@ export interface DeployAndWaitPort {
69
69
  deploymentUuid: string;
70
70
  status: string;
71
71
  }>;
72
+ /** Present on a real client; `build-record.ts` reads it to record the built commit. */
73
+ listApplicationDeployments?(uuid: string, take?: number): Promise<Array<{
74
+ deployment_uuid: string;
75
+ commit?: string;
76
+ status: string;
77
+ }>>;
72
78
  }
73
79
  /**
74
80
  * How a call site proves the new version is the one that answers (or that
@@ -114,7 +120,9 @@ export interface DeployHealthRuntime {
114
120
  * with no such ordering constraint calls {@link waitForNewDeployment}
115
121
  * instead, never this plus {@link waitForHealthyVersion} by hand.
116
122
  */
117
- export declare function waitForDeploymentFinished(coolify: DeployAndWaitPort, appId: string, runtime: DeployHealthRuntime, label: string, appliedRefDescription: string): Promise<void>;
123
+ export declare function waitForDeploymentFinished(coolify: DeployAndWaitPort, appId: string, runtime: DeployHealthRuntime, label: string, appliedRefDescription: string): Promise<{
124
+ deploymentUuid: string;
125
+ }>;
118
126
  /**
119
127
  * Phase 2 alone: the health check named by `health.kind`, with no deploy and
120
128
  * no Coolify polling -- see {@link waitForDeploymentFinished}'s own doc
@@ -133,4 +141,6 @@ export declare function waitForHealthyVersion(appId: string, health: DeployHealt
133
141
  * what is still live so a caller's error is actionable without the helper
134
142
  * itself knowing any step's scope keys.
135
143
  */
136
- export declare function waitForNewDeployment(coolify: DeployAndWaitPort, appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<void>;
144
+ export declare function waitForNewDeployment(coolify: DeployAndWaitPort, appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<{
145
+ deploymentUuid: string;
146
+ }>;
@@ -93,6 +93,7 @@ export async function waitForDeploymentFinished(coolify, appId, runtime, label,
93
93
  if (status !== DEPLOYMENT_SUCCEEDED) {
94
94
  throw new Error(`${label}: the deployment (${deploymentUuid}) of ${appId} did not finish (status: ${status}); ${appliedRefDescription}. Apply again`);
95
95
  }
96
+ return { deploymentUuid };
96
97
  }
97
98
  /**
98
99
  * Phase 2 alone: the health check named by `health.kind`, with no deploy and
@@ -132,8 +133,9 @@ export async function waitForHealthyVersion(appId, health, runtime, sleep, label
132
133
  * itself knowing any step's scope keys.
133
134
  */
134
135
  export async function waitForNewDeployment(coolify, appId, health, runtime, sleep, label, appliedRefDescription) {
135
- await waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription);
136
+ const { deploymentUuid } = await waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription);
136
137
  await waitForHealthyVersion(appId, health, runtime, sleep, label, appliedRefDescription);
138
+ return { deploymentUuid };
137
139
  }
138
140
  async function matchesHealth(fetchImpl, health) {
139
141
  try {
@@ -0,0 +1,73 @@
1
+ import type { CoolifyPort } from './executor.js';
2
+ /** The Docker network of the server's default destination: everything on the platform side. */
3
+ export declare const PLATFORM_NETWORK = "coolify";
4
+ /**
5
+ * Whether a customer company gets its own `co-<slug>` network. Off for go-live: every company shares the
6
+ * platform network, because Archon (on it) calls each company's files, mail admin and jobs at internal
7
+ * addresses and Coolify cannot put Archon on a second network. Per-company networks return after go-live,
8
+ * once a design lists every call that crosses the split. `let` so tests can turn it on.
9
+ */
10
+ export declare let COMPANY_NETWORKS: boolean;
11
+ /** Test-only: turns the per-company networks on or off. */
12
+ export declare function setCompanyNetworksForTests(on: boolean): void;
13
+ /** A company runs on the platform network: the owner always, a customer while {@link COMPANY_NETWORKS} is off. */
14
+ export declare function onPlatformNetwork(ownerCompany: boolean | undefined): boolean;
15
+ /** Coolify's destination name for a company, so a listing says who owns it. */
16
+ export declare const COMPANY_DESTINATION_NAME_PREFIX = "foundry:";
17
+ /** Docker's default address pools give about 30 user networks; stop a little short. */
18
+ export declare const DEFAULT_MAX_DESTINATIONS = 25;
19
+ /** The company's own Docker network: `co-<slug>`. */
20
+ export declare function companyNetworkName(slug: string): string;
21
+ /** Refuses a name Docker or this module cannot take; the message names no value. */
22
+ export declare function validateNetworkName(network: unknown): string;
23
+ /** A server already holds as many networks as the capacity gate allows. */
24
+ export declare class DestinationCapacityError extends Error {
25
+ readonly limit: number;
26
+ constructor(limit: number);
27
+ }
28
+ type DestinationRaw = Pick<CoolifyPort, 'raw'>;
29
+ /** The uuid of the server's destination on `network`, or undefined. Creates nothing. */
30
+ export declare function findDestination(coolify: DestinationRaw, serverUuid: string, network: string): Promise<string | undefined>;
31
+ /**
32
+ * The uuid of the server's destination on `network`; creates it when absent.
33
+ * Coolify makes the Docker network and attaches its proxy to it when the
34
+ * destination is created. Each call has a 60 second timeout. A create that
35
+ * fails (Coolify answers 409 when the network already exists) is followed by
36
+ * one more listing; found there, that uuid is used, so two runs at once and a
37
+ * re-run after a crash converge on one destination. `maxDestinations` stops a
38
+ * create on a server that already holds that many (`DestinationCapacityError`,
39
+ * before anything is created).
40
+ */
41
+ export declare function ensureDestination(coolify: DestinationRaw, serverUuid: string, network: string, options?: {
42
+ name?: string;
43
+ maxDestinations?: number;
44
+ }): Promise<string>;
45
+ type PlacedPort = Partial<Pick<CoolifyPort, 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase'>>;
46
+ /**
47
+ * The Coolify port with `destination_uuid` added to the four create calls
48
+ * (the other calls pass through). `resolve` gives the destination of the
49
+ * input's `server_uuid`; it runs once per server for this port. A create that
50
+ * already names a destination keeps it. A `resolve` that gives nothing leaves
51
+ * the create as it was. Composes with `limited`: `limited(placed(port, ...))`.
52
+ */
53
+ export declare function placed<C extends PlacedPort>(coolify: C, resolve: (serverUuid: string) => Promise<string | undefined>): C;
54
+ /**
55
+ * `placed` on the platform's destination (Docker network `coolify`) of each
56
+ * server. Once a server has a company destination, a create without
57
+ * `destination_uuid` is refused by Coolify, so every platform create names the
58
+ * default one. A server whose listing has no `coolify` destination, and a port
59
+ * with no `raw`, get the create unchanged.
60
+ */
61
+ export declare function placedOnPlatform<C extends PlacedPort & {
62
+ raw?: CoolifyPort['raw'];
63
+ }>(coolify: C): C;
64
+ /**
65
+ * `placed`, with the destination chosen by the step now running: `scope` gives a key (the company) and
66
+ * the resolver for it, or nothing, and a create then goes out as it was. One `placed` wrapper is made
67
+ * per key, so its answers are never shared between companies.
68
+ */
69
+ export declare function placedByScope<C extends PlacedPort>(coolify: C, scope: () => {
70
+ key: string;
71
+ resolve: (serverUuid: string) => Promise<string | undefined>;
72
+ } | undefined): C;
73
+ export {};
@@ -0,0 +1,217 @@
1
+ import { OWN_LIMITS_TIMEOUT_MS } from './own-limits.js';
2
+ /** The Docker network of the server's default destination: everything on the platform side. */
3
+ export const PLATFORM_NETWORK = 'coolify';
4
+ /**
5
+ * Whether a customer company gets its own `co-<slug>` network. Off for go-live: every company shares the
6
+ * platform network, because Archon (on it) calls each company's files, mail admin and jobs at internal
7
+ * addresses and Coolify cannot put Archon on a second network. Per-company networks return after go-live,
8
+ * once a design lists every call that crosses the split. `let` so tests can turn it on.
9
+ */
10
+ export let COMPANY_NETWORKS = false;
11
+ /** Test-only: turns the per-company networks on or off. */
12
+ export function setCompanyNetworksForTests(on) {
13
+ COMPANY_NETWORKS = on;
14
+ }
15
+ /** A company runs on the platform network: the owner always, a customer while {@link COMPANY_NETWORKS} is off. */
16
+ export function onPlatformNetwork(ownerCompany) {
17
+ return ownerCompany === true || !COMPANY_NETWORKS;
18
+ }
19
+ /** Coolify's destination name for a company, so a listing says who owns it. */
20
+ export const COMPANY_DESTINATION_NAME_PREFIX = 'foundry:';
21
+ /** A Docker network name is at most 64 characters; keep room under it. */
22
+ const MAX_NETWORK_LENGTH = 60;
23
+ const NETWORK_NAME = /^[a-z0-9][a-z0-9_.-]*$/;
24
+ /** Docker's default address pools give about 30 user networks; stop a little short. */
25
+ export const DEFAULT_MAX_DESTINATIONS = 25;
26
+ /** The company's own Docker network: `co-<slug>`. */
27
+ export function companyNetworkName(slug) {
28
+ const network = `co-${slug}`;
29
+ return validateNetworkName(network);
30
+ }
31
+ /** Refuses a name Docker or this module cannot take; the message names no value. */
32
+ export function validateNetworkName(network) {
33
+ if (typeof network !== 'string' || network.length === 0)
34
+ throw new Error('destination: the network name is empty');
35
+ if (network.length > MAX_NETWORK_LENGTH)
36
+ throw new Error(`destination: the network name is over ${MAX_NETWORK_LENGTH} characters`);
37
+ if (!NETWORK_NAME.test(network))
38
+ throw new Error('destination: the network name has a character outside a-z, 0-9, _, . and -');
39
+ return network;
40
+ }
41
+ /** A server already holds as many networks as the capacity gate allows. */
42
+ export class DestinationCapacityError extends Error {
43
+ limit;
44
+ constructor(limit) {
45
+ super(`destination: the server already has ${limit} destinations; raise Docker's address pools`);
46
+ this.limit = limit;
47
+ this.name = 'DestinationCapacityError';
48
+ }
49
+ }
50
+ function readList(body) {
51
+ if (!Array.isArray(body))
52
+ throw new Error('destination: the destination list is not an array');
53
+ const out = [];
54
+ for (const row of body) {
55
+ const r = row;
56
+ if (r && typeof r.uuid === 'string' && typeof r.network === 'string')
57
+ out.push({ uuid: r.uuid, network: r.network });
58
+ }
59
+ return out;
60
+ }
61
+ async function bounded(work, what) {
62
+ let timer;
63
+ try {
64
+ return await Promise.race([
65
+ work,
66
+ new Promise((_, reject) => {
67
+ timer = setTimeout(() => reject(new Error(`destination: ${what} timed out`)), OWN_LIMITS_TIMEOUT_MS);
68
+ }),
69
+ ]);
70
+ }
71
+ finally {
72
+ clearTimeout(timer);
73
+ }
74
+ }
75
+ /** The uuid of the server's destination on `network`, or undefined. Creates nothing. */
76
+ export async function findDestination(coolify, serverUuid, network) {
77
+ const path = `/servers/${encodeURIComponent(serverUuid)}/destinations`;
78
+ const body = await bounded(coolify.raw('GET', path), 'listing the destinations');
79
+ // A listing that is not an array names no destination: the create goes out as it was.
80
+ if (!Array.isArray(body))
81
+ return undefined;
82
+ return readList(body).find((r) => r.network === network)?.uuid;
83
+ }
84
+ /**
85
+ * The uuid of the server's destination on `network`; creates it when absent.
86
+ * Coolify makes the Docker network and attaches its proxy to it when the
87
+ * destination is created. Each call has a 60 second timeout. A create that
88
+ * fails (Coolify answers 409 when the network already exists) is followed by
89
+ * one more listing; found there, that uuid is used, so two runs at once and a
90
+ * re-run after a crash converge on one destination. `maxDestinations` stops a
91
+ * create on a server that already holds that many (`DestinationCapacityError`,
92
+ * before anything is created).
93
+ */
94
+ export async function ensureDestination(coolify, serverUuid, network, options = {}) {
95
+ validateNetworkName(network);
96
+ const path = `/servers/${encodeURIComponent(serverUuid)}/destinations`;
97
+ const list = async () => readList(await bounded(coolify.raw('GET', path), 'listing the destinations'));
98
+ const found = (rows) => rows.find((r) => r.network === network)?.uuid;
99
+ const rows = await list();
100
+ const have = found(rows);
101
+ if (have)
102
+ return have;
103
+ const limit = options.maxDestinations ?? DEFAULT_MAX_DESTINATIONS;
104
+ if (rows.length >= limit)
105
+ throw new DestinationCapacityError(limit);
106
+ try {
107
+ const created = (await bounded(coolify.raw('POST', path, {
108
+ network,
109
+ name: options.name ?? `${COMPANY_DESTINATION_NAME_PREFIX}${network}`,
110
+ }), 'creating the destination'));
111
+ if (created && typeof created.uuid === 'string')
112
+ return created.uuid;
113
+ }
114
+ catch {
115
+ // Fall through to one more listing: a 409 means someone else made it.
116
+ const again = found(await list());
117
+ if (again)
118
+ return again;
119
+ throw new Error('destination: creating the destination failed');
120
+ }
121
+ const after = found(await list());
122
+ if (after)
123
+ return after;
124
+ throw new Error('destination: the created destination is not in the listing');
125
+ }
126
+ const CREATES = [
127
+ 'createGithubAppApplication',
128
+ 'createDockerImageApplication',
129
+ 'createDockerfileApplication',
130
+ 'createPostgresDatabase',
131
+ ];
132
+ /**
133
+ * The Coolify port with `destination_uuid` added to the four create calls
134
+ * (the other calls pass through). `resolve` gives the destination of the
135
+ * input's `server_uuid`; it runs once per server for this port. A create that
136
+ * already names a destination keeps it. A `resolve` that gives nothing leaves
137
+ * the create as it was. Composes with `limited`: `limited(placed(port, ...))`.
138
+ */
139
+ export function placed(coolify, resolve) {
140
+ const cache = new Map();
141
+ const destinationOf = (serverUuid) => {
142
+ let hit = cache.get(serverUuid);
143
+ if (!hit) {
144
+ hit = resolve(serverUuid);
145
+ cache.set(serverUuid, hit);
146
+ hit.catch(() => cache.delete(serverUuid));
147
+ }
148
+ return hit;
149
+ };
150
+ const over = {};
151
+ for (const method of CREATES) {
152
+ const create = coolify[method];
153
+ if (!create)
154
+ continue;
155
+ over[method] = async (input) => {
156
+ if (input.destination_uuid)
157
+ return create.call(coolify, input);
158
+ const destination = await destinationOf(input.server_uuid);
159
+ return create.call(coolify, (destination ? { ...input, destination_uuid: destination } : input));
160
+ };
161
+ }
162
+ return new Proxy(coolify, {
163
+ get(target, prop) {
164
+ if (typeof prop === 'string' && prop in over)
165
+ return over[prop];
166
+ const value = Reflect.get(target, prop, target);
167
+ return typeof value === 'function' ? value.bind(target) : value;
168
+ },
169
+ });
170
+ }
171
+ /**
172
+ * `placed` on the platform's destination (Docker network `coolify`) of each
173
+ * server. Once a server has a company destination, a create without
174
+ * `destination_uuid` is refused by Coolify, so every platform create names the
175
+ * default one. A server whose listing has no `coolify` destination, and a port
176
+ * with no `raw`, get the create unchanged.
177
+ */
178
+ export function placedOnPlatform(coolify) {
179
+ const { raw } = coolify;
180
+ if (typeof raw !== 'function')
181
+ return coolify;
182
+ return placed(coolify, (serverUuid) => findDestination({ raw: raw.bind(coolify) }, serverUuid, PLATFORM_NETWORK));
183
+ }
184
+ /**
185
+ * `placed`, with the destination chosen by the step now running: `scope` gives a key (the company) and
186
+ * the resolver for it, or nothing, and a create then goes out as it was. One `placed` wrapper is made
187
+ * per key, so its answers are never shared between companies.
188
+ */
189
+ export function placedByScope(coolify, scope) {
190
+ const perKey = new Map();
191
+ const over = {};
192
+ for (const method of CREATES) {
193
+ const create = coolify[method];
194
+ if (!create)
195
+ continue;
196
+ over[method] = async (input) => {
197
+ const current = scope();
198
+ if (!current)
199
+ return create.call(coolify, input);
200
+ let wrapper = perKey.get(current.key);
201
+ if (!wrapper) {
202
+ wrapper = placed(coolify, current.resolve);
203
+ perKey.set(current.key, wrapper);
204
+ }
205
+ const made = wrapper[method];
206
+ return made.call(wrapper, input);
207
+ };
208
+ }
209
+ return new Proxy(coolify, {
210
+ get(target, prop) {
211
+ if (typeof prop === 'string' && prop in over)
212
+ return over[prop];
213
+ const value = Reflect.get(target, prop, target);
214
+ return typeof value === 'function' ? value.bind(target) : value;
215
+ },
216
+ });
217
+ }
@@ -41,8 +41,12 @@ export interface DraftStep {
41
41
  * New ones go here, not in new positional arguments (rule 7).
42
42
  */
43
43
  export interface PlanOrgOptions {
44
+ /** The platform's edge has the `oikos.host` zone: a company without a domain waits on it for its own services. */
45
+ oikosZoneReady?: boolean;
44
46
  /** #267: the executor was built with a `loginHostBindings` port. */
45
47
  loginHostBindings?: boolean;
48
+ /** A live read said the org's login host serves OIDC discovery as `https://<host>`. Absent: not read, so Agora keeps the platform issuer. */
49
+ loginHostServesDiscovery?: boolean;
46
50
  /** The executor's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`; without it `planIdTokenUserinfo` plans nothing. */
47
51
  zitadelIdTokenUserinfo?: boolean;
48
52
  /** F5: the executor was built with a `broker` port. */
@@ -59,6 +63,47 @@ export interface PlanOrgOptions {
59
63
  ownerInviteMail?: boolean;
60
64
  /** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
61
65
  platformHostnames?: readonly string[];
66
+ /** The executor was built with an `ownServices` port. Without it an own org's own-service steps are blocked. */
67
+ ownServices?: boolean;
68
+ /** An own org's Lettermint key (`orgTarget(org).refs.brokerMailKey`) is held. */
69
+ ownMailKeyHeld?: boolean;
70
+ /** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
71
+ ownMailKeyMintable?: boolean;
72
+ /** The own jobs admin token's held-key ref holds a value. */
73
+ ownJobsTokenHeld?: boolean;
74
+ /** The own media server's key pair ref holds a value. */
75
+ ownSfuKeysHeld?: boolean;
76
+ /** The relay is declared and has applied a route list; an own org's mail server waits for it. */
77
+ mailRelayApplied?: boolean;
78
+ /** The host port an own org's mail server publishes for the relay (`plan-apply.ts` allocates it). */
79
+ ownMailSmtpPort?: number;
80
+ /**
81
+ * The relay has applied a route for this org's mail domain. `false` holds the org's `coolify:mail`
82
+ * step: it publishes the MX, and until the relay routes the domain senders get a permanent 5xx.
83
+ * Absent counts as held.
84
+ */
85
+ mailRelayRoute?: boolean;
86
+ /** The host confirmed the ports of an own org's media server slot are open on the server. */
87
+ sfuPortsOpen?: boolean;
88
+ /** What the executor's own files dependencies supply: valid keys worker settings, and a bucket port. Absent counts as neither. */
89
+ ownFilesDeps?: {
90
+ keys: boolean;
91
+ bucket: boolean;
92
+ };
93
+ /**
94
+ * Which ports the own files storage key step needs the host supplied. Absent: the executor was not
95
+ * asked, and the step is neither planned nor reported.
96
+ */
97
+ ownFilesR2Ports?: {
98
+ r2Keys: boolean;
99
+ storeR2Credentials: boolean;
100
+ };
101
+ /**
102
+ * F6: the uuid of the platform's backup storage on Coolify. Absent: the host
103
+ * wired no backups, so a company's databases get no backup step. `null`: wired,
104
+ * but the storage is not registered, so each database is reported blocked.
105
+ */
106
+ backupStorageUuid?: string | null;
62
107
  }
63
108
  export interface PlanDetail {
64
109
  orgId: OrgId;
@@ -128,14 +173,7 @@ export declare function toPlan(detail: PlanDetail): Plan;
128
173
  * against this seam.
129
174
  */
130
175
  export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness, options?: PlanOrgOptions): PlanDetail;
131
- /**
132
- * #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
133
- * only shape {@link agoraHostValuesFromPlatform} or a host-supplied
134
- * `AgoraHostPort` ever hands back), or `null` when there is no issuer to
135
- * read one from. What {@link loginHostnameFor} compares the org's own
136
- * computed `auth.<domain>` against, so the platform's own org never plans
137
- * its login host equal to the shared ZITADEL instance's own.
138
- */
176
+ /** True when `org` is the platform's owner company. */
139
177
  export declare function isPlatformOwnOrg(org: Pick<DeclaredOrg, 'id'>, platformOwnerOrgId: string | null): boolean;
140
178
  /**
141
179
  * What {@link planOrgPostgres} reports about the org's one shared Postgres