@wtfalch/foundry 0.3.0 → 0.5.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 (71) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +22 -4
  18. package/dist/calls-sfu.js +22 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0014_calls_enabled.sql +7 -0
  22. package/dist/migrations/0015_domain_unique.sql +39 -0
  23. package/dist/migrations/0016_platform.sql +61 -0
  24. package/dist/migrations/0017_requested_changes.sql +42 -0
  25. package/dist/migrations/0018_platform_apply.sql +19 -0
  26. package/dist/org.d.ts +18 -2
  27. package/dist/org.js +56 -0
  28. package/dist/plan/agora.d.ts +77 -0
  29. package/dist/plan/agora.js +127 -0
  30. package/dist/plan/calls-matrix.d.ts +135 -0
  31. package/dist/plan/calls-matrix.js +275 -0
  32. package/dist/plan/cloudflared-connector.d.ts +70 -0
  33. package/dist/plan/cloudflared-connector.js +106 -0
  34. package/dist/plan/diff.d.ts +26 -5
  35. package/dist/plan/diff.js +720 -106
  36. package/dist/plan/executor.d.ts +731 -36
  37. package/dist/plan/executor.js +1180 -41
  38. package/dist/plan/index.d.ts +22 -5
  39. package/dist/plan/index.js +12 -2
  40. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  41. package/dist/plan/jobs-scheduler-http.js +50 -0
  42. package/dist/plan/live.d.ts +51 -2
  43. package/dist/plan/live.js +8 -0
  44. package/dist/plan/plan-apply.d.ts +192 -3
  45. package/dist/plan/plan-apply.js +290 -14
  46. package/dist/plan/platform-diff.d.ts +312 -0
  47. package/dist/plan/platform-diff.js +867 -0
  48. package/dist/plan/platform-org-ports.d.ts +50 -0
  49. package/dist/plan/platform-org-ports.js +70 -0
  50. package/dist/plan/platform-org.d.ts +120 -0
  51. package/dist/plan/platform-org.js +115 -0
  52. package/dist/plan/platform-providers.d.ts +501 -0
  53. package/dist/plan/platform-providers.js +3029 -0
  54. package/dist/plan/platform-sfu.d.ts +93 -0
  55. package/dist/plan/platform-sfu.js +100 -0
  56. package/dist/plan/providers-readers.js +22 -1
  57. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  58. package/dist/plan/zitadel-machine-user.js +95 -0
  59. package/dist/plan.d.ts +6 -0
  60. package/dist/platform.d.ts +55 -0
  61. package/dist/platform.js +46 -0
  62. package/dist/requested-changes.d.ts +108 -0
  63. package/dist/requested-changes.js +95 -0
  64. package/dist/slug.js +21 -2
  65. package/dist/store/audit.js +4 -2
  66. package/dist/store/postgres.d.ts +40 -7
  67. package/dist/store/postgres.js +257 -29
  68. package/dist/store/schema.d.ts +700 -0
  69. package/dist/store/schema.js +58 -0
  70. package/dist/store.d.ts +56 -0
  71. package/package.json +2 -2
