@wtfalch/foundry 0.2.0 → 0.4.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/calls-sfu-deploy-support.d.ts +57 -0
- package/dist/calls-sfu-deploy-support.js +82 -0
- package/dist/calls-sfu.d.ts +192 -0
- package/dist/calls-sfu.js +256 -0
- package/dist/index.d.ts +8 -5
- package/dist/index.js +6 -4
- package/dist/migrate.d.ts +38 -0
- package/dist/migrate.js +47 -0
- package/dist/migrations/0012_audit.sql +21 -0
- package/dist/migrations/0013_access_emails.sql +6 -0
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/org.d.ts +41 -2
- package/dist/org.js +85 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/diff.d.ts +8 -4
- package/dist/plan/diff.js +239 -9
- package/dist/plan/executor.d.ts +751 -14
- package/dist/plan/executor.js +1538 -32
- package/dist/plan/index.d.ts +3 -3
- package/dist/plan/index.js +2 -2
- package/dist/plan/plan-apply.js +29 -7
- package/dist/plan/providers-readers.d.ts +54 -1
- package/dist/plan/providers-readers.js +153 -1
- package/dist/provision.d.ts +56 -0
- package/dist/provision.js +69 -0
- package/dist/slug.d.ts +12 -18
- package/dist/slug.js +74 -2
- package/dist/store/audit.d.ts +8 -0
- package/dist/store/audit.js +43 -0
- package/dist/store/postgres.d.ts +11 -6
- package/dist/store/postgres.js +104 -27
- package/dist/store/schema.d.ts +205 -0
- package/dist/store/schema.js +15 -1
- package/dist/store.d.ts +12 -0
- package/package.json +9 -3
|
@@ -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
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -56,10 +56,14 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
56
56
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
57
57
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
58
58
|
* `AppliedState.serviceIds`) rather than a live resource, since a human user
|
|
59
|
-
* is never something `live` can contain.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* is never something `live` can contain. #128 adds one more singleton, off
|
|
60
|
+
* by default unlike chat's own: {@link planCalls}, the org's own calls
|
|
61
|
+
* Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
|
|
62
|
+
* planned right after it and gated on the same flag: {@link planCallsService},
|
|
63
|
+
* the calls service itself (`wtfalch/calls`'s `packages/service`). Everything
|
|
64
|
+
* else in `live` that none of these fifteen singletons and however many
|
|
65
|
+
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
66
|
+
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
63
67
|
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
64
68
|
* Company Factory end to end", which wires real readers and executors
|
|
65
69
|
* against this seam.
|
package/dist/plan/diff.js
CHANGED
|
@@ -34,10 +34,14 @@ function resourceKey(system, kind, key) {
|
|
|
34
34
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
35
35
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
36
36
|
* `AppliedState.serviceIds`) rather than a live resource, since a human user
|
|
37
|
-
* is never something `live` can contain.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* is never something `live` can contain. #128 adds one more singleton, off
|
|
38
|
+
* by default unlike chat's own: {@link planCalls}, the org's own calls
|
|
39
|
+
* Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
|
|
40
|
+
* planned right after it and gated on the same flag: {@link planCallsService},
|
|
41
|
+
* the calls service itself (`wtfalch/calls`'s `packages/service`). Everything
|
|
42
|
+
* else in `live` that none of these fifteen singletons and however many
|
|
43
|
+
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
44
|
+
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
41
45
|
* Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
|
|
42
46
|
* Company Factory end to end", which wires real readers and executors
|
|
43
47
|
* against this seam.
|
|
@@ -58,6 +62,16 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
58
62
|
const chat = planChat(org, byKey, claimed);
|
|
59
63
|
if (chat)
|
|
60
64
|
plannedSteps.push(chat);
|
|
65
|
+
const calls = planCalls(org, byKey, claimed);
|
|
66
|
+
if (calls)
|
|
67
|
+
plannedSteps.push(calls);
|
|
68
|
+
// Right after the Matrix stack (#145): its own executor
|
|
69
|
+
// (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
|
|
70
|
+
// and MAS client credentials `coolifyCalls` just recorded, the same
|
|
71
|
+
// ordering `planOwner` relies on for `zitadel:issuer`.
|
|
72
|
+
const callsService = planCallsService(org, byKey, claimed);
|
|
73
|
+
if (callsService)
|
|
74
|
+
plannedSteps.push(callsService);
|
|
61
75
|
const filesOrganisation = planFilesOrganisation(org, byKey, claimed);
|
|
62
76
|
if (filesOrganisation)
|
|
63
77
|
plannedSteps.push(filesOrganisation);
|
|
@@ -70,9 +84,27 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
70
84
|
const aiBudget = planAiBudget(org, byKey, claimed);
|
|
71
85
|
if (aiBudget)
|
|
72
86
|
plannedSteps.push(aiBudget);
|
|
87
|
+
// Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
|
|
88
|
+
// step's recorded client id, the same way `coolifyApp` runs after the
|
|
89
|
+
// hosted app's own `zitadel:app` steps.
|
|
90
|
+
const cmsClient = planCmsClient(org, byKey, claimed);
|
|
91
|
+
if (cmsClient)
|
|
92
|
+
plannedSteps.push(cmsClient);
|
|
73
93
|
const site = planSite(org, byKey, claimed);
|
|
74
94
|
if (site)
|
|
75
95
|
plannedSteps.push(site);
|
|
96
|
+
// Every declared ZITADEL app, before the app cluster below (level-1
|
|
97
|
+
// app-env campaign, gap P2): `coolifyApp`'s own env push
|
|
98
|
+
// (`executor.ts`'s `pushAppEnv`) reads a web app's `clientId` to set
|
|
99
|
+
// `AUTH_CLIENT_ID`, which only exists once that app's own `zitadel:app`
|
|
100
|
+
// step has run -- `plan-apply.ts`'s `runSteps` executes `plannedSteps` in
|
|
101
|
+
// this array order, one at a time.
|
|
102
|
+
plannedSteps.push(...planIssuerApps(org, byKey, claimed));
|
|
103
|
+
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
104
|
+
// first apply the hostname is gated before anything answers on it.
|
|
105
|
+
const access = planAccess(org, recordedIds);
|
|
106
|
+
if (access)
|
|
107
|
+
plannedSteps.push(access);
|
|
76
108
|
// Database before app (hosting-wiring campaign): `plan-apply.ts`'s
|
|
77
109
|
// `runSteps` executes `plannedSteps` in this array order, and the app's own
|
|
78
110
|
// step (`executor.ts`'s `coolifyApp`) pushes the database's connection
|
|
@@ -91,7 +123,12 @@ export function computePlanDetail(org, live, recordedIds = {}) {
|
|
|
91
123
|
const zone = planZone(org, byKey, claimed);
|
|
92
124
|
if (zone)
|
|
93
125
|
plannedSteps.push(zone);
|
|
94
|
-
|
|
126
|
+
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
127
|
+
// apex domain, so the zone step above must already have run (or the zone
|
|
128
|
+
// must already exist live) by the time this one's action executes.
|
|
129
|
+
const rateLimit = planRateLimit(org, recordedIds);
|
|
130
|
+
if (rateLimit)
|
|
131
|
+
plannedSteps.push(rateLimit);
|
|
95
132
|
// After the apps: the customers org is granted the project the first app
|
|
96
133
|
// step creates.
|
|
97
134
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
@@ -318,6 +355,34 @@ function planChat(org, byKey, claimed) {
|
|
|
318
355
|
},
|
|
319
356
|
};
|
|
320
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* The org's own calls Matrix stack (#128) -- a dedicated Synapse and
|
|
360
|
+
* matrix-authentication-service, entirely separate from chat's
|
|
361
|
+
* (`plan/calls-matrix.ts`'s own top doc comment: a call guest must never gain
|
|
362
|
+
* chat membership). Unlike chat, off by default: `org.ts`'s
|
|
363
|
+
* `CallsConfig.enabled` doc comment. Same on/off, park-not-delete shape as
|
|
364
|
+
* {@link planApp}/{@link planDatabase}/{@link planTunnel} below
|
|
365
|
+
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
|
|
366
|
+
* MAS, never either database (`executor.ts`'s `coolifyCalls`).
|
|
367
|
+
*/
|
|
368
|
+
function planCalls(org, byKey, claimed) {
|
|
369
|
+
return planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
|
|
373
|
+
* `packages/service`, deployed alongside the calls Matrix stack {@link planCalls}
|
|
374
|
+
* provisions and wired to it and to the shared LiveKit SFU (foundry#122/#127).
|
|
375
|
+
* Gated on the same `org.calls.enabled` flag as the Matrix stack, not a flag
|
|
376
|
+
* of its own: a calls service with no Matrix stack behind it has nothing to
|
|
377
|
+
* authenticate a guest against, and a Matrix stack alone answers no browser,
|
|
378
|
+
* so the two are declared as one on/off switch even though they are two
|
|
379
|
+
* planner steps. Same on/off, park-not-delete shape as {@link planCalls}
|
|
380
|
+
* (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
|
|
381
|
+
* never its database (`executor.ts`'s `coolifyCallsService`).
|
|
382
|
+
*/
|
|
383
|
+
function planCallsService(org, byKey, claimed) {
|
|
384
|
+
return planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`);
|
|
385
|
+
}
|
|
321
386
|
/**
|
|
322
387
|
* Deduped `ai.*` permissions offered to every organisation by default
|
|
323
388
|
* (Decision, this campaign's report): the minimum set that lets an org
|
|
@@ -456,6 +521,46 @@ function planAiBudget(org, byKey, claimed) {
|
|
|
456
521
|
},
|
|
457
522
|
};
|
|
458
523
|
}
|
|
524
|
+
/**
|
|
525
|
+
* https://github.com/wtfalch/foundry/issues/139 (parent decision:
|
|
526
|
+
* https://github.com/wtfalch/foundry/issues/99): every site-enabled org gets
|
|
527
|
+
* one fixed ZITADEL web client named `cms`, so its studio
|
|
528
|
+
* (`@wtfalch/cms`'s `apps/host`) can sign anyone in -- automatically, with no
|
|
529
|
+
* new field on `DeclaredOrg`. Modeled on {@link planIssuerApps}'s single-app
|
|
530
|
+
* shape: provision when missing, recreate when the live `appType` differs,
|
|
531
|
+
* nothing otherwise. There is no park for this client either, for the same
|
|
532
|
+
* reason `planIssuerApps`'s own doc comment gives -- ZITADEL has no notion of
|
|
533
|
+
* a disabled-but-retained OIDC client.
|
|
534
|
+
*/
|
|
535
|
+
function planCmsClient(org, byKey, claimed) {
|
|
536
|
+
if (!org.site.enabled)
|
|
537
|
+
return null;
|
|
538
|
+
const key = resourceKey('zitadel', 'app', 'cms');
|
|
539
|
+
const live = byKey.get(key);
|
|
540
|
+
if (live)
|
|
541
|
+
claimed.add(key);
|
|
542
|
+
const desired = { appType: 'web' };
|
|
543
|
+
if (!live) {
|
|
544
|
+
return {
|
|
545
|
+
action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
|
|
546
|
+
step: {
|
|
547
|
+
description: `provision the web ZITADEL app 'cms' for ${org.slug}'s cms studio sign-in`,
|
|
548
|
+
destroys: false,
|
|
549
|
+
migrates: false,
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (live.config.appType === 'web')
|
|
554
|
+
return null;
|
|
555
|
+
return {
|
|
556
|
+
action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
|
|
557
|
+
step: {
|
|
558
|
+
description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
|
|
559
|
+
destroys: true,
|
|
560
|
+
migrates: false,
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
459
564
|
/**
|
|
460
565
|
* The org's cms site (`org.ts`'s `SiteConfig`), a per-org singleton
|
|
461
566
|
* resource modeled on {@link planMail}. Disabling a live site parks it,
|
|
@@ -562,9 +667,42 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
562
667
|
* The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
|
|
563
668
|
* per-org singleton resource modeled on {@link planSite}: park, not delete,
|
|
564
669
|
* on the way off (#7 corrections 1).
|
|
670
|
+
*
|
|
671
|
+
* #75: every app foundry deploys also carries a durable log directory (a
|
|
672
|
+
* persistent volume mounted at `/app/logs`, `LOG_DIR` in its env) so a
|
|
673
|
+
* database outage does not erase the only other copy of the evidence
|
|
674
|
+
* (`executor.ts`'s `ensureLogVolume`/`pushAppEnv`, lokessmie's own pilot at
|
|
675
|
+
* https://github.com/wtfalch/lokessmie/pull/521). `planHostingToggle` above
|
|
676
|
+
* only tracks on/off, so once it finds nothing to toggle -- app live and
|
|
677
|
+
* already enabled -- this planner adds one more check on top: a live app
|
|
678
|
+
* that predates this feature (`live.config.hasLogVolume === false`,
|
|
679
|
+
* `providers-readers.ts`'s `withLogVolumeConfig`) is planned for it, reusing
|
|
680
|
+
* the same `coolify:app`/`enabled: true` action `planHostingToggle`'s own
|
|
681
|
+
* provision step uses -- `executor.ts`'s `coolifyApp` already runs
|
|
682
|
+
* `ensureLogVolume` unconditionally on that action, whether it just created
|
|
683
|
+
* the app or found it already there, so no new step kind is needed. A
|
|
684
|
+
* missing `hasLogVolume` (rather than an explicit `false`) reads as
|
|
685
|
+
* compliant, the same convention `planHostingToggle` itself uses for a live
|
|
686
|
+
* resource's `enabled` field -- a live resource nothing has actually
|
|
687
|
+
* checked yet is assumed fine rather than replanned every time.
|
|
565
688
|
*/
|
|
566
689
|
function planApp(org, byKey, claimed) {
|
|
567
|
-
|
|
690
|
+
const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
|
|
691
|
+
if (toggled)
|
|
692
|
+
return toggled;
|
|
693
|
+
if (!org.hosting.enabled)
|
|
694
|
+
return null;
|
|
695
|
+
const live = byKey.get(resourceKey('coolify', 'app', org.id));
|
|
696
|
+
if (!live || live.config.hasLogVolume !== false)
|
|
697
|
+
return null;
|
|
698
|
+
return {
|
|
699
|
+
action: { system: 'coolify', kind: 'app', key: org.id, desired: { enabled: true } },
|
|
700
|
+
step: {
|
|
701
|
+
description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
|
|
702
|
+
destroys: false,
|
|
703
|
+
migrates: false,
|
|
704
|
+
},
|
|
705
|
+
};
|
|
568
706
|
}
|
|
569
707
|
/**
|
|
570
708
|
* A Postgres database beside the org's app (`org.ts`'s
|
|
@@ -579,10 +717,31 @@ function planDatabase(org, byKey, claimed) {
|
|
|
579
717
|
* A Cloudflare tunnel reaching the org's app (`org.ts`'s
|
|
580
718
|
* `HostingConfig.tunnel`, #71) -- `validateHosting` requires
|
|
581
719
|
* `hosting.enabled` before this can be true. Same park-not-delete shape as
|
|
582
|
-
* {@link planApp}
|
|
720
|
+
* {@link planApp}, gated on the same `usesDomain`+`domain` pair
|
|
721
|
+
* {@link planZone} checks: a level-1 org (no own domain, served at
|
|
722
|
+
* `<slug>.oikos.host`) gets no per-org tunnel at all -- one shared tunnel
|
|
723
|
+
* with a wildcard `*.oikos.host` record serves every level-1 org instead
|
|
724
|
+
* (William, 2026-09-26, map gap N3). The gate folds into the `desired`
|
|
725
|
+
* flag rather than an early return, so an org that drops its domain while
|
|
726
|
+
* a per-org tunnel is still live gets that tunnel parked, not left
|
|
727
|
+
* unmanaged, through `planHostingToggle`'s own park path.
|
|
728
|
+
*
|
|
729
|
+
* https://github.com/wtfalch/foundry/issues/115: a site-enabled org always
|
|
730
|
+
* needs its tunnel too, routing both its public site and its cms studio
|
|
731
|
+
* (`executor.ts`'s `tunnelIngressHostnames`) -- there is no separate
|
|
732
|
+
* `site.tunnel` toggle to gate on the way `hosting.tunnel` does, since a site
|
|
733
|
+
* has no meaning without one.
|
|
734
|
+
*
|
|
735
|
+
* #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
|
|
736
|
+
* needs a tunnel too, for the same reason a site does -- an org that wants
|
|
737
|
+
* `calls.<domain>` but declares no other own-domain feature must still get
|
|
738
|
+
* one provisioned, not silently have nowhere for that hostname to route.
|
|
583
739
|
*/
|
|
584
740
|
function planTunnel(org, byKey, claimed) {
|
|
585
|
-
|
|
741
|
+
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
742
|
+
(org.site.enabled && Boolean(org.domain)) ||
|
|
743
|
+
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain));
|
|
744
|
+
return planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
|
|
586
745
|
}
|
|
587
746
|
/**
|
|
588
747
|
* The org's Cloudflare zone for its own domain (`org.ts`'s
|
|
@@ -593,9 +752,20 @@ function planTunnel(org, byKey, claimed) {
|
|
|
593
752
|
* foundry should stop managing a domain the org may still own elsewhere, so
|
|
594
753
|
* a live zone under a not-`usesDomain` org is left unmanaged rather than
|
|
595
754
|
* claimed -- the same choice {@link planMail} makes for a `mode: 'off'` org.
|
|
755
|
+
*
|
|
756
|
+
* https://github.com/wtfalch/foundry/issues/115: a site-enabled org
|
|
757
|
+
* (`org.ts`'s `SiteConfig.enabled`) also needs a zone for its domain, even
|
|
758
|
+
* though it can never have `hosting` on at the same time (`validateSite`) --
|
|
759
|
+
* `org.site.enabled` already guarantees both `domain` and `site.usesDomain`
|
|
760
|
+
* are set, so the `org.domain` check below still covers it.
|
|
761
|
+
*
|
|
762
|
+
* #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
|
|
763
|
+
* needs a zone for the same reason, whether or not the org also has hosting
|
|
764
|
+
* or a site on its domain.
|
|
596
765
|
*/
|
|
597
766
|
function planZone(org, byKey, claimed) {
|
|
598
|
-
|
|
767
|
+
const usesDomain = org.hosting.usesDomain || org.site.enabled || (org.calls.enabled && org.calls.usesDomain);
|
|
768
|
+
if (!usesDomain || !org.domain)
|
|
599
769
|
return null;
|
|
600
770
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
601
771
|
const live = byKey.get(key);
|
|
@@ -693,3 +863,63 @@ function planCustomers(org, live, claimed, recordedIds) {
|
|
|
693
863
|
},
|
|
694
864
|
};
|
|
695
865
|
}
|
|
866
|
+
/**
|
|
867
|
+
* #84: a Cloudflare Access gate on the org's app hostname, letting only
|
|
868
|
+
* `hosting.accessEmails` through. Idempotent on the recorded Access app id,
|
|
869
|
+
* like `planOwner`: Cloudflare's live listing has no Access apps. A later
|
|
870
|
+
* change to the email list is not reconciled yet.
|
|
871
|
+
*/
|
|
872
|
+
function planAccess(org, recordedIds) {
|
|
873
|
+
if (!org.hosting.enabled || org.hosting.accessEmails.length === 0)
|
|
874
|
+
return null;
|
|
875
|
+
if (recordedIds[resourceKey('cloudflare', 'access', org.id)])
|
|
876
|
+
return null;
|
|
877
|
+
return {
|
|
878
|
+
action: { system: 'cloudflare', kind: 'access', key: org.id, desired: {} },
|
|
879
|
+
step: {
|
|
880
|
+
description: `put ${org.slug}'s app behind Cloudflare Access`,
|
|
881
|
+
destroys: false,
|
|
882
|
+
migrates: false,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* https://github.com/wtfalch/foundry/issues/43: one Cloudflare rate-limiting
|
|
888
|
+
* rule on the org's own app hostname's sign-in paths (`@wtfalch/auth`'s
|
|
889
|
+
* `NextAuth.handlers`, mounted at `app/auth/[...auth]/route.ts` -- every
|
|
890
|
+
* sign-in request passes through `/auth/*`, `executor.ts`'s
|
|
891
|
+
* `cloudflareRateLimit`). Gated on the same `usesDomain`/`site.enabled`
|
|
892
|
+
* +`domain` pair {@link planZone} and {@link planTunnel} check (widened for a
|
|
893
|
+
* site-enabled org by #115: the cms studio signs in under `/auth/` too, on
|
|
894
|
+
* the same zone {@link planZone} provisions for it): only an own-domain org
|
|
895
|
+
* gets this step. A level-1 org (no own domain, served at
|
|
896
|
+
* `<slug>.oikos.host`) shares the `oikos.host` zone with every other level-1
|
|
897
|
+
* org, and Cloudflare's Free plan allows exactly one rate limiting rule per
|
|
898
|
+
* zone (developers.cloudflare.com/waf/rate-limiting-rules/, checked
|
|
899
|
+
* 2026-09-26) -- a per-org step here would fight every other level-1 org's
|
|
900
|
+
* for that one slot the moment a second org applied. The shared zone instead
|
|
901
|
+
* gets one rule covering every level-1 org at once, provisioned by
|
|
902
|
+
* `scripts/shared-ingress.mjs`, not by this planner (#43's own task brief).
|
|
903
|
+
*
|
|
904
|
+
* Idempotent on the recorded rule id, like {@link planAccess}: rate limiting
|
|
905
|
+
* rules are not part of any system's `listResources` (`@wtfalch/providers`
|
|
906
|
+
* 0.5.0 has no typed reader for them, only its generic `request` escape
|
|
907
|
+
* hatch -- `executor.ts`'s `CloudflarePort.request` doc comment), so there is
|
|
908
|
+
* no live resource to diff against here. Planned right after {@link planZone}
|
|
909
|
+
* so the zone `cloudflareRateLimit` needs to resolve always exists by the
|
|
910
|
+
* time this step's own action runs.
|
|
911
|
+
*/
|
|
912
|
+
function planRateLimit(org, recordedIds) {
|
|
913
|
+
if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
|
|
914
|
+
return null;
|
|
915
|
+
if (recordedIds[resourceKey('cloudflare', 'ratelimit', org.id)])
|
|
916
|
+
return null;
|
|
917
|
+
return {
|
|
918
|
+
action: { system: 'cloudflare', kind: 'ratelimit', key: org.id, desired: {} },
|
|
919
|
+
step: {
|
|
920
|
+
description: `rate limit ${org.slug}'s sign-in paths on Cloudflare (free-plan allowance: one rule per zone)`,
|
|
921
|
+
destroys: false,
|
|
922
|
+
migrates: false,
|
|
923
|
+
},
|
|
924
|
+
};
|
|
925
|
+
}
|