@wtfalch/foundry 0.2.0 → 0.3.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.
@@ -1,13 +1,14 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { URL } from 'node:url';
1
3
  // ---------------------------------------------------------------------------
2
4
  // Errors
3
5
  // ---------------------------------------------------------------------------
4
6
  /**
5
7
  * Thrown by name for a step this executor deliberately does not implement:
6
- * mail and chat (no admin API exists yet in either service) and the cms
7
- * site (same), plus moving the issuer onto or off the org's domain (built,
8
- * but not in v1 — domain verification and passkey invalidation need their
9
- * own design). Never carries a token, secret or key (task brief: "errors
10
- * carry the step and the provider's status only").
8
+ * chat (no admin API exists yet), plus moving the issuer onto or off the
9
+ * org's domain (built, but not in v1 — domain verification and passkey
10
+ * invalidation need their own design). Never carries a token, secret or key
11
+ * (task brief: "errors carry the step and the provider's status only").
11
12
  */
12
13
  export class NotBuiltError extends Error {
13
14
  constructor(step, detail) {
@@ -15,13 +16,6 @@ export class NotBuiltError extends Error {
15
16
  this.name = 'NotBuiltError';
16
17
  }
17
18
  }
18
- /** `coolify:app` needs source code to build from; `DeclaredOrg.bespokeAppRepo` is the only source this executor knows (#71, #20). */
19
- export class MissingBespokeAppRepoError extends Error {
20
- constructor(slug) {
21
- super(`${slug} has no declared bespokeAppRepo; coolify:app cannot provision a hosted app without one`);
22
- this.name = 'MissingBespokeAppRepoError';
23
- }
24
- }
25
19
  // ---------------------------------------------------------------------------
26
20
  // Hostnames
27
21
  // ---------------------------------------------------------------------------
@@ -36,6 +30,61 @@ export class MissingBespokeAppRepoError extends Error {
36
30
  function hostnameFor(org) {
37
31
  return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
38
32
  }
33
+ /**
34
+ * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
35
+ * from the public site itself at `org.domain` (used directly there). Only
36
+ * ever called from `cmsSite`, which only ever runs once `org.site.enabled`
37
+ * is true, so `org.domain` is non-null by the same `validateSite` guarantee
38
+ * {@link tunnelIngressHostnames}'s own doc comment cites.
39
+ */
40
+ function cmsHostnameFor(org) {
41
+ return `cms.${org.domain}`;
42
+ }
43
+ /**
44
+ * The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
45
+ * (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
46
+ * (`org.ts`'s `SiteConfig.enabled`) has no single app hostname to reuse -- it
47
+ * needs both its public site at `org.domain` and its cms studio (the sign-in
48
+ * and editing surface, `@wtfalch/cms`) at `cms.<domain>`, so both are named
49
+ * here; `org.domain` is non-null in that branch, guaranteed by `org.ts`'s
50
+ * `validateSite` ("site.enabled requires domain"). Every other org --
51
+ * concretely a hosting org, whose one app hostname this served before #115
52
+ * ever existed -- keeps exactly the prior single-hostname behavior
53
+ * ({@link hostnameFor}), unchanged.
54
+ */
55
+ function tunnelIngressHostnames(org) {
56
+ if (org.site.enabled && org.domain)
57
+ return [org.domain, `cms.${org.domain}`];
58
+ return [hostnameFor(org)];
59
+ }
60
+ /**
61
+ * The health URLs an outside prober should call for this org (#43): one per
62
+ * app foundry runs for it. A hosting org's app answers at its own hostname,
63
+ * and a site org's site app and cms app both serve `/api/health`
64
+ * (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
65
+ * has nothing to probe. Exported so archon, which owns uptime, never repeats
66
+ * this file's hostname rules.
67
+ */
68
+ export function healthUrls(org) {
69
+ if (org.hosting.enabled)
70
+ return [`https://${hostnameFor(org)}/api/health`];
71
+ if (org.site.enabled && org.domain) {
72
+ return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
73
+ }
74
+ return [];
75
+ }
76
+ /**
77
+ * The `OrgMailOrg` argument for `applyOrgMail`/`parkOrgMail`: an org with
78
+ * `mail.usesDomain` off has no domain of its own, so it is level 1 on
79
+ * `MAIL_APEX` and its mode is forced to `'hosted'` — `@wtfalch/email`'s own
80
+ * `resolveDomain` refuses any other mode for a domain-less org, and a
81
+ * level-1 `{slug}.{apex}` domain always has Stalwart's MX (foundry#7 "Mail").
82
+ */
83
+ function mailOrgFor(org, usesDomain, mode) {
84
+ return usesDomain && org.domain
85
+ ? { slug: org.slug, domain: org.domain, mode }
86
+ : { slug: org.slug, mode: 'hosted' };
87
+ }
39
88
  /**
40
89
  * Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
41
90
  * itself points that proxy's routing at whatever `domains` an application
@@ -47,6 +96,136 @@ function hostnameFor(org) {
47
96
  * value.
48
97
  */
49
98
  const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
99
+ /** The one Access policy foundry puts on its own Access app (#84). */
100
+ const ACCESS_POLICY_NAME = 'foundry: allowed emails';
101
+ /**
102
+ * The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
103
+ * `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
104
+ * handlers` from app/auth/[...auth]/route.ts") -- every sign-in request an
105
+ * app built on `@wtfalch/auth` serves passes through `/auth/*`, which is
106
+ * what https://github.com/wtfalch/foundry/issues/43's "sign-in paths" names.
107
+ * `http.request.uri.path` (Cloudflare's "Path" field) is one of the two
108
+ * fields the Free plan allows in a rate limiting rule's expression (the
109
+ * other, "Verified Bot", is not needed here) -- checked against
110
+ * developers.cloudflare.com/waf/rate-limiting-rules/, 2026-09-26. Duplicated,
111
+ * not imported, in `scripts/shared-ingress.mjs`'s own constant of the same
112
+ * value: that file sits outside the pnpm workspace (its own top comment) and
113
+ * cannot import from this package.
114
+ */
115
+ const SIGN_IN_PATH_EXPRESSION = 'starts_with(http.request.uri.path, "/auth/")';
116
+ /**
117
+ * The rate limiting rule's fixed `description`, found before create (like
118
+ * `cloudflareAccess`'s app name) so a re-run after a crash reuses it instead
119
+ * of trying to add a second rule -- which the Free plan's one-rule-per-zone
120
+ * ceiling would refuse anyway (#43).
121
+ */
122
+ const RATE_LIMIT_RULE_DESCRIPTION = 'foundry: rate limit sign-in paths';
123
+ /**
124
+ * The Cloudflare Free plan's fixed values for a rate limiting rule's period
125
+ * and mitigation timeout -- the only values that plan accepts for either
126
+ * (developers.cloudflare.com/waf/rate-limiting-rules/, checked 2026-09-26).
127
+ * `characteristics: ['cf.colo.id', 'ip.src']`: the IP address is the Free
128
+ * plan's only counting characteristic, and the API requires `cf.colo.id`
129
+ * explicitly on every rule. The expression uses `starts_with`, since
130
+ * `matches` needs Business or above. `requests_per_period` is foundry's own
131
+ * choice within that ceiling, not a plan limit.
132
+ */
133
+ const RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS = 10;
134
+ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
135
+ /**
136
+ * The shared standard app a level-1 org with no `bespokeAppRepo` gets
137
+ * deployed (gap P1, `.plans/2026-09-26-level-1-org-map.md`): one repo, the
138
+ * same for every such org, decided by William 2026-09-26. `coolifyApp` below
139
+ * names it as the git source in exactly the shape `bespokeAppRepo` itself
140
+ * uses (`org.ts`'s `DeclaredOrg.bespokeAppRepo: string | null`) whenever an
141
+ * org declares none of its own — the repo may not exist yet at decision
142
+ * time, and Coolify's own GitHub App create call is what will fail loudly
143
+ * against it, the same as any other repo name that does not exist.
144
+ */
145
+ const DEFAULT_APP_REPO = 'wtfalch/hestia';
146
+ /**
147
+ * The one repo `cmsSite` builds both of its apps from
148
+ * (https://github.com/wtfalch/cms) -- the 2026-09-25 decision this campaign
149
+ * implements (`.plans/2026-09-25-org-sites.md`, that repo): foundry never
150
+ * calls a cms admin API for the org's site, it provisions two ordinary
151
+ * Coolify applications from this one repo, told apart by
152
+ * `dockerfile_location`. `CMS_APP_DOCKERFILE` is the repo's own root
153
+ * `Dockerfile` (`apps/host`, single-org mode); `CMS_SITE_APP_DOCKERFILE`
154
+ * is `apps/site`'s own Dockerfile (the reference renderer).
155
+ */
156
+ const CMS_REPO = 'wtfalch/cms';
157
+ const CMS_APP_DOCKERFILE = 'Dockerfile';
158
+ const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
159
+ /**
160
+ * Every app foundry deploys keeps its own logs across a redeploy, so a
161
+ * database outage does not also erase the only other copy of the evidence
162
+ * (#75). This is the exact mount path and `LOG_DIR` value lokessmie's own
163
+ * pilot uses (https://github.com/wtfalch/lokessmie/pull/521, verified live
164
+ * with Postgres stopped) — `diff.ts`'s `planApp` names this same path in its
165
+ * own step description, kept as a plain string there rather than importing
166
+ * it from this file, the same way `hostnameFor`'s hostname rule is
167
+ * restated rather than shared across the two files.
168
+ */
169
+ const LOG_DIR = '/app/logs';
170
+ /**
171
+ * The one shared Stalwart apex and MX host every org's mail lives on (#7
172
+ * "Mail"), the same values `@wtfalch/email`'s own README examples use for
173
+ * `applyOrgMail`/`parkOrgMail`'s `OrgMailOptions`.
174
+ */
175
+ const MAIL_APEX = 'wtfalch.dev';
176
+ const MAIL_HOST = 'mail.wtfalch.dev';
177
+ /**
178
+ * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
179
+ * not a `system:kind:key` resource id (that slot already holds the app's
180
+ * `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
181
+ * is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
182
+ * above. A level-1 org declares exactly one web `issuerApps` entry for its
183
+ * one standard app, so one flat key is enough; an org with more than one web
184
+ * app would need a richer key, not built here (Finding, this campaign's
185
+ * report).
186
+ */
187
+ const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
188
+ /**
189
+ * `cmsSite` provisions two applications from one `cms:site` step, but
190
+ * `execute`'s own return value auto-records only one `resourceId`, under
191
+ * `cms:site:<orgId>` (`runSteps`'s own doc comment) -- the same one-step,
192
+ * two-resource shape `zitadelApp`/`ensureZitadelProject` already solve with
193
+ * a flat scope id (`zitadel_project`) alongside the per-key
194
+ * `zitadel:app:<key>` record. `cmsSite` returns the cms app's id as its
195
+ * `resourceId` (so it lands under `cms:site:<orgId>` same as any other
196
+ * step), and records both ids under these two flat keys instead of a
197
+ * `system:kind:key`-shaped one -- `recordScopeId`'s own doc comment requires
198
+ * a key with no `:`, precisely so a scope key can never collide with that
199
+ * form.
200
+ */
201
+ const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
202
+ const SITE_APP_SCOPE_KEY = 'coolify_site_app';
203
+ /**
204
+ * #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
205
+ * created inside `cmsSite` the same way `coolifyDatabase` does for the org's
206
+ * own app -- but `cms:site`'s one step still auto-records only the cms app's
207
+ * id as its `resourceId` (see {@link CMS_APP_SCOPE_KEY}'s doc comment above),
208
+ * so this database's id has no `system:kind:key` slot of its own either.
209
+ * Recorded as a third flat scope id for the same reason: no `:`, so it can
210
+ * never collide with that form.
211
+ */
212
+ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
213
+ /**
214
+ * Whether an application's own Coolify `status` (`` `running:healthy`,
215
+ * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
216
+ * already up, or on its way there -- `findOrCreateCmsApp`'s own check of
217
+ * whether a found-but-parked app still needs `start`ing (#116 review round
218
+ * 3, Finding 1). Only `exited`/`stopped` (Coolify's own resting-off states;
219
+ * `stopped` is not one this codebase has verified against a live instance,
220
+ * included defensively) read as off; a missing `status` defaults to on, the
221
+ * same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
222
+ * uses for the org's own app.
223
+ */
224
+ function isRunningOrStarting(status) {
225
+ if (!status)
226
+ return true;
227
+ return !status.startsWith('exited') && !status.startsWith('stopped');
228
+ }
50
229
  // ---------------------------------------------------------------------------
51
230
  // Executor
52
231
  // ---------------------------------------------------------------------------
@@ -74,6 +253,10 @@ export class FoundryStepExecutor {
74
253
  return this.cloudflareTunnel(action, org, context);
75
254
  case 'cloudflare:zone':
76
255
  return this.cloudflareZone(action);
256
+ case 'cloudflare:access':
257
+ return this.cloudflareAccess(org);
258
+ case 'cloudflare:ratelimit':
259
+ return this.cloudflareRateLimit(org);
77
260
  case 'files:organisation':
78
261
  return this.filesOrganisation(action, org);
79
262
  case 'files:storage':
@@ -83,15 +266,106 @@ export class FoundryStepExecutor {
83
266
  case 'ai:budget':
84
267
  return this.aiBudget(action, org);
85
268
  case 'coolify:mail':
86
- throw new NotBuiltError(`coolify:mail for ${org.slug}`, 'mail has no Stalwart admin API yet; tracked at https://github.com/wtfalch/email/issues/96');
269
+ return this.coolifyMail(action, org);
87
270
  case 'coolify:chat':
88
271
  throw new NotBuiltError(`coolify:chat for ${org.slug}`, "chat's per-org Synapse+MAS has no admin API yet; tracked at https://github.com/wtfalch/chat/issues/40");
89
272
  case 'cms:site':
90
- throw new NotBuiltError(`cms:site for ${org.slug}`, 'the cms site has no admin API yet; tracked at https://github.com/wtfalch/cms/issues/20');
273
+ return this.cmsSite(action, org, context);
91
274
  default:
92
275
  throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
93
276
  }
94
277
  }
278
+ /**
279
+ * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
280
+ * Cloudflare Access app that lets only `hosting.accessEmails` through.
281
+ * foundry names its Access app `foundry:<orgId>` and reuses only an app with
282
+ * that name, so a re-run after a crash does not duplicate it. It refuses,
283
+ * rather than adopt, when another Access app already holds the hostname:
284
+ * destroy would later delete it. It also refuses when its own app carries
285
+ * any policy but foundry's, since that policy could let others in.
286
+ */
287
+ async cloudflareAccess(org) {
288
+ const domain = hostnameFor(org);
289
+ const name = `foundry:${org.id}`;
290
+ const cf = this.ports.cloudflare;
291
+ const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
292
+ const foreign = onDomain.find((a) => a.name !== name);
293
+ if (foreign) {
294
+ throw new Error(`cloudflare:access for ${org.slug}: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
295
+ }
296
+ const app = onDomain[0] ?? (await cf.createAccessApp({ name, domain, type: 'self_hosted' }));
297
+ const policies = await cf.listAccessPolicies(app.id);
298
+ const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
299
+ if (other) {
300
+ throw new Error(`cloudflare:access for ${org.slug}: Access app ${app.id} has policy '${other.name}' that foundry did not create`);
301
+ }
302
+ if (policies.length === 0) {
303
+ await cf.createAccessPolicy(app.id, {
304
+ name: ACCESS_POLICY_NAME,
305
+ decision: 'allow',
306
+ include: org.hosting.accessEmails.map((email) => ({ email: { email } })),
307
+ });
308
+ }
309
+ return { resourceId: app.id };
310
+ }
311
+ /**
312
+ * `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
313
+ * rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
314
+ * zone -- only ever called for an own-domain org (`planRateLimit`'s own
315
+ * gate), so `org.domain` is set and that zone is the org's alone, never
316
+ * shared with another org the way `oikos.host` is.
317
+ *
318
+ * Rate limiting rules attach to a zone's `http_ratelimit` phase entry
319
+ * point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
320
+ * has no typed method for either -- `CloudflarePort.request`'s own doc
321
+ * comment) -- so "find by name" here means reading that one ruleset's
322
+ * `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
323
+ * `cloudflareAccess` above finds its own Access app by name. The Free
324
+ * plan allows exactly one rule per zone, so a rule already there under a
325
+ * different description is refused, never adopted or replaced, mirroring
326
+ * `cloudflareAccess`'s own refusal when a foreign Access app already
327
+ * holds the hostname.
328
+ */
329
+ async cloudflareRateLimit(org) {
330
+ if (!org.domain) {
331
+ throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
332
+ }
333
+ const cf = this.ports.cloudflare;
334
+ const zone = await cf.findZoneByApex(org.domain);
335
+ if (!zone) {
336
+ throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
337
+ }
338
+ const path = `/zones/${zone.id}/rulesets/phases/http_ratelimit/entrypoint`;
339
+ const existing = await cf.request('GET', path, undefined, true);
340
+ const rules = existing?.rules ?? [];
341
+ const own = rules.find((r) => r.description === RATE_LIMIT_RULE_DESCRIPTION);
342
+ if (own)
343
+ return { resourceId: own.id };
344
+ const foreign = rules.find((r) => r.description !== RATE_LIMIT_RULE_DESCRIPTION);
345
+ if (foreign) {
346
+ throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
347
+ }
348
+ const updated = await cf.request('PUT', path, {
349
+ rules: [
350
+ {
351
+ description: RATE_LIMIT_RULE_DESCRIPTION,
352
+ expression: SIGN_IN_PATH_EXPRESSION,
353
+ action: 'block',
354
+ ratelimit: {
355
+ characteristics: ['cf.colo.id', 'ip.src'],
356
+ period: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
357
+ requests_per_period: RATE_LIMIT_REQUESTS_PER_PERIOD,
358
+ mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
359
+ },
360
+ },
361
+ ],
362
+ });
363
+ const created = updated.rules?.[0];
364
+ if (!created) {
365
+ throw new Error(`cloudflare:ratelimit for ${org.slug}: PUT ${path} did not return the created rule`);
366
+ }
367
+ return { resourceId: created.id };
368
+ }
95
369
  // ---- zitadel --------------------------------------------------------
96
370
  async zitadelIssuer(action, org, context) {
97
371
  if (action.desired.provision !== true) {
@@ -178,6 +452,14 @@ export class FoundryStepExecutor {
178
452
  redirectUris: appType === 'native' ? [] : [`https://${hostname}/auth/callback`],
179
453
  postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
180
454
  });
455
+ if (appType === 'web') {
456
+ // `coolifyApp`'s own `pushAppEnv` reads this to push AUTH_CLIENT_ID
457
+ // (level-1 app-env campaign, gap P2) -- public, not a secret, so a
458
+ // plain scope id, not `deliverSecret`. A native app signs in via
459
+ // device code with no cookie session of its own, so its clientId is
460
+ // never something the app's env needs.
461
+ context.recordScopeId(APP_CLIENT_ID_SCOPE_KEY, created.clientId);
462
+ }
181
463
  return { resourceId: created.appId };
182
464
  }
183
465
  async ensureZitadelProject(org, zitadelOrgId, context) {
@@ -218,8 +500,10 @@ export class FoundryStepExecutor {
218
500
  await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
219
501
  return { resourceId: appId };
220
502
  }
221
- if (!org.bespokeAppRepo)
222
- throw new MissingBespokeAppRepoError(org.slug);
503
+ // A level-1 org with no bespoke app of its own gets the shared standard
504
+ // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`), in the
505
+ // same repo shape `bespokeAppRepo` itself uses.
506
+ const appRepo = org.bespokeAppRepo ?? DEFAULT_APP_REPO;
223
507
  const projectUuid = await this.ensureCoolifyProject(org, context);
224
508
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
225
509
  const existing = await this.ports.coolify.findApplicationByName(org.slug, {
@@ -228,6 +512,16 @@ export class FoundryStepExecutor {
228
512
  let appId;
229
513
  if (existing) {
230
514
  appId = existing.uuid;
515
+ // Found by name (rather than just created) is started back up if not
516
+ // already running or on its way there (#130, `findOrCreateCmsApp`'s
517
+ // own analogous fix): parking (the `enabled === false` branch above)
518
+ // stops the app without deleting it, so re-enabling found it here,
519
+ // still stopped, and it stayed stopped forever despite `apply`
520
+ // reporting success.
521
+ const live = await this.ports.coolify.getApplication(existing.uuid);
522
+ if (!isRunningOrStarting(live.status)) {
523
+ await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
524
+ }
231
525
  }
232
526
  else {
233
527
  const server = await this.resolveServer();
@@ -238,7 +532,7 @@ export class FoundryStepExecutor {
238
532
  environment_name: environment.name,
239
533
  environment_uuid: environment.uuid,
240
534
  github_app_uuid: githubApp.uuid,
241
- git_repository: org.bespokeAppRepo,
535
+ git_repository: appRepo,
242
536
  git_branch: org.hosting.gitBranch,
243
537
  build_pack: org.hosting.buildPack,
244
538
  name: org.slug,
@@ -256,8 +550,54 @@ export class FoundryStepExecutor {
256
550
  if (dbId)
257
551
  await this.pushDatabaseUrl(appId, dbId);
258
552
  }
553
+ // Every app keeps its own logs across a redeploy (#75). Unconditional,
554
+ // like the env push below: found by mount path before create, so a
555
+ // re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
556
+ // predates this feature) never mounts a second volume. `existing` tells
557
+ // `ensureLogVolume` whether this app was already running before this
558
+ // call -- see its own doc comment for why that decides whether to
559
+ // restart it.
560
+ await this.ensureLogVolume(appId, org, Boolean(existing));
561
+ // The app receives whatever it needs to sign in (gap P2). Unconditional,
562
+ // unlike the database push above: every app has a hostname, and an
563
+ // upsert of the same value on a later apply is a no-op, not a change.
564
+ await this.pushAppEnv(appId, org, context);
259
565
  return { resourceId: appId };
260
566
  }
567
+ /**
568
+ * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
569
+ * is already there. Found by `mount_path`, not a recorded id — no
570
+ * `serviceIds` entry exists for a storage the way one does for the app or
571
+ * database itself, and Coolify's own listing is cheap enough to check
572
+ * first every time, the same way `coolifyDatabase` finds its database by
573
+ * name before creating one. Never called from the `enabled === false`
574
+ * (park) branch above, so parking an app never touches its volume either.
575
+ *
576
+ * Mounting a volume on an application already running does not attach it
577
+ * to the live container -- Coolify only binds a storage in at the
578
+ * container's next start (Finding, refuted against the first version of
579
+ * this method: it created the storage and let a passing `listStorages`
580
+ * read report `hasLogVolume: true` on the very next plan, with the
581
+ * container itself still writing nowhere). `wasAlreadyRunning` tells this
582
+ * method whether `appId` names an app `coolifyApp` found already there
583
+ * (restart it once the volume is created) or one it just created fresh
584
+ * (already about to get its first deploy/start from
585
+ * `createGithubAppApplication`'s own instant-deploy path, so a restart
586
+ * here would only be a redundant extra one).
587
+ */
588
+ async ensureLogVolume(appId, org, wasAlreadyRunning) {
589
+ const storages = await this.ports.coolify.listStorages(appId);
590
+ const already = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
591
+ if (already)
592
+ return;
593
+ await this.ports.coolify.createPersistentStorage(appId, {
594
+ name: `${org.slug}-logs`,
595
+ mount_path: LOG_DIR,
596
+ });
597
+ if (wasAlreadyRunning) {
598
+ await this.ports.coolify.restart(appId);
599
+ }
600
+ }
261
601
  async coolifyDatabase(action, org, context) {
262
602
  if (action.desired.enabled === false) {
263
603
  const dbId = context.recordedIds()[`coolify:database:${action.key}`];
@@ -333,6 +673,348 @@ export class FoundryStepExecutor {
333
673
  { key: 'DATABASE_URL', value: db.internal_db_url },
334
674
  ]);
335
675
  }
676
+ /**
677
+ * Pushes what the org's app needs to sign a user in through ZITADEL (gap
678
+ * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
679
+ * call rather than folding into `pushDatabaseUrl`'s, since this one runs
680
+ * unconditionally (`coolifyApp`) where that one runs only once
681
+ * `hosting.database` is declared and provisioned.
682
+ *
683
+ * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
684
+ * value is pushed only once its own source step has actually recorded it
685
+ * (`context.recordedIds()`) -- on a first-ever apply,
686
+ * run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
687
+ * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
688
+ * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
689
+ * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
690
+ * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
691
+ * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
692
+ * own doc comment) are out of scope here (task brief).
693
+ */
694
+ async pushAppEnv(appId, org, context) {
695
+ const recorded = context.recordedIds();
696
+ const envs = [
697
+ { key: 'APP_URL', value: `https://${hostnameFor(org)}` },
698
+ // #75: read back by the package-side pino sink (not this repo's job,
699
+ // #75 "What belongs where") once `ensureLogVolume` has mounted the
700
+ // matching volume.
701
+ { key: 'LOG_DIR', value: LOG_DIR },
702
+ ];
703
+ const zitadelOrgId = recorded.zitadel_org;
704
+ if (zitadelOrgId)
705
+ envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
706
+ const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
707
+ if (clientId)
708
+ envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
709
+ // The invited owner's ZITADEL user id (gap P3): the app uses it to make
710
+ // them its own owner in authz on first sign-in. `runSteps` records this
711
+ // under the same `system:kind:key` shape every step's `resourceId` uses
712
+ // (`zitadelOwner`'s own doc comment), not a scope id.
713
+ const ownerUserId = recorded[`zitadel:owner:${org.id}`];
714
+ if (ownerUserId)
715
+ envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
716
+ // Idempotent the same way `pushDatabaseUrl`'s credential is treated:
717
+ // generated once, handed straight to `bulkUpsertEnvs` and nowhere else in
718
+ // this class -- never returned, recorded, logged or interpolated into a
719
+ // thrown error.
720
+ const cookieSecret = await this.ensureAuthCookieSecret(appId);
721
+ if (cookieSecret)
722
+ envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
723
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
724
+ }
725
+ /**
726
+ * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
727
+ * only the first time (task brief), never regenerated or overwritten, so a
728
+ * later apply's `bulkUpsertEnvs` call simply omits the key rather than
729
+ * upsert a fresh value over it.
730
+ */
731
+ async ensureAuthCookieSecret(appId) {
732
+ const envs = await this.ports.coolify.listEnvs(appId);
733
+ const already = envs.some((e) => e.key === 'AUTH_COOKIE_SECRET' && !e.is_preview);
734
+ if (already)
735
+ return null;
736
+ return randomBytes(32).toString('base64');
737
+ }
738
+ /**
739
+ * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
740
+ * database and site app per org"): the 2026-09-25 decision
741
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
742
+ * means foundry never calls a cms admin API for the org's site -- it
743
+ * provisions two ordinary Coolify applications from the one
744
+ * https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
745
+ * provisions the org's own hosted app: a site app (`apps/site`, the
746
+ * reference renderer) at `org.domain`, built from
747
+ * `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
748
+ * https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
749
+ * at {@link cmsHostnameFor}, built from the repo's own root
750
+ * `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
751
+ * `dockerfile_location`.
752
+ *
753
+ * `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
754
+ * own auto-record has only one `resourceId` slot for this step
755
+ * (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
756
+ * this step is named for. The site app's id has no such slot, so both ids
757
+ * are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
758
+ * {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
759
+ * later apply, when nothing gets provisioned to return one -- read
760
+ * independently of each other there, since a crash partway through a
761
+ * first-ever provision can leave only one of the two actually recorded.
762
+ *
763
+ * #117: this step also finds or creates the cms app's own Postgres
764
+ * database ({@link ensureCmsDatabase}) and pushes its owner and runtime
765
+ * connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
766
+ * same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
767
+ * `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
768
+ * id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
769
+ * are: no `system:kind:key` slot of its own. Never touched from the park
770
+ * branch above -- parking stops the two apps only, same as #116's own
771
+ * destroy note; this step never parks or destroys the database.
772
+ */
773
+ async cmsSite(action, org, context) {
774
+ if (action.desired.enabled === false) {
775
+ const recorded = context.recordedIds();
776
+ const cmsAppId = recorded[CMS_APP_SCOPE_KEY];
777
+ const siteAppId = recorded[SITE_APP_SCOPE_KEY];
778
+ if (!cmsAppId && !siteAppId) {
779
+ throw new Error(`cms:site for ${org.slug}: no live cms/site app recorded, nothing to park`);
780
+ }
781
+ // Stops whichever of the two is recorded rather than refusing the
782
+ // whole step over the other -- a crash between recording the site
783
+ // app's id and the cms app's own (`cmsSite`'s provisioning half,
784
+ // above) must not leave a live app stuck running forever because park
785
+ // always threw first.
786
+ if (cmsAppId)
787
+ await this.ports.coolify.raw('POST', `/applications/${cmsAppId}/stop`);
788
+ if (siteAppId)
789
+ await this.ports.coolify.raw('POST', `/applications/${siteAppId}/stop`);
790
+ return { resourceId: cmsAppId ?? siteAppId };
791
+ }
792
+ // `validateSite` ("site.enabled requires domain") guarantees this
793
+ // whenever this step runs -- checked anyway, the same way
794
+ // `cloudflareRateLimit` above guards its own `org.domain` read, since
795
+ // TypeScript's `string | null` gives no static guarantee of it.
796
+ if (!org.domain) {
797
+ throw new Error(`cms:site for ${org.slug}: site.enabled is set but domain is null`);
798
+ }
799
+ const domain = org.domain;
800
+ const projectUuid = await this.ensureCoolifyProject(org, context);
801
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
802
+ const site = await this.findOrCreateCmsApp({
803
+ projectUuid,
804
+ environment,
805
+ name: `${org.slug}-site`,
806
+ dockerfileLocation: CMS_SITE_APP_DOCKERFILE,
807
+ domain,
808
+ });
809
+ const cms = await this.findOrCreateCmsApp({
810
+ projectUuid,
811
+ environment,
812
+ name: `${org.slug}-cms`,
813
+ dockerfileLocation: CMS_APP_DOCKERFILE,
814
+ domain: cmsHostnameFor(org),
815
+ });
816
+ // Every app foundry deploys keeps its own logs (#75) -- reused as-is for
817
+ // both of these two, the same unconditional call `coolifyApp` makes for
818
+ // its own one app.
819
+ await this.ensureLogVolume(site.appId, org, site.existed);
820
+ await this.ensureLogVolume(cms.appId, org, cms.existed);
821
+ context.recordScopeId(SITE_APP_SCOPE_KEY, site.appId);
822
+ context.recordScopeId(CMS_APP_SCOPE_KEY, cms.appId);
823
+ // `@wtfalch/cms`'s own env contracts
824
+ // (https://github.com/wtfalch/cms/blob/main/apps/host/.env.example and
825
+ // .../apps/site/.env.example): the cms app runs in single-org mode
826
+ // (`CMS_ORGANISATION_ID`) and needs the site's own URL to link back to;
827
+ // the site app needs only the cms app's URL to read content from.
828
+ //
829
+ // Pushed here, before the database work below (#117 review round 2),
830
+ // rather than folded into one later `bulkUpsertEnvs` call:
831
+ // `ensureCmsDatabase`/`getDatabase` are deliberately left free to throw
832
+ // (see that section's own comment) instead of caught, so a database
833
+ // failure must not also cost the app these two calls already have
834
+ // everything they need for.
835
+ await this.ports.coolify.bulkUpsertEnvs(cms.appId, [
836
+ { key: 'APP_URL', value: `https://${cmsHostnameFor(org)}` },
837
+ { key: 'CMS_ORGANISATION_ID', value: org.id },
838
+ { key: 'CMS_SITE_URL', value: `https://${domain}` },
839
+ ]);
840
+ await this.ports.coolify.bulkUpsertEnvs(site.appId, [
841
+ { key: 'CMS_URL', value: `https://${cmsHostnameFor(org)}` },
842
+ ]);
843
+ // #117: the cms app's own database, split into a migration-only
844
+ // superuser role (`DATABASE_URL_OWNER`) and a restricted `cms_rt`
845
+ // runtime role (`DATABASE_URL`) -- `ensureCmsDatabase`'s and
846
+ // `ensureCmsRuntimeUrl`'s own doc comments. Neither call below is
847
+ // wrapped in a try/catch: a failure here (Coolify down, a bad response)
848
+ // must still fail this step, so it is visible in the apply's own output
849
+ // and retried on the next one, rather than silently skipped -- the two
850
+ // `bulkUpsertEnvs` calls above have already landed by the time either
851
+ // could throw.
852
+ const dbId = await this.ensureCmsDatabase(org, context, projectUuid, environment);
853
+ context.recordScopeId(CMS_DATABASE_SCOPE_KEY, dbId);
854
+ // Read back from Coolify's own record (`pushDatabaseUrl`'s own doc
855
+ // comment on why: live state, not `createPostgresDatabase`'s return
856
+ // value, since this run may have just found the database rather than
857
+ // created it) -- both values are live credentials, handed straight to
858
+ // `bulkUpsertEnvs` below and nowhere else in this class: never returned,
859
+ // recorded, logged, or interpolated into a thrown error. Its own call,
860
+ // separate from the two app-env calls above, since it has nothing to
861
+ // push once `internal_db_url` is absent.
862
+ const db = await this.ports.coolify.getDatabase(dbId);
863
+ if (db.internal_db_url) {
864
+ const dbEnvs = [
865
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
866
+ ];
867
+ const runtimeUrl = await this.ensureCmsRuntimeUrl(cms.appId, db.internal_db_url);
868
+ if (runtimeUrl)
869
+ dbEnvs.push({ key: 'DATABASE_URL', value: runtimeUrl });
870
+ await this.ports.coolify.bulkUpsertEnvs(cms.appId, dbEnvs);
871
+ }
872
+ return { resourceId: cms.appId };
873
+ }
874
+ /**
875
+ * `cmsSite`'s own find-or-create of a Postgres database for the cms app,
876
+ * modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
877
+ * by `cmsSite` before this is called) and `findOrCreateEnvironment` give
878
+ * the project/environment this reuses, then `listDatabases` finds one
879
+ * already created by name before `createPostgresDatabase` makes one.
880
+ * Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
881
+ * `<org.slug>-db`, so the two never collide in the same project for an org
882
+ * that has both.
883
+ *
884
+ * `context.recordedIds()` is checked first, the same short-circuit
885
+ * `coolifyDatabase` itself does not have (that step's own `resourceId` slot
886
+ * already covers it) -- here it is load-bearing, since this database has
887
+ * no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
888
+ * doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
889
+ * not just the first.
890
+ *
891
+ * Destroying this database is out of scope here (#117, same as #116's own
892
+ * destroy note for the cms/site apps).
893
+ */
894
+ async ensureCmsDatabase(org, context, projectUuid, environment) {
895
+ const existing = context.recordedIds()[CMS_DATABASE_SCOPE_KEY];
896
+ if (existing)
897
+ return existing;
898
+ const name = `${org.slug}-cms-db`;
899
+ const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
900
+ if (found)
901
+ return found.uuid;
902
+ const server = await this.resolveServer();
903
+ const dbName = name.replace(/-/g, '_');
904
+ const created = await this.ports.coolify.createPostgresDatabase({
905
+ project_uuid: projectUuid,
906
+ server_uuid: server.uuid,
907
+ environment_name: environment.name,
908
+ environment_uuid: environment.uuid,
909
+ name,
910
+ postgres_db: dbName,
911
+ postgres_user: dbName,
912
+ });
913
+ return created.uuid;
914
+ }
915
+ /**
916
+ * `null` once the cms app already has a `DATABASE_URL` -- never rotated,
917
+ * the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
918
+ * and for the same kind of reason: `@wtfalch/cms`'s own
919
+ * `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
920
+ * the live `cms_rt` Postgres role from exactly the username/password this
921
+ * method mints, once, at container boot -- overwriting `DATABASE_URL` on a
922
+ * later apply would leave that already-created role's real password out of
923
+ * sync with whatever foundry hands the app next.
924
+ *
925
+ * The password is minted with `randomBytes` (the same import
926
+ * `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
927
+ * swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
928
+ * `postgres://` URL's userinfo segment with no further percent-encoding
929
+ * needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
930
+ * with `URL` for its host, path and query string; its own superuser
931
+ * username and password are discarded -- this mints a distinct, restricted
932
+ * role's own credential, never reuses the superuser's. The query string
933
+ * (`search`) is kept as-is and appended unchanged (#117 review round 2):
934
+ * a parameter there (e.g. `?sslmode=require`) is a connection requirement
935
+ * Coolify itself put on this database, not something specific to the
936
+ * superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
937
+ * (`DATABASE_URL`) connections must carry the same one.
938
+ *
939
+ * The password is a live credential: built here and handed straight to
940
+ * `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
941
+ * never returned, recorded, logged, or interpolated into a thrown error
942
+ * (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
943
+ * `URL`'s own parse failure is caught below rather than left to propagate:
944
+ * nothing here states or controls the superuser password's alphabet
945
+ * (Coolify's own, unlike the `cms_rt` one minted just below it), and
946
+ * Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
947
+ * it carries the *whole* input -- credential included -- on its own
948
+ * `.input` property (never in `.message`, but still reachable by a logger
949
+ * that inspects the error object, not just its message). Caught and
950
+ * turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
951
+ * still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
952
+ * retries this once `listEnvs` still shows no `DATABASE_URL`.
953
+ */
954
+ async ensureCmsRuntimeUrl(appId, dbInternalUrl) {
955
+ const envs = await this.ports.coolify.listEnvs(appId);
956
+ const already = envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview);
957
+ if (already)
958
+ return null;
959
+ let host;
960
+ let pathname;
961
+ let search;
962
+ try {
963
+ ({ host, pathname, search } = new URL(dbInternalUrl));
964
+ }
965
+ catch {
966
+ return null;
967
+ }
968
+ const password = randomBytes(24).toString('base64url');
969
+ return `postgres://cms_rt:${password}@${host}${pathname}${search}`;
970
+ }
971
+ /**
972
+ * `cmsSite`'s own find-by-name-then-create flow for one of its two
973
+ * applications -- the same shape `coolifyApp` above inlines for its own,
974
+ * single, application, extracted here since `cmsSite` needs it twice in
975
+ * one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
976
+ * (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
977
+ * the way `org.hosting` carries for the org's *own* app, since the cms
978
+ * site is not that app.
979
+ *
980
+ * An app found by name (rather than just created) is started back up if
981
+ * it is not already running or on its way there (#116 review round 3,
982
+ * Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
983
+ * apps without deleting them, so re-enabling finds them here, still
984
+ * stopped, and `ProvidersCmsReader` would otherwise keep reporting
985
+ * `enabled: false` forever -- the plan never converging back to on.
986
+ * `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
987
+ * app either) has since closed the same way, inline in that method (#130);
988
+ * this method is `cmsSite`'s only caller.
989
+ */
990
+ async findOrCreateCmsApp(input) {
991
+ const existing = await this.ports.coolify.findApplicationByName(input.name, {
992
+ environmentIds: [input.environment.id],
993
+ });
994
+ if (existing) {
995
+ const live = await this.ports.coolify.getApplication(existing.uuid);
996
+ if (!isRunningOrStarting(live.status)) {
997
+ await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
998
+ }
999
+ return { appId: existing.uuid, existed: true };
1000
+ }
1001
+ const server = await this.resolveServer();
1002
+ const githubApp = await this.ports.coolify.requireGithubApp();
1003
+ const created = await this.ports.coolify.createGithubAppApplication({
1004
+ project_uuid: input.projectUuid,
1005
+ server_uuid: server.uuid,
1006
+ environment_name: input.environment.name,
1007
+ environment_uuid: input.environment.uuid,
1008
+ github_app_uuid: githubApp.uuid,
1009
+ git_repository: CMS_REPO,
1010
+ git_branch: 'main',
1011
+ build_pack: 'dockerfile',
1012
+ name: input.name,
1013
+ domains: input.domain,
1014
+ dockerfile_location: input.dockerfileLocation,
1015
+ });
1016
+ return { appId: created.uuid, existed: false };
1017
+ }
336
1018
  // ---- cloudflare --------------------------------------------------------
337
1019
  async cloudflareTunnel(action, org, context) {
338
1020
  if (action.desired.enabled === false) {
@@ -350,11 +1032,11 @@ export class FoundryStepExecutor {
350
1032
  const tunnelName = `${org.slug}-tunnel`;
351
1033
  const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
352
1034
  const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
353
- const hostname = hostnameFor(org);
354
- // Routes the app's own hostname to Coolify's own Traefik reverse proxy,
355
- // not a per-app container address -- `COOLIFY_PROXY_ORIGIN`, the same
356
- // constant name and value `wtfalch/app-template`'s prior art used
357
- // (`bootstrap/steps/cf-tunnel.ts`, offline.tf's bootstrap, cited by
1035
+ // Routes each of the org's hostnames (`tunnelIngressHostnames`) to
1036
+ // Coolify's own Traefik reverse proxy, not a per-app container address --
1037
+ // `COOLIFY_PROXY_ORIGIN`, the same constant name and value
1038
+ // `wtfalch/app-template`'s prior art used (`bootstrap/steps/cf-tunnel.ts`,
1039
+ // offline.tf's bootstrap, cited by
358
1040
  // https://github.com/wtfalch/foundry/issues/71): every app on the
359
1041
  // Coolify box already gets a Traefik route from the `domains` its
360
1042
  // `coolify:app` step declared (`hostnameFor(org)`, this file's
@@ -363,9 +1045,16 @@ export class FoundryStepExecutor {
363
1045
  // the connector never needs to know the app's own container name or
364
1046
  // uuid. Verified evidence, not a guess (see this campaign's report);
365
1047
  // `http://<slug>:3000`, the placeholder this replaces, named a container
366
- // and port no `coolify:app` step here ever configures.
1048
+ // and port no `coolify:app` step here ever configures. A site-enabled org
1049
+ // (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
1050
+ // returns, all routed to the same proxy -- Traefik itself, not this list,
1051
+ // is what tells the public site apart from the cms studio.
1052
+ const ingress = tunnelIngressHostnames(org).map((hostname) => ({
1053
+ hostname,
1054
+ service: COOLIFY_PROXY_ORIGIN,
1055
+ }));
367
1056
  await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
368
- ingress: [{ hostname, service: COOLIFY_PROXY_ORIGIN }, { service: 'http_status:404' }],
1057
+ ingress: [...ingress, { service: 'http_status:404' }],
369
1058
  });
370
1059
  return { resourceId: tunnel.id };
371
1060
  }
@@ -377,6 +1066,49 @@ export class FoundryStepExecutor {
377
1066
  const created = await this.ports.cloudflare.createZone(domain);
378
1067
  return { resourceId: created.id };
379
1068
  }
1069
+ // ---- mail -----------------------------------------------------------
1070
+ /**
1071
+ * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
1072
+ * or parks it on the one shared Stalwart instance (`this.ports.mail`,
1073
+ * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
1074
+ * publishes whatever DNS records the call returns.
1075
+ */
1076
+ async coolifyMail(action, org) {
1077
+ const mode = action.desired.mode;
1078
+ const usesDomain = action.desired.usesDomain;
1079
+ const options = { apex: MAIL_APEX, mailHost: MAIL_HOST };
1080
+ if (mode === 'off') {
1081
+ await this.ports.mail.parkOrgMail(mailOrgFor(org, usesDomain, 'hosted'), options);
1082
+ return undefined;
1083
+ }
1084
+ const result = await this.ports.mail.applyOrgMail(mailOrgFor(org, usesDomain, mode), options);
1085
+ await this.publishMailDnsRecords(org, result);
1086
+ return { resourceId: result.domain };
1087
+ }
1088
+ /**
1089
+ * Upserts each DNS record `applyOrgMail` says foundry must publish, found
1090
+ * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
1091
+ * comment) so update never mistakes a stale record of a different type for
1092
+ * a match. A `DKIM` record can be absent on the first call, since Stalwart
1093
+ * mints the key on a background task; the next apply publishes it.
1094
+ */
1095
+ async publishMailDnsRecords(org, result) {
1096
+ const cf = this.ports.cloudflare;
1097
+ const zone = await cf.findZoneByApex(result.domain);
1098
+ if (!zone) {
1099
+ throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
1100
+ }
1101
+ for (const record of result.records) {
1102
+ const [existing] = await cf.findDnsRecord(zone.id, record.name, record.type);
1103
+ const rec = { type: record.type, name: record.name, content: record.value };
1104
+ if (existing) {
1105
+ await cf.updateDnsRecord(zone.id, existing.id, rec);
1106
+ }
1107
+ else {
1108
+ await cf.createDnsRecord(zone.id, rec);
1109
+ }
1110
+ }
1111
+ }
380
1112
  // ---- files / ai --------------------------------------------------------
381
1113
  /**
382
1114
  * #80: provisions the org's own organisation on Files, before
@@ -441,6 +1173,9 @@ export class FoundryStepExecutor {
441
1173
  case 'cloudflare:tunnel':
442
1174
  await this.ports.cloudflare.deleteTunnel(target.id);
443
1175
  return;
1176
+ case 'cloudflare:access':
1177
+ await this.ports.cloudflare.deleteAccessApp(target.id);
1178
+ return;
444
1179
  case 'coolify:app':
445
1180
  await this.ports.coolify.deleteApplication(target.id);
446
1181
  return;