@@ -0,0 +1,275 @@
1
+ import { generateKeyPairSync, randomBytes } from 'node:crypto';
2
+ /**
3
+ * The calls Matrix stack (#128, part 1 of 2 -- see that issue's "If this
4
+ * passes about 800 changed lines, split it" note): pure config generation
5
+ * for the dedicated Synapse and matrix-authentication-service (MAS)
6
+ * `executor.ts`'s `coolifyCalls` deploys per org, adapted from
7
+ * `wtfalch/app-template#344`'s `bootstrap/steps/calls-instance.ts`.
8
+ *
9
+ * TWO COOLIFY DATABASES, NOT ONE SHARED POSTGRES
10
+ * ------------------------------------------------
11
+ * PR 344 ran one bare `postgres` container and created Synapse's and MAS's
12
+ * databases on it with hand-rolled SQL over a direct connection -- workable
13
+ * for a CLI run with network access to the box, but this executor never
14
+ * connects to a database it creates (`executor.ts`'s `pushDatabaseUrl`/
15
+ * `ensureCmsRuntimeUrl` only ever read `internal_db_url` back through
16
+ * Coolify's own API). So this stack instead asks Coolify for two ordinary
17
+ * managed Postgres databases, `coolifyCalls`'s own `CoolifyPort.
18
+ * createPostgresDatabase` calls -- the same primitive `coolifyDatabase`/
19
+ * `ensureCmsDatabase` already use for the org's own app and its cms site.
20
+ * That also means neither app needs a postgres superuser password minted
21
+ * here: Coolify mints and owns each database's own credential.
22
+ *
23
+ * NOT CHAT'S STACK, BY CONSTRUCTION
24
+ * ----------------------------------
25
+ * PR 344's review settled that a call guest must never gain chat membership.
26
+ * This is not a gate bolted onto shared infrastructure -- there is no shared
27
+ * infrastructure: this stack is an entirely separate homeserver from
28
+ * `archon/bootstrap/steps/chat-instance.ts`'s own Synapse+MAS, with its own
29
+ * databases, its own `server_name` ({@link callsServerName}) and its own MAS
30
+ * client. A guest account minted here cannot join a chat room because it does
31
+ * not exist on chat's homeserver at all.
32
+ *
33
+ * NO PUBLIC HOSTNAME OF ITS OWN
34
+ * -------------------------------
35
+ * `@wtfalch/calls`' own `SYNAPSE_URL`/`MAS_URL` env vars
36
+ * (`calls/packages/service/src/config.mjs`) are plain internal addresses --
37
+ * the calls service is the only thing that ever talks to either, over
38
+ * Coolify's own Docker network, proxying the Matrix client-server API on a
39
+ * guest's behalf (`calls/packages/service/src/matrix-gateway.mjs`). Neither
40
+ * app needs a Cloudflare-routed hostname, unlike chat's own Synapse (real
41
+ * Matrix clients, real federation surface). That is also why this module
42
+ * mints no tunnel or DNS anything: the only public hostname this stack ever
43
+ * needs is the calls *service's* own, which foundry#145 (part 2 of #128)
44
+ * gives it -- `<slug>-calls.oikos.host` on the shared tunnel for a level-1
45
+ * org, `calls.<domain>` on its own per-org tunnel for an own-domain one
46
+ * (`executor.ts`'s `callsHostnameFor`).
47
+ */
48
+ export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
49
+ export const SYNAPSE_TAG = 'v1.161.0';
50
+ export const MAS_IMAGE = 'ghcr.io/element-hq/matrix-authentication-service:1.24.0';
51
+ /**
52
+ * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
53
+ * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
54
+ * Synapse agree on, and the same value the calls service's own
55
+ * `MAS_HOMESERVER` (part 2) must carry. `<org.slug>` alone would collide with
56
+ * a real domain some org might later own; `.internal` never resolves and
57
+ * matches `@wtfalch/calls`' own bare-homeserver-domain regex
58
+ * (`^[A-Za-z0-9.:-]+$`).
59
+ */
60
+ export function callsServerName(orgSlug) {
61
+ return `calls-${orgSlug}.internal`;
62
+ }
63
+ /**
64
+ * Coolify's own `internal_db_url` is `postgres://user:password@host[:port]/db`
65
+ * -- `URL` parses it exactly the way `executor.ts`'s `ensureCmsRuntimeUrl`
66
+ * already parses the same shape for the cms app's own database. Returns
67
+ * `null` on a malformed URL rather than throwing, the same defensive stance
68
+ * `ensureCmsRuntimeUrl` takes: Coolify's own superuser password is not
69
+ * something this package controls the alphabet of.
70
+ */
71
+ export function parseCallsDbUrl(internalDbUrl) {
72
+ try {
73
+ const url = new URL(internalDbUrl);
74
+ const database = url.pathname.replace(/^\//, '');
75
+ if (!url.hostname || !url.username || !database)
76
+ return null;
77
+ return {
78
+ host: url.hostname,
79
+ port: url.port ? Number(url.port) : 5432,
80
+ user: decodeURIComponent(url.username),
81
+ password: decodeURIComponent(url.password),
82
+ database,
83
+ };
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ export function mintCallsMatrixSecrets() {
90
+ const rsa = generateKeyPairSync('rsa', {
91
+ modulusLength: 2048,
92
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
93
+ publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
94
+ });
95
+ const ec = generateKeyPairSync('ec', {
96
+ namedCurve: 'P-256',
97
+ privateKeyEncoding: { type: 'sec1', format: 'pem' },
98
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
99
+ });
100
+ return {
101
+ masSynapseSharedSecret: randomBytes(32).toString('hex'),
102
+ masClientId: generateUlid(),
103
+ masClientSecret: randomBytes(24).toString('base64url'),
104
+ masEncryptionKey: randomBytes(32).toString('hex'),
105
+ masRsaKeyPem: rsa.privateKey,
106
+ masEcKeyPem: ec.privateKey,
107
+ };
108
+ }
109
+ /**
110
+ * A ULID-shaped (26-char Crockford base32) id -- MAS's `clients[].client_id`
111
+ * must be one (`docs/reference/configuration.md`). Not time-sortable (a
112
+ * static config value, not an event id), just shape-valid: 16 random bytes,
113
+ * Crockford-encoded. Identical to `chat-instance.ts`'s own helper of the same
114
+ * name -- restated, not imported, since neither `plan/` nor `archon`'s
115
+ * bootstrap tree exposes the other as a dependency.
116
+ */
117
+ export function generateUlid(bytes = randomBytes(16)) {
118
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
119
+ let bits = 0n;
120
+ for (const b of bytes)
121
+ bits = (bits << 8n) | BigInt(b);
122
+ let out = '';
123
+ for (let i = 0; i < 26; i++) {
124
+ const shift = BigInt(130 - (i + 1) * 5);
125
+ const idx = shift >= 0n ? Number((bits >> shift) & 0x1fn) : Number((bits << -shift) & 0x1fn);
126
+ out += alphabet[idx];
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * `homeserver.yaml` -- rate limits raised well past Synapse's defaults
132
+ * (`rc_*` below), matching `calls/proof/provider/scripts/guest-stack.mjs`'s
133
+ * own real-deployment values: every guest session this stack mints goes
134
+ * through MAS's admin API and Synapse's client API in a burst when a call
135
+ * starts, not at human typing speed.
136
+ */
137
+ export function callsSynapseHomeserverYaml(input) {
138
+ const serverName = callsServerName(input.orgSlug);
139
+ const rateLimit = '{ per_second: 100, burst_count: 1000 }';
140
+ return [
141
+ '# Generated by @wtfalch/foundry (executor.ts, coolify:calls). Do not edit',
142
+ '# by hand; re-run apply instead -- it is idempotent and will not rotate',
143
+ '# secrets that already exist.',
144
+ '',
145
+ `server_name: "${serverName}"`,
146
+ 'pid_file: /data/homeserver.pid',
147
+ 'report_stats: false',
148
+ '',
149
+ 'listeners:',
150
+ ' - port: 8008',
151
+ ' type: http',
152
+ ' tls: false',
153
+ ' bind_addresses: ["0.0.0.0"]',
154
+ ' x_forwarded: true',
155
+ ' resources:',
156
+ ' - names: [client]',
157
+ ' compress: false',
158
+ '',
159
+ 'database:',
160
+ ' name: psycopg2',
161
+ ' args:',
162
+ ` user: ${input.db.user}`,
163
+ ` password: "${input.db.password}"`,
164
+ ` dbname: ${input.db.database}`,
165
+ ` host: ${input.db.host}`,
166
+ ` port: ${input.db.port}`,
167
+ ' cp_min: 5',
168
+ ' cp_max: 10',
169
+ '',
170
+ '# "Specifying an empty list ([]) here is the recommended way of disabling',
171
+ '# federation." (config_documentation.md, federation_domain_whitelist) --',
172
+ '# this homeserver never federates: it exists only to authenticate calls',
173
+ '# guests, never to talk to another Matrix server.',
174
+ 'federation_domain_whitelist: []',
175
+ '',
176
+ 'presence:',
177
+ ' enabled: false',
178
+ '',
179
+ 'media_store_path: /data/media_store',
180
+ 'signing_key_path: /data/signing.key',
181
+ '',
182
+ 'matrix_authentication_service:',
183
+ ' enabled: true',
184
+ ` endpoint: "http://${input.masHost}:8080/"`,
185
+ ` secret: "${input.masSharedSecret}"`,
186
+ '',
187
+ `rc_message: ${rateLimit}`,
188
+ `rc_room_creation: ${rateLimit}`,
189
+ 'rc_joins:',
190
+ ` local: ${rateLimit}`,
191
+ ` remote: ${rateLimit}`,
192
+ 'rc_invites:',
193
+ ` per_room: ${rateLimit}`,
194
+ ` per_user: ${rateLimit}`,
195
+ ` per_issuer: ${rateLimit}`,
196
+ '',
197
+ ].join('\n');
198
+ }
199
+ /**
200
+ * `config.yaml` -- password login and registration off (the calls service is
201
+ * the only way in, minting a guest a personal session through the admin API,
202
+ * `calls/proof/provider/scripts/guest-stack.mjs`'s `provisioner`); the one
203
+ * registered client is the calls service itself, granted `admin_clients` so
204
+ * its `client_credentials` grant can carry `urn:mas:admin`.
205
+ */
206
+ export function callsMasConfigYaml(input) {
207
+ const serverName = callsServerName(input.orgSlug);
208
+ const indentKey = (pem, spaces) => pem
209
+ .trimEnd()
210
+ .split('\n')
211
+ .map((line) => `${' '.repeat(spaces)}${line}`)
212
+ .join('\n');
213
+ return [
214
+ '# Generated by @wtfalch/foundry (executor.ts, coolify:calls). Do not edit by hand.',
215
+ '',
216
+ 'http:',
217
+ ' listeners:',
218
+ ' - name: web',
219
+ ' resources:',
220
+ ' - name: discovery',
221
+ ' - name: human',
222
+ ' - name: oauth',
223
+ ' - name: compat',
224
+ ' - name: graphql',
225
+ ' - name: assets',
226
+ ' - name: adminapi',
227
+ ' - name: health',
228
+ ' binds:',
229
+ ' - address: "[::]:8080"',
230
+ ' proxy_protocol: false',
231
+ ` public_base: "http://${serverName}/"`,
232
+ ' trusted_proxies:',
233
+ ' - 0.0.0.0/0',
234
+ '',
235
+ 'database:',
236
+ ` host: ${input.db.host}`,
237
+ ` port: ${input.db.port}`,
238
+ ` username: ${input.db.user}`,
239
+ ` password: "${input.db.password}"`,
240
+ ` database: ${input.db.database}`,
241
+ ' ssl_mode: disable',
242
+ '',
243
+ 'matrix:',
244
+ ' kind: synapse',
245
+ ` homeserver: ${serverName}`,
246
+ ` endpoint: "http://${input.synapseHost}:8008"`,
247
+ ` secret: "${input.sharedSecret}"`,
248
+ '',
249
+ '# The calls service is the only way in.',
250
+ 'passwords:',
251
+ ' enabled: false',
252
+ '',
253
+ 'account:',
254
+ ' password_registration_enabled: false',
255
+ '',
256
+ 'clients:',
257
+ ` - client_id: "${input.clientId}"`,
258
+ ' client_auth_method: client_secret_basic',
259
+ ` client_secret: "${input.clientSecret}"`,
260
+ '',
261
+ 'policy:',
262
+ ' data:',
263
+ ' admin_clients:',
264
+ ` - "${input.clientId}"`,
265
+ '',
266
+ 'secrets:',
267
+ ` encryption: ${input.encryptionKey}`,
268
+ ' keys:',
269
+ ' - key: |',
270
+ indentKey(input.rsaKeyPem, 8),
271
+ ' - key: |',
272
+ indentKey(input.ecKeyPem, 8),
273
+ '',
274
+ ].join('\n');
275
+ }
@@ -0,0 +1,70 @@
1
+ import type { CloudflarePort, CoolifyPort } from './executor.js';
2
+ /**
3
+ * #197: the pinned `cloudflared` image, the same release tag as
4
+ * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
6
+ */
7
+ export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
8
+ /**
9
+ * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
+ * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
11
+ * image's own `CMD` is `version`, and Coolify's `start_command` is ignored
12
+ * for image-based applications, so the run command has to be stated in a
13
+ * Dockerfile's `CMD` instead. `TUNNEL_TOKEN` is never baked in here; it
14
+ * reaches the container only via `bulkUpsertEnvs`, as an environment
15
+ * variable `cloudflared` reads at boot.
16
+ */
17
+ export declare function cloudflaredDockerfile(): string;
18
+ /**
19
+ * #197: ensure one `cloudflared` connector app exists in Coolify for a
20
+ * tunnel, with the current token, deployed if needed. Decision: one connector
21
+ * per platform tunnel, so each tunnel-owning platform step (auth, mail,
22
+ * archon) calls this once with its own app name and scope key.
23
+ */
24
+ export interface EnsureCloudflaredConnectorInput {
25
+ coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
26
+ cloudflare: Pick<CloudflarePort, 'getTunnelToken'>;
27
+ /** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
28
+ tunnelId: string;
29
+ /** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
30
+ appName: string;
31
+ projectUuid: string;
32
+ environment: {
33
+ id: number;
34
+ name: string;
35
+ uuid: string;
36
+ };
37
+ /** Called only when the app must be created -- never on the found-by-name or found-by-id paths. */
38
+ resolveServer: () => Promise<{
39
+ uuid: string;
40
+ }>;
41
+ /**
42
+ * The app's uuid from a prior run's recorded scope id, or `null`. When
43
+ * given, it is trusted over a by-name lookup, so a renamed or
44
+ * duplicate-named app never causes a second create. A recorded uuid whose
45
+ * app no longer exists (not found) falls through to find-by-name, then create.
46
+ */
47
+ recordedConnectorAppId: string | null;
48
+ /**
49
+ * Called with the app's uuid as soon as it is created or found by name,
50
+ * before the token fetch, env write and deploy. The caller records and
51
+ * persists it there, so a later failure never leaves an app nothing points at.
52
+ */
53
+ recordAppId: (appId: string) => Promise<void>;
54
+ }
55
+ /**
56
+ * Finds or creates the one Coolify app that runs `cloudflared` for
57
+ * {@link EnsureCloudflaredConnectorInput.tunnelId}, writes its tunnel token as
58
+ * `TUNNEL_TOKEN` (a literal env var) when the token is new or changed, and
59
+ * deploys it when it was just created, its token changed, or it is not
60
+ * explicitly running (a missing status counts as not running, so a run that
61
+ * failed after the env write is repaired by the next one). `CLAUDE.md` rule 4.
62
+ *
63
+ * The token is a credential (`CLAUDE.md` rule 6): it goes to `bulkUpsertEnvs`
64
+ * and nowhere else. A failed write throws an error that names the app only,
65
+ * with no cause, because a Coolify error can echo the request body.
66
+ */
67
+ export declare function ensureCloudflaredConnector(input: EnsureCloudflaredConnectorInput): Promise<{
68
+ appId: string;
69
+ deployed: boolean;
70
+ }>;
@@ -0,0 +1,106 @@
1
+ import { isNotFoundError } from './executor.js';
2
+ /**
3
+ * #197: the pinned `cloudflared` image, the same release tag as
4
+ * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
6
+ */
7
+ export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
8
+ /**
9
+ * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
+ * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
11
+ * image's own `CMD` is `version`, and Coolify's `start_command` is ignored
12
+ * for image-based applications, so the run command has to be stated in a
13
+ * Dockerfile's `CMD` instead. `TUNNEL_TOKEN` is never baked in here; it
14
+ * reaches the container only via `bulkUpsertEnvs`, as an environment
15
+ * variable `cloudflared` reads at boot.
16
+ */
17
+ export function cloudflaredDockerfile() {
18
+ return `FROM ${CLOUDFLARED_IMAGE_REF}\nCMD ["tunnel", "--no-autoupdate", "run"]\n`;
19
+ }
20
+ /**
21
+ * Finds or creates the one Coolify app that runs `cloudflared` for
22
+ * {@link EnsureCloudflaredConnectorInput.tunnelId}, writes its tunnel token as
23
+ * `TUNNEL_TOKEN` (a literal env var) when the token is new or changed, and
24
+ * deploys it when it was just created, its token changed, or it is not
25
+ * explicitly running (a missing status counts as not running, so a run that
26
+ * failed after the env write is repaired by the next one). `CLAUDE.md` rule 4.
27
+ *
28
+ * The token is a credential (`CLAUDE.md` rule 6): it goes to `bulkUpsertEnvs`
29
+ * and nowhere else. A failed write throws an error that names the app only,
30
+ * with no cause, because a Coolify error can echo the request body.
31
+ */
32
+ export async function ensureCloudflaredConnector(input) {
33
+ const { coolify, cloudflare } = input;
34
+ const liveStatus = async (uuid) => {
35
+ try {
36
+ return { found: true, status: (await coolify.getApplication(uuid)).status };
37
+ }
38
+ catch (error) {
39
+ if (isNotFoundError(error))
40
+ return { found: false };
41
+ throw error;
42
+ }
43
+ };
44
+ let appId = null;
45
+ let status;
46
+ let justCreated = false;
47
+ if (input.recordedConnectorAppId) {
48
+ const live = await liveStatus(input.recordedConnectorAppId);
49
+ if (live.found) {
50
+ appId = input.recordedConnectorAppId;
51
+ status = live.status;
52
+ }
53
+ }
54
+ if (!appId) {
55
+ const found = await coolify.findApplicationByName(input.appName, {
56
+ environmentIds: [input.environment.id],
57
+ });
58
+ if (found) {
59
+ const live = await liveStatus(found.uuid);
60
+ if (live.found) {
61
+ appId = found.uuid;
62
+ status = live.status;
63
+ }
64
+ }
65
+ }
66
+ if (!appId) {
67
+ const server = await input.resolveServer();
68
+ const created = await coolify.createDockerfileApplication({
69
+ project_uuid: input.projectUuid,
70
+ server_uuid: server.uuid,
71
+ environment_name: input.environment.name,
72
+ environment_uuid: input.environment.uuid,
73
+ dockerfile: cloudflaredDockerfile(),
74
+ name: input.appName,
75
+ description: 'cloudflared connector for this platform tunnel (#197)',
76
+ instant_deploy: false,
77
+ });
78
+ appId = created.uuid;
79
+ justCreated = true;
80
+ }
81
+ if (appId !== input.recordedConnectorAppId)
82
+ await input.recordAppId(appId);
83
+ const token = await cloudflare.getTunnelToken(input.tunnelId);
84
+ const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
85
+ if (tokenChanged) {
86
+ try {
87
+ await coolify.bulkUpsertEnvs(appId, [{ key: 'TUNNEL_TOKEN', value: token, literal: true }]);
88
+ }
89
+ catch {
90
+ throw new Error(`cloudflared connector ${input.appName}: writing TUNNEL_TOKEN failed`);
91
+ }
92
+ }
93
+ // Only a connector that is up or on its way up is left alone; a missing,
94
+ // stopped, crash-looping or degraded one is deployed.
95
+ const upOrStarting = Boolean(status?.startsWith('running') || status?.startsWith('starting'));
96
+ const deployed = justCreated || tokenChanged || !upOrStarting;
97
+ if (deployed) {
98
+ await coolify.deploy(appId);
99
+ }
100
+ return { appId, deployed };
101
+ }
102
+ /** Whether the app's live `TUNNEL_TOKEN` is missing or differs from the token just fetched. */
103
+ async function tunnelTokenDiffers(coolify, appId, token) {
104
+ const live = (await coolify.listEnvs(appId)).find((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
105
+ return !live || live.value !== token;
106
+ }
@@ -1,6 +1,8 @@
1
1
  import type { DeclaredOrg, OrgId } from '../org.js';
2
2
  import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
3
+ import { type AgoraHostReading } from './agora.js';
3
4
  import type { LiveResource } from './live.js';
5
+ import { type OrgPlatformReadiness } from './platform-org.js';
4
6
  /**
5
7
  * What a {@link StepExecutor} (see `plan-apply.ts`) needs to actually make a
6
8
  * step true, alongside the public {@link PlanStep} an operator reads.
@@ -56,12 +58,31 @@ export declare function toPlan(detail: PlanDetail): Plan;
56
58
  * rest: the org's owner, invited by ZITADEL email, idempotent on the
57
59
  * *recorded* resource id (`recordedIds`, sourced from
58
60
  * `AppliedState.serviceIds`) rather than a live resource, since a human user
59
- * is never something `live` can contain. Everything else in `live` that none
60
- * of these twelve singletons and however many declared issuer apps claim is
61
- * unmanaged. The full field-by-field provisioning surface (SSO, guests,
62
- * calls, adopted/bespoke apps, the actual
61
+ * is never something `live` can contain. #128 adds one more singleton, off
62
+ * by default unlike chat's own: {@link planCalls}, the org's own calls
63
+ * Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
64
+ * planned right after it and gated on the same flag: {@link planCallsService},
65
+ * the calls service itself (`wtfalch/calls`'s `packages/service`). #164 adds
66
+ * two more, unconditionally -- no `DeclaredOrg` field gates either, since
67
+ * every org's own Agora is always on: {@link planAgoraClient}, a fixed
68
+ * ZITADEL web client named `agora` (modeled on {@link planCmsClient}), and
69
+ * {@link planAgora}, the Agora itself as a Coolify app (modeled on
70
+ * {@link planSite}, minus the park branch -- foundry never deletes an
71
+ * Agora). #173 adds one more, gated on `org.hosting.enabled` like
72
+ * {@link planApp} itself: {@link planJobsRegistration}, registering the org's
73
+ * app with the platform's jobs scheduler, idempotent on the *recorded*
74
+ * resource id like {@link planOwner} rather than a live resource, since
75
+ * nothing in `live` can report a scheduler registration (no reader exists for
76
+ * it). Everything
77
+ * else in `live` that none of these eighteen singletons and however many
78
+ * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
79
+ * guests, calls on the org's own domain, adopted/bespoke apps, the actual
63
80
  * Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
64
81
  * Company Factory end to end", which wires real readers and executors
65
82
  * against this seam.
66
83
  */
67
- export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>): PlanDetail;
84
+ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness): PlanDetail;
85
+ /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
86
+ export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
87
+ /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
88
+ export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registration_undelivered";