@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.
@@ -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
+ }
@@ -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. 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
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. Everything else in `live` that none
38
- * of these twelve singletons and however many declared issuer apps claim is
39
- * unmanaged. The full field-by-field provisioning surface (SSO, guests,
40
- * calls, adopted/bespoke apps, the actual
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
- plannedSteps.push(...planIssuerApps(org, byKey, claimed));
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
- return 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)`);
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
- return planHostingToggle('cloudflare', 'tunnel', org.hosting.tunnel, 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)`);
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
- if (!org.hosting.usesDomain || !org.domain)
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
+ }