@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,3 +1,4 @@
1
+ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
1
2
  import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
2
3
  import type { StepAction } from './diff.js';
3
4
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
@@ -68,6 +69,17 @@ export interface CoolifyPort {
68
69
  }): Promise<{
69
70
  uuid: string;
70
71
  } | null>;
72
+ /**
73
+ * One application's own live status (`` `running:healthy`, `exited:unhealthy`
74
+ * `` -- `@wtfalch/providers`' `CoolifyApplication.status` doc comment) --
75
+ * `findOrCreateCmsApp`'s own check of whether an app it found by name
76
+ * (rather than just created) needs starting again (#116 review round 3,
77
+ * Finding 1).
78
+ */
79
+ getApplication(uuid: string): Promise<{
80
+ uuid: string;
81
+ status?: string;
82
+ }>;
71
83
  createGithubAppApplication(input: {
72
84
  project_uuid: string;
73
85
  server_uuid: string;
@@ -79,6 +91,8 @@ export interface CoolifyPort {
79
91
  build_pack: HostingBuildPack;
80
92
  name?: string;
81
93
  domains?: string;
94
+ /** Selects which of two Dockerfiles in one repo to build (`cmsSite`'s own site/cms apps). */
95
+ dockerfile_location?: string;
82
96
  }): Promise<{
83
97
  uuid: string;
84
98
  }>;
@@ -99,7 +113,8 @@ export interface CoolifyPort {
99
113
  * from `createPostgresDatabase`'s return value) so the app's env is wired
100
114
  * from the database's live state whether this run just created it or found
101
115
  * it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
102
- * campaign). `internal_db_url` is `postgres://user:password@host/db` — a
116
+ * campaign; `cmsSite`'s own cms database, #117). `internal_db_url` is
117
+ * `postgres://user:password@host/db` — a
103
118
  * live credential, present in the raw Coolify API response and reachable
104
119
  * only from inside the Coolify Docker network (`@wtfalch/providers`'
105
120
  * `CoolifyClient.createPostgresDatabase` doc comment).
@@ -122,10 +137,12 @@ export interface CoolifyPort {
122
137
  /**
123
138
  * Upsert environment variables on an application in one call — used here to
124
139
  * push the database's `internal_db_url` in as the app's `DATABASE_URL`
125
- * (hosting-wiring campaign). The value is a live credential: it must reach
126
- * this call and nothing else — never a step's return value, `serviceIds`
127
- * (hence the store), an audit record, a log or a thrown error. See
128
- * `coolifyApp`'s `pushDatabaseUrl`.
140
+ * (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
141
+ * plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
142
+ * value is a live credential: it must reach this call and nothing else —
143
+ * never a step's return value, `serviceIds` (hence the store), an audit
144
+ * record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
145
+ * `cmsSite`'s `ensureCmsRuntimeUrl`.
129
146
  */
130
147
  bulkUpsertEnvs(applicationUuid: string, envs: Array<{
131
148
  key: string;
@@ -134,8 +151,50 @@ export interface CoolifyPort {
134
151
  literal?: boolean;
135
152
  multiline?: boolean;
136
153
  }>): Promise<unknown>;
154
+ /**
155
+ * Every environment variable currently on the application — read before
156
+ * generating `AUTH_COOKIE_SECRET` (`ensureAuthCookieSecret`, level-1
157
+ * app-env campaign) so that value is only ever generated once, never
158
+ * overwritten. Every key comes back twice (a production row and an
159
+ * `is_preview` duplicate, `@wtfalch/providers`' `CoolifyClient.listEnvs`
160
+ * doc comment), so a caller checking "does this key already exist" filters
161
+ * `is_preview === false` the same way a deployed-config reader must.
162
+ */
163
+ listEnvs(applicationUuid: string): Promise<Array<{
164
+ key: string;
165
+ value: string;
166
+ is_preview: boolean;
167
+ }>>;
137
168
  /** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
138
169
  raw(method: string, path: string, body?: unknown): Promise<unknown>;
170
+ /**
171
+ * Persistent (named volume) and file storages already attached to an
172
+ * application (#75) — `ensureLogVolume` reads this before creating one, so
173
+ * a re-run never mounts a second volume at the same path.
174
+ */
175
+ listStorages(applicationUuid: string): Promise<{
176
+ persistent_storages?: Array<{
177
+ uuid: string;
178
+ name?: string;
179
+ mount_path: string;
180
+ }>;
181
+ file_storages?: Array<{
182
+ uuid: string;
183
+ mount_path: string;
184
+ }>;
185
+ }>;
186
+ /** Mounts a named volume on an application (#75) — `ensureLogVolume`'s create path. */
187
+ createPersistentStorage(applicationUuid: string, input: {
188
+ name: string;
189
+ mount_path: string;
190
+ }): Promise<unknown>;
191
+ /**
192
+ * Restarts the running container without a rebuild (#75) — `ensureLogVolume`
193
+ * calls this after mounting a volume on an app that was already running,
194
+ * since Coolify only binds a newly created storage into the container at
195
+ * its next start.
196
+ */
197
+ restart(applicationUuid: string): Promise<unknown>;
139
198
  /** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
140
199
  deleteApplication(uuid: string): Promise<unknown>;
141
200
  /** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
@@ -165,6 +224,31 @@ export interface CloudflarePort {
165
224
  putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
166
225
  /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
167
226
  deleteTunnel(tunnelId: string): Promise<void>;
227
+ /** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
228
+ listAccessApps(): Promise<Array<{
229
+ id: string;
230
+ name: string;
231
+ domain: string;
232
+ }>>;
233
+ createAccessApp(app: {
234
+ name: string;
235
+ domain: string;
236
+ type: string;
237
+ }): Promise<{
238
+ id: string;
239
+ }>;
240
+ listAccessPolicies(appId: string): Promise<Array<{
241
+ id: string;
242
+ name: string;
243
+ }>>;
244
+ createAccessPolicy(appId: string, policy: {
245
+ name: string;
246
+ decision: string;
247
+ include: unknown[];
248
+ }): Promise<{
249
+ id: string;
250
+ }>;
251
+ deleteAccessApp(appId: string): Promise<void>;
168
252
  /**
169
253
  * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
170
254
  * for the org. Nothing in this executor creates or records one yet (Finding,
@@ -174,6 +258,57 @@ export interface CloudflarePort {
174
258
  * creating one.
175
259
  */
176
260
  deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
261
+ /** `coolify:mail`'s lookup of an existing MX/TXT record before upserting it, filtered by type so a stale record of a different type is never mistaken for a match. */
262
+ findDnsRecord(zoneId: string, name: string, type?: string): Promise<Array<{
263
+ id: string;
264
+ type: string;
265
+ content: string;
266
+ name: string;
267
+ }>>;
268
+ createDnsRecord(zoneId: string, rec: {
269
+ type: string;
270
+ name: string;
271
+ content: string;
272
+ proxied?: boolean;
273
+ ttl?: number;
274
+ }): Promise<{
275
+ id: string;
276
+ name: string;
277
+ content: string;
278
+ }>;
279
+ updateDnsRecord(zoneId: string, recordId: string, rec: {
280
+ type: string;
281
+ name: string;
282
+ content: string;
283
+ proxied?: boolean;
284
+ ttl?: number;
285
+ }): Promise<{
286
+ id: string;
287
+ name: string;
288
+ content: string;
289
+ }>;
290
+ /**
291
+ * Escape hatch (mirrors `CoolifyPort.raw`'s own precedent above) --
292
+ * `@wtfalch/providers` 0.5.0's `CloudflareClient` has no typed method for
293
+ * Health Checks or zone rulesets/rate-limiting rules
294
+ * (https://github.com/wtfalch/foundry/issues/43), only this public,
295
+ * generic `request<T>(method, path, body?, missingOkay?)`, which
296
+ * this port names directly rather than inventing a narrower method no real
297
+ * client could structurally satisfy. `cloudflareRateLimit` below is its one
298
+ * caller today. `missingOkay` makes a 404 resolve to `undefined` instead of
299
+ * throwing, matching `CloudflareClient`'s own real behavior exactly.
300
+ */
301
+ request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
302
+ }
303
+ /**
304
+ * The Stalwart calls this executor makes for one org's mail, shaped to match
305
+ * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
306
+ * with the `StalwartClient` bound, the same way {@link CloudflarePort} mirrors
307
+ * `CloudflareClient`.
308
+ */
309
+ export interface MailPort {
310
+ applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
311
+ parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
177
312
  }
178
313
  /**
179
314
  * The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
@@ -350,6 +485,7 @@ export interface FoundryStepExecutorPorts {
350
485
  coolify: CoolifyPort;
351
486
  cloudflare: CloudflarePort;
352
487
  zitadel: ZitadelPort;
488
+ mail: MailPort;
353
489
  /** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
354
490
  filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
355
491
  aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
@@ -373,25 +509,58 @@ export interface FoundryStepExecutorPorts {
373
509
  }
374
510
  /**
375
511
  * Thrown by name for a step this executor deliberately does not implement:
376
- * mail and chat (no admin API exists yet in either service) and the cms
377
- * site (same), plus moving the issuer onto or off the org's domain (built,
378
- * but not in v1 — domain verification and passkey invalidation need their
379
- * own design). Never carries a token, secret or key (task brief: "errors
380
- * carry the step and the provider's status only").
512
+ * chat (no admin API exists yet), plus moving the issuer onto or off the
513
+ * org's domain (built, but not in v1 — domain verification and passkey
514
+ * invalidation need their own design). Never carries a token, secret or key
515
+ * (task brief: "errors carry the step and the provider's status only").
381
516
  */
382
517
  export declare class NotBuiltError extends Error {
383
518
  constructor(step: string, detail: string);
384
519
  }
385
- /** `coolify:app` needs source code to build from; `DeclaredOrg.bespokeAppRepo` is the only source this executor knows (#71, #20). */
386
- export declare class MissingBespokeAppRepoError extends Error {
387
- constructor(slug: string);
388
- }
520
+ /**
521
+ * The health URLs an outside prober should call for this org (#43): one per
522
+ * app foundry runs for it. A hosting org's app answers at its own hostname,
523
+ * and a site org's site app and cms app both serve `/api/health`
524
+ * (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
525
+ * has nothing to probe. Exported so archon, which owns uptime, never repeats
526
+ * this file's hostname rules.
527
+ */
528
+ export declare function healthUrls(org: DeclaredOrg): string[];
389
529
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
390
530
  private readonly ports;
391
531
  constructor(ports: FoundryStepExecutorPorts);
392
532
  execute(action: StepAction, context: StepExecutionContext): Promise<{
393
533
  resourceId?: string;
394
534
  } | undefined>;
535
+ /**
536
+ * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
537
+ * Cloudflare Access app that lets only `hosting.accessEmails` through.
538
+ * foundry names its Access app `foundry:<orgId>` and reuses only an app with
539
+ * that name, so a re-run after a crash does not duplicate it. It refuses,
540
+ * rather than adopt, when another Access app already holds the hostname:
541
+ * destroy would later delete it. It also refuses when its own app carries
542
+ * any policy but foundry's, since that policy could let others in.
543
+ */
544
+ private cloudflareAccess;
545
+ /**
546
+ * `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
547
+ * rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
548
+ * zone -- only ever called for an own-domain org (`planRateLimit`'s own
549
+ * gate), so `org.domain` is set and that zone is the org's alone, never
550
+ * shared with another org the way `oikos.host` is.
551
+ *
552
+ * Rate limiting rules attach to a zone's `http_ratelimit` phase entry
553
+ * point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
554
+ * has no typed method for either -- `CloudflarePort.request`'s own doc
555
+ * comment) -- so "find by name" here means reading that one ruleset's
556
+ * `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
557
+ * `cloudflareAccess` above finds its own Access app by name. The Free
558
+ * plan allows exactly one rule per zone, so a rule already there under a
559
+ * different description is refused, never adopted or replaced, mirroring
560
+ * `cloudflareAccess`'s own refusal when a foreign Access app already
561
+ * holds the hostname.
562
+ */
563
+ private cloudflareRateLimit;
395
564
  private zitadelIssuer;
396
565
  /**
397
566
  * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
@@ -416,6 +585,28 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
416
585
  private ensureCoolifyProject;
417
586
  private resolveServer;
418
587
  private coolifyApp;
588
+ /**
589
+ * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
590
+ * is already there. Found by `mount_path`, not a recorded id — no
591
+ * `serviceIds` entry exists for a storage the way one does for the app or
592
+ * database itself, and Coolify's own listing is cheap enough to check
593
+ * first every time, the same way `coolifyDatabase` finds its database by
594
+ * name before creating one. Never called from the `enabled === false`
595
+ * (park) branch above, so parking an app never touches its volume either.
596
+ *
597
+ * Mounting a volume on an application already running does not attach it
598
+ * to the live container -- Coolify only binds a storage in at the
599
+ * container's next start (Finding, refuted against the first version of
600
+ * this method: it created the storage and let a passing `listStorages`
601
+ * read report `hasLogVolume: true` on the very next plan, with the
602
+ * container itself still writing nowhere). `wasAlreadyRunning` tells this
603
+ * method whether `appId` names an app `coolifyApp` found already there
604
+ * (restart it once the volume is created) or one it just created fresh
605
+ * (already about to get its first deploy/start from
606
+ * `createGithubAppApplication`'s own instant-deploy path, so a restart
607
+ * here would only be a redundant extra one).
608
+ */
609
+ private ensureLogVolume;
419
610
  private coolifyDatabase;
420
611
  /**
421
612
  * Pushes the database's connection string into the app's environment as
@@ -440,8 +631,166 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
440
631
  * never interpolated into a thrown error.
441
632
  */
442
633
  private pushDatabaseUrl;
634
+ /**
635
+ * Pushes what the org's app needs to sign a user in through ZITADEL (gap
636
+ * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
637
+ * call rather than folding into `pushDatabaseUrl`'s, since this one runs
638
+ * unconditionally (`coolifyApp`) where that one runs only once
639
+ * `hosting.database` is declared and provisioned.
640
+ *
641
+ * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
642
+ * value is pushed only once its own source step has actually recorded it
643
+ * (`context.recordedIds()`) -- on a first-ever apply,
644
+ * run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
645
+ * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
646
+ * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
647
+ * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
648
+ * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
649
+ * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
650
+ * own doc comment) are out of scope here (task brief).
651
+ */
652
+ private pushAppEnv;
653
+ /**
654
+ * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
655
+ * only the first time (task brief), never regenerated or overwritten, so a
656
+ * later apply's `bulkUpsertEnvs` call simply omits the key rather than
657
+ * upsert a fresh value over it.
658
+ */
659
+ private ensureAuthCookieSecret;
660
+ /**
661
+ * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
662
+ * database and site app per org"): the 2026-09-25 decision
663
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
664
+ * means foundry never calls a cms admin API for the org's site -- it
665
+ * provisions two ordinary Coolify applications from the one
666
+ * https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
667
+ * provisions the org's own hosted app: a site app (`apps/site`, the
668
+ * reference renderer) at `org.domain`, built from
669
+ * `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
670
+ * https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
671
+ * at {@link cmsHostnameFor}, built from the repo's own root
672
+ * `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
673
+ * `dockerfile_location`.
674
+ *
675
+ * `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
676
+ * own auto-record has only one `resourceId` slot for this step
677
+ * (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
678
+ * this step is named for. The site app's id has no such slot, so both ids
679
+ * are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
680
+ * {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
681
+ * later apply, when nothing gets provisioned to return one -- read
682
+ * independently of each other there, since a crash partway through a
683
+ * first-ever provision can leave only one of the two actually recorded.
684
+ *
685
+ * #117: this step also finds or creates the cms app's own Postgres
686
+ * database ({@link ensureCmsDatabase}) and pushes its owner and runtime
687
+ * connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
688
+ * same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
689
+ * `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
690
+ * id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
691
+ * are: no `system:kind:key` slot of its own. Never touched from the park
692
+ * branch above -- parking stops the two apps only, same as #116's own
693
+ * destroy note; this step never parks or destroys the database.
694
+ */
695
+ private cmsSite;
696
+ /**
697
+ * `cmsSite`'s own find-or-create of a Postgres database for the cms app,
698
+ * modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
699
+ * by `cmsSite` before this is called) and `findOrCreateEnvironment` give
700
+ * the project/environment this reuses, then `listDatabases` finds one
701
+ * already created by name before `createPostgresDatabase` makes one.
702
+ * Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
703
+ * `<org.slug>-db`, so the two never collide in the same project for an org
704
+ * that has both.
705
+ *
706
+ * `context.recordedIds()` is checked first, the same short-circuit
707
+ * `coolifyDatabase` itself does not have (that step's own `resourceId` slot
708
+ * already covers it) -- here it is load-bearing, since this database has
709
+ * no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
710
+ * doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
711
+ * not just the first.
712
+ *
713
+ * Destroying this database is out of scope here (#117, same as #116's own
714
+ * destroy note for the cms/site apps).
715
+ */
716
+ private ensureCmsDatabase;
717
+ /**
718
+ * `null` once the cms app already has a `DATABASE_URL` -- never rotated,
719
+ * the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
720
+ * and for the same kind of reason: `@wtfalch/cms`'s own
721
+ * `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
722
+ * the live `cms_rt` Postgres role from exactly the username/password this
723
+ * method mints, once, at container boot -- overwriting `DATABASE_URL` on a
724
+ * later apply would leave that already-created role's real password out of
725
+ * sync with whatever foundry hands the app next.
726
+ *
727
+ * The password is minted with `randomBytes` (the same import
728
+ * `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
729
+ * swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
730
+ * `postgres://` URL's userinfo segment with no further percent-encoding
731
+ * needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
732
+ * with `URL` for its host, path and query string; its own superuser
733
+ * username and password are discarded -- this mints a distinct, restricted
734
+ * role's own credential, never reuses the superuser's. The query string
735
+ * (`search`) is kept as-is and appended unchanged (#117 review round 2):
736
+ * a parameter there (e.g. `?sslmode=require`) is a connection requirement
737
+ * Coolify itself put on this database, not something specific to the
738
+ * superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
739
+ * (`DATABASE_URL`) connections must carry the same one.
740
+ *
741
+ * The password is a live credential: built here and handed straight to
742
+ * `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
743
+ * never returned, recorded, logged, or interpolated into a thrown error
744
+ * (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
745
+ * `URL`'s own parse failure is caught below rather than left to propagate:
746
+ * nothing here states or controls the superuser password's alphabet
747
+ * (Coolify's own, unlike the `cms_rt` one minted just below it), and
748
+ * Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
749
+ * it carries the *whole* input -- credential included -- on its own
750
+ * `.input` property (never in `.message`, but still reachable by a logger
751
+ * that inspects the error object, not just its message). Caught and
752
+ * turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
753
+ * still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
754
+ * retries this once `listEnvs` still shows no `DATABASE_URL`.
755
+ */
756
+ private ensureCmsRuntimeUrl;
757
+ /**
758
+ * `cmsSite`'s own find-by-name-then-create flow for one of its two
759
+ * applications -- the same shape `coolifyApp` above inlines for its own,
760
+ * single, application, extracted here since `cmsSite` needs it twice in
761
+ * one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
762
+ * (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
763
+ * the way `org.hosting` carries for the org's *own* app, since the cms
764
+ * site is not that app.
765
+ *
766
+ * An app found by name (rather than just created) is started back up if
767
+ * it is not already running or on its way there (#116 review round 3,
768
+ * Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
769
+ * apps without deleting them, so re-enabling finds them here, still
770
+ * stopped, and `ProvidersCmsReader` would otherwise keep reporting
771
+ * `enabled: false` forever -- the plan never converging back to on.
772
+ * `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
773
+ * app either) has since closed the same way, inline in that method (#130);
774
+ * this method is `cmsSite`'s only caller.
775
+ */
776
+ private findOrCreateCmsApp;
443
777
  private cloudflareTunnel;
444
778
  private cloudflareZone;
779
+ /**
780
+ * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
781
+ * or parks it on the one shared Stalwart instance (`this.ports.mail`,
782
+ * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
783
+ * publishes whatever DNS records the call returns.
784
+ */
785
+ private coolifyMail;
786
+ /**
787
+ * Upserts each DNS record `applyOrgMail` says foundry must publish, found
788
+ * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
789
+ * comment) so update never mistakes a stale record of a different type for
790
+ * a match. A `DKIM` record can be absent on the first call, since Stalwart
791
+ * mints the key on a background task; the next apply publishes it.
792
+ */
793
+ private publishMailDnsRecords;
445
794
  /**
446
795
  * #80: provisions the org's own organisation on Files, before
447
796
  * {@link filesStorage} can set its spend limit. `provisionOrganisation` is