@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.
- package/dist/bootstrap/bootstrap.d.ts +96 -0
- package/dist/bootstrap/bootstrap.js +285 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +94 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +9 -6
- package/dist/calls-sfu-deploy-support.js +9 -6
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +22 -4
- package/dist/calls-sfu.js +22 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/org.d.ts +18 -2
- package/dist/org.js +56 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +26 -5
- package/dist/plan/diff.js +720 -106
- package/dist/plan/executor.d.ts +731 -36
- package/dist/plan/executor.js +1180 -41
- package/dist/plan/index.d.ts +22 -5
- package/dist/plan/index.js +12 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/plan-apply.d.ts +192 -3
- package/dist/plan/plan-apply.js +290 -14
- package/dist/plan/platform-diff.d.ts +312 -0
- package/dist/plan/platform-diff.js +867 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +120 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +501 -0
- package/dist/plan/platform-providers.js +3029 -0
- package/dist/plan/platform-sfu.d.ts +93 -0
- package/dist/plan/platform-sfu.js +100 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +34 -0
- package/dist/plan/zitadel-machine-user.js +95 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +108 -0
- package/dist/requested-changes.js +95 -0
- package/dist/slug.js +21 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +40 -7
- package/dist/store/postgres.js +257 -29
- package/dist/store/schema.d.ts +700 -0
- package/dist/store/schema.js +58 -0
- package/dist/store.d.ts +56 -0
- 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
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -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.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
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";
|