@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.
@@ -1,4 +1,6 @@
1
+ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
1
2
  import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
3
+ import type { DeclaredOrgStore } from '../store.js';
2
4
  import type { StepAction } from './diff.js';
3
5
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
4
6
  /**
@@ -68,6 +70,17 @@ export interface CoolifyPort {
68
70
  }): Promise<{
69
71
  uuid: string;
70
72
  } | null>;
73
+ /**
74
+ * One application's own live status (`` `running:healthy`, `exited:unhealthy`
75
+ * `` -- `@wtfalch/providers`' `CoolifyApplication.status` doc comment) --
76
+ * `findOrCreateCmsApp`'s own check of whether an app it found by name
77
+ * (rather than just created) needs starting again (#116 review round 3,
78
+ * Finding 1).
79
+ */
80
+ getApplication(uuid: string): Promise<{
81
+ uuid: string;
82
+ status?: string;
83
+ }>;
71
84
  createGithubAppApplication(input: {
72
85
  project_uuid: string;
73
86
  server_uuid: string;
@@ -79,6 +92,8 @@ export interface CoolifyPort {
79
92
  build_pack: HostingBuildPack;
80
93
  name?: string;
81
94
  domains?: string;
95
+ /** Selects which of two Dockerfiles in one repo to build (`cmsSite`'s own site/cms apps). */
96
+ dockerfile_location?: string;
82
97
  }): Promise<{
83
98
  uuid: string;
84
99
  }>;
@@ -90,6 +105,7 @@ export interface CoolifyPort {
90
105
  name: string;
91
106
  postgres_db: string;
92
107
  postgres_user: string;
108
+ postgres_initdb_args?: string;
93
109
  }): Promise<{
94
110
  uuid: string;
95
111
  internal_db_url: string;
@@ -99,7 +115,8 @@ export interface CoolifyPort {
99
115
  * from `createPostgresDatabase`'s return value) so the app's env is wired
100
116
  * from the database's live state whether this run just created it or found
101
117
  * it already there (`coolifyApp`/`coolifyDatabase` below, hosting-wiring
102
- * campaign). `internal_db_url` is `postgres://user:password@host/db` — a
118
+ * campaign; `cmsSite`'s own cms database, #117). `internal_db_url` is
119
+ * `postgres://user:password@host/db` — a
103
120
  * live credential, present in the raw Coolify API response and reachable
104
121
  * only from inside the Coolify Docker network (`@wtfalch/providers`'
105
122
  * `CoolifyClient.createPostgresDatabase` doc comment).
@@ -122,10 +139,12 @@ export interface CoolifyPort {
122
139
  /**
123
140
  * Upsert environment variables on an application in one call — used here to
124
141
  * 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`.
142
+ * (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
143
+ * plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
144
+ * value is a live credential: it must reach this call and nothing else —
145
+ * never a step's return value, `serviceIds` (hence the store), an audit
146
+ * record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
147
+ * `cmsSite`'s `ensureCmsRuntimeUrl`.
129
148
  */
130
149
  bulkUpsertEnvs(applicationUuid: string, envs: Array<{
131
150
  key: string;
@@ -134,12 +153,122 @@ export interface CoolifyPort {
134
153
  literal?: boolean;
135
154
  multiline?: boolean;
136
155
  }>): Promise<unknown>;
156
+ /**
157
+ * Every environment variable currently on the application — read before
158
+ * generating `AUTH_COOKIE_SECRET` (`ensureAuthCookieSecret`, level-1
159
+ * app-env campaign) so that value is only ever generated once, never
160
+ * overwritten. Every key comes back twice (a production row and an
161
+ * `is_preview` duplicate, `@wtfalch/providers`' `CoolifyClient.listEnvs`
162
+ * doc comment), so a caller checking "does this key already exist" filters
163
+ * `is_preview === false` the same way a deployed-config reader must.
164
+ */
165
+ listEnvs(applicationUuid: string): Promise<Array<{
166
+ key: string;
167
+ value: string;
168
+ is_preview: boolean;
169
+ }>>;
137
170
  /** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
138
171
  raw(method: string, path: string, body?: unknown): Promise<unknown>;
172
+ /**
173
+ * Persistent (named volume) and file storages already attached to an
174
+ * application (#75) — `ensureLogVolume` reads this before creating one, so
175
+ * a re-run never mounts a second volume at the same path.
176
+ */
177
+ listStorages(applicationUuid: string): Promise<{
178
+ persistent_storages?: Array<{
179
+ uuid: string;
180
+ name?: string;
181
+ mount_path: string;
182
+ }>;
183
+ file_storages?: Array<{
184
+ uuid: string;
185
+ mount_path: string;
186
+ }>;
187
+ }>;
188
+ /** Mounts a named volume on an application (#75) — `ensureLogVolume`'s create path. */
189
+ createPersistentStorage(applicationUuid: string, input: {
190
+ name: string;
191
+ mount_path: string;
192
+ }): Promise<unknown>;
193
+ /**
194
+ * Restarts the running container without a rebuild (#75) — `ensureLogVolume`
195
+ * calls this after mounting a volume on an app that was already running,
196
+ * since Coolify only binds a newly created storage into the container at
197
+ * its next start.
198
+ */
199
+ restart(applicationUuid: string): Promise<unknown>;
139
200
  /** `FoundryPlanApply.destroy`'s teardown of the org's own app. */
140
201
  deleteApplication(uuid: string): Promise<unknown>;
141
202
  /** `FoundryPlanApply.destroy`'s teardown of the org's own database. */
142
203
  deleteDatabase(uuid: string): Promise<unknown>;
204
+ /**
205
+ * An application that runs a published image as-is (#128, `coolifyCalls`'s
206
+ * own Postgres) — matches `@wtfalch/providers`' `CoolifyClient.
207
+ * createDockerImageApplication` method-for-method.
208
+ */
209
+ createDockerImageApplication(input: {
210
+ project_uuid: string;
211
+ server_uuid: string;
212
+ environment_name: string;
213
+ environment_uuid: string;
214
+ docker_registry_image_name: string;
215
+ docker_registry_image_tag: string;
216
+ ports_exposes: string;
217
+ name?: string;
218
+ description?: string;
219
+ instant_deploy?: boolean;
220
+ }): Promise<{
221
+ uuid: string;
222
+ }>;
223
+ /**
224
+ * An application built from an inline Dockerfile (#128, `coolifyCalls`'s own
225
+ * Synapse and MAS — neither's published image takes the module/command this
226
+ * stack needs from a plain image application) — matches `CoolifyClient.
227
+ * createDockerfileApplication` method-for-method.
228
+ */
229
+ createDockerfileApplication(input: {
230
+ project_uuid: string;
231
+ server_uuid: string;
232
+ environment_name: string;
233
+ environment_uuid: string;
234
+ dockerfile: string;
235
+ name?: string;
236
+ description?: string;
237
+ instant_deploy?: boolean;
238
+ }): Promise<{
239
+ uuid: string;
240
+ }>;
241
+ /**
242
+ * A single file mounted into the container (#128) — how `homeserver.yaml`
243
+ * and MAS's `config.yaml` reach a Docker-image application with no git
244
+ * repository of its own to carry them. Matches `CoolifyClient.
245
+ * createFileStorage` method-for-method.
246
+ */
247
+ createFileStorage(applicationUuid: string, input: {
248
+ mount_path: string;
249
+ content: string;
250
+ }): Promise<unknown>;
251
+ /** Updates an existing file storage's content in place. Matches `CoolifyClient.updateFileStorage`. */
252
+ updateFileStorage(applicationUuid: string, storageUuid: string, content: string): Promise<unknown>;
253
+ /**
254
+ * A generic application PATCH (#128) — `coolifyCalls` uses this to give
255
+ * Postgres/Synapse/MAS a fixed `custom_internal_name` so the other two can
256
+ * address each other by a name that never changes across a re-run, instead
257
+ * of guessing at Coolify's own container-naming scheme. Matches
258
+ * `CoolifyClient.updateApplication` method-for-method.
259
+ */
260
+ updateApplication(uuid: string, patch: Record<string, unknown>): Promise<unknown>;
261
+ /**
262
+ * Queues a deployment (#128) — `coolifyCalls` calls this after writing a
263
+ * fresh Synapse/MAS config file, since a file-storage write alone does not
264
+ * reach the running container until its next deploy (issue #128's "a
265
+ * running app is redeployed when its config changed"). Fire-and-forget,
266
+ * like `restart` above: this executor does not poll a deployment to
267
+ * completion anywhere else, and `coolifyCalls` only calls this when the
268
+ * config it just wrote was actually freshly minted. Matches `CoolifyClient.
269
+ * deploy` method-for-method.
270
+ */
271
+ deploy(uuid: string, force?: boolean): Promise<unknown>;
143
272
  }
144
273
  /**
145
274
  * The Cloudflare calls this executor makes, shaped to match
@@ -165,6 +294,31 @@ export interface CloudflarePort {
165
294
  putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
166
295
  /** `FoundryPlanApply.destroy`'s teardown of the org's own Cloudflare tunnel. */
167
296
  deleteTunnel(tunnelId: string): Promise<void>;
297
+ /** `cloudflareAccess` finds the org's Access app by domain before creating one (#84). */
298
+ listAccessApps(): Promise<Array<{
299
+ id: string;
300
+ name: string;
301
+ domain: string;
302
+ }>>;
303
+ createAccessApp(app: {
304
+ name: string;
305
+ domain: string;
306
+ type: string;
307
+ }): Promise<{
308
+ id: string;
309
+ }>;
310
+ listAccessPolicies(appId: string): Promise<Array<{
311
+ id: string;
312
+ name: string;
313
+ }>>;
314
+ createAccessPolicy(appId: string, policy: {
315
+ name: string;
316
+ decision: string;
317
+ include: unknown[];
318
+ }): Promise<{
319
+ id: string;
320
+ }>;
321
+ deleteAccessApp(appId: string): Promise<void>;
168
322
  /**
169
323
  * `FoundryPlanApply.destroy`'s teardown of any DNS record foundry created
170
324
  * for the org. Nothing in this executor creates or records one yet (Finding,
@@ -174,6 +328,154 @@ export interface CloudflarePort {
174
328
  * creating one.
175
329
  */
176
330
  deleteDnsRecord(zoneId: string, recordId: string): Promise<void>;
331
+ /** `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. */
332
+ findDnsRecord(zoneId: string, name: string, type?: string): Promise<Array<{
333
+ id: string;
334
+ type: string;
335
+ content: string;
336
+ name: string;
337
+ }>>;
338
+ createDnsRecord(zoneId: string, rec: {
339
+ type: string;
340
+ name: string;
341
+ content: string;
342
+ proxied?: boolean;
343
+ ttl?: number;
344
+ }): Promise<{
345
+ id: string;
346
+ name: string;
347
+ content: string;
348
+ }>;
349
+ updateDnsRecord(zoneId: string, recordId: string, rec: {
350
+ type: string;
351
+ name: string;
352
+ content: string;
353
+ proxied?: boolean;
354
+ ttl?: number;
355
+ }): Promise<{
356
+ id: string;
357
+ name: string;
358
+ content: string;
359
+ }>;
360
+ /**
361
+ * Escape hatch (mirrors `CoolifyPort.raw`'s own precedent above) --
362
+ * `@wtfalch/providers` 0.5.0's `CloudflareClient` has no typed method for
363
+ * Health Checks or zone rulesets/rate-limiting rules
364
+ * (https://github.com/wtfalch/foundry/issues/43), only this public,
365
+ * generic `request<T>(method, path, body?, missingOkay?)`, which
366
+ * this port names directly rather than inventing a narrower method no real
367
+ * client could structurally satisfy. `cloudflareRateLimit` below is its one
368
+ * caller today. `missingOkay` makes a 404 resolve to `undefined` instead of
369
+ * throwing, matching `CloudflareClient`'s own real behavior exactly.
370
+ */
371
+ request<T>(method: string, path: string, body?: unknown, missingOkay?: boolean): Promise<T>;
372
+ }
373
+ /**
374
+ * One DNS record a Resend domain's own response names it must publish --
375
+ * `record` is Resend's own label (`SPF`, `DKIM`, `DMARC`, `MX`), which
376
+ * {@link ResendPort.createDomain}/{@link ResendPort.getDomain} return
377
+ * alongside `type`/`name`/`value` for `coolifyMail` to act on, the same shape
378
+ * https://resend.com/docs/api-reference/domains/create-domain documents.
379
+ */
380
+ export interface ResendDnsRecordSpec {
381
+ readonly record: 'SPF' | 'DKIM' | 'DMARC' | 'MX' | string;
382
+ readonly name: string;
383
+ readonly type: 'TXT' | 'CNAME' | 'MX';
384
+ readonly value: string;
385
+ readonly priority?: number;
386
+ }
387
+ export interface ResendDomain {
388
+ readonly id: string;
389
+ readonly name: string;
390
+ readonly status: string;
391
+ readonly records?: readonly ResendDnsRecordSpec[];
392
+ }
393
+ /**
394
+ * The Resend calls this executor makes to verify an org's mail domain (#138)
395
+ * and mint its app's sending key (#140 rework) -- the shared Stalwart relays
396
+ * every outbound message through Resend
397
+ * (https://github.com/wtfalch/email/blob/main/packages/email/src/postmaster/objects.ts#L149-L168),
398
+ * which refuses mail from a domain Resend has not verified. `@wtfalch/providers`
399
+ * has no Resend client yet, unlike Coolify/Cloudflare/ZITADEL above, so this
400
+ * port names Resend's own REST shapes directly
401
+ * (https://resend.com/docs/api-reference/domains/create-domain,
402
+ * https://resend.com/docs/api-reference/api-keys/create-api-key,
403
+ * https://resend.com/docs/api-reference/api-keys/list-api-keys,
404
+ * https://resend.com/docs/api-reference/api-keys/delete-api-key) -- the same
405
+ * prior art `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts` already
406
+ * proved against a live account. The relay's own `<slug>-relay` key
407
+ * (`.plans/2026-09-27-org-mail-resend-design.md`) is a separate change, filed
408
+ * as https://github.com/wtfalch/foundry/issues/149 and out of scope here.
409
+ */
410
+ export interface ResendPort {
411
+ /** An exact name match, never a listing scan `coolifyMail` has to page itself. */
412
+ findDomain(name: string): Promise<{
413
+ id: string;
414
+ name: string;
415
+ status: string;
416
+ } | undefined>;
417
+ createDomain(name: string): Promise<ResendDomain>;
418
+ /** Re-reads a domain already found by {@link findDomain}, for its current status and DNS records. */
419
+ getDomain(id: string): Promise<ResendDomain>;
420
+ /**
421
+ * Asks Resend to re-check DNS now. `coolifyMail` never polls or waits on
422
+ * this -- the task brief: "do not block the plan on DNS propagation:
423
+ * record the status, and let the next apply re-check it (idempotent, like
424
+ * `applyOrgMail`)".
425
+ */
426
+ verifyDomain(id: string): Promise<{
427
+ status: string;
428
+ }>;
429
+ /**
430
+ * Every key in the account, `id`+`name` only -- Resend never returns a
431
+ * key's token except at creation, so this is how the mint rule
432
+ * (`.plans/2026-09-27-org-mail-resend-design.md` "One rule for minting")
433
+ * tells whether the app's key is healthy before deciding to mint. `ensureAppResendKey`'s own doc comment below
434
+ * spells the rule out.
435
+ */
436
+ listApiKeys(): Promise<Array<{
437
+ id: string;
438
+ name: string;
439
+ }>>;
440
+ /** Sending-only, scoped to one domain -- the app step's `<slug>-app@<domainId>` (`ensureAppResendKey`), minted under the mint rule. */
441
+ createApiKey(input: {
442
+ name: string;
443
+ permission: 'sending_access';
444
+ domainId: string;
445
+ }): Promise<{
446
+ id: string;
447
+ token: string;
448
+ }>;
449
+ /** Deletes one key by id -- the mint rule's own cleanup of an orphan, a duplicate, or a key for a mail domain the org no longer uses. */
450
+ deleteApiKey(id: string): Promise<void>;
451
+ }
452
+ /**
453
+ * The Stalwart calls this executor makes for one org's mail, shaped to match
454
+ * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
455
+ * with the `StalwartClient` bound, the same way {@link CloudflarePort} mirrors
456
+ * `CloudflareClient`.
457
+ */
458
+ export interface MailPort {
459
+ applyOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<OrgMailResult>;
460
+ parkOrgMail(org: OrgMailOrg, options: OrgMailOptions): Promise<void>;
461
+ }
462
+ /**
463
+ * The shared calls SFU's own LiveKit API credentials (#122, #127), resolved
464
+ * by the host from wherever `scripts/calls-sfu-deploy.mjs`'s own
465
+ * `CallsSfuConfig` delivered them (#145) -- this package never mints or
466
+ * invents a LiveKit key/secret pair itself, the same "the executor is
467
+ * constructed with narrow ports ... a host [archon] supplies real clients"
468
+ * rule this file's own top doc comment states for every provider credential.
469
+ * `resolveCredentials` returns `null` when the SFU has not been configured
470
+ * yet, the same "nothing recorded" signal a bare `CoolifyPort.listEnvs` gives
471
+ * `ensureCallsMatrixSecrets` -- just for a secret this package never held in
472
+ * the first place, so there is nowhere here to read it back from itself.
473
+ */
474
+ export interface CallsSfuPort {
475
+ resolveCredentials(): Promise<{
476
+ apiKey: string;
477
+ apiSecret: string;
478
+ } | null>;
177
479
  }
178
480
  /**
179
481
  * The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
@@ -224,6 +526,23 @@ export interface ZitadelPort {
224
526
  clientSecret: string;
225
527
  }>;
226
528
  deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
529
+ /**
530
+ * `zitadelApp`'s kind-change path reads this to find an app a prior,
531
+ * lost-response create already made under the new name, before calling
532
+ * `createOidcApp`/`createApiApp` again and orphaning that one under the
533
+ * same name. Matches `@wtfalch/providers/zitadel`'s `ZitadelClient.
534
+ * listApps` return shape exactly, so that client satisfies this port with
535
+ * no adapter -- it does not carry `clientId`/`clientSecret` (that provider
536
+ * method never parses either, and a secret is minted once, on creation,
537
+ * never handed back again), which is why the reuse path below cannot
538
+ * re-deliver either one.
539
+ */
540
+ listApps(orgId: string, projectId: string): Promise<Array<{
541
+ id: string;
542
+ name: string;
543
+ appType: 'web' | 'native' | 'api';
544
+ state: string;
545
+ }>>;
227
546
  /**
228
547
  * The org-owner flow (goal line): find-or-create the human user by email in
229
548
  * the org, then have ZITADEL email them an invite to set up a passkey.
@@ -350,6 +669,10 @@ export interface FoundryStepExecutorPorts {
350
669
  coolify: CoolifyPort;
351
670
  cloudflare: CloudflarePort;
352
671
  zitadel: ZitadelPort;
672
+ mail: MailPort;
673
+ resend: ResendPort;
674
+ /** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
675
+ callsSfu: CallsSfuPort;
353
676
  /** A factory, not a flat client: Files/AI are org-scoped credentials the host resolves per org. */
354
677
  filesLimitsFor: (orgId: OrgId) => FilesOrgLimits;
355
678
  aiLimitsFor: (orgId: OrgId) => AiOrgLimits;
@@ -370,28 +693,69 @@ export interface FoundryStepExecutorPorts {
370
693
  * themselves.
371
694
  */
372
695
  deliverSecret(ref: string, secret: string): Promise<void>;
696
+ /**
697
+ * Read access to the declared-org store, narrowed to the one query
698
+ * `cloudflareZone` needs for its cross-org refusal check (#131) -- see that
699
+ * method's own doc comment for why a zone needs this where
700
+ * `cloudflareAccess` reads Cloudflare's own live state instead. A real host
701
+ * passes the same store instance it constructs `FoundryPlanApply` with.
702
+ */
703
+ declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
373
704
  }
374
705
  /**
375
706
  * 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").
707
+ * chat (no admin API exists yet), plus moving the issuer onto or off the
708
+ * org's domain (built, but not in v1 — domain verification and passkey
709
+ * invalidation need their own design). Never carries a token, secret or key
710
+ * (task brief: "errors carry the step and the provider's status only").
381
711
  */
382
712
  export declare class NotBuiltError extends Error {
383
713
  constructor(step: string, detail: string);
384
714
  }
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
- }
715
+ /**
716
+ * The health URLs an outside prober should call for this org (#43): one per
717
+ * app foundry runs for it. A hosting org's app answers at its own hostname,
718
+ * and a site org's site app and cms app both serve `/api/health`
719
+ * (https://github.com/wtfalch/cms/tree/main/apps). An org that runs neither
720
+ * has nothing to probe. Exported so archon, which owns uptime, never repeats
721
+ * this file's hostname rules.
722
+ */
723
+ export declare function healthUrls(org: DeclaredOrg): string[];
389
724
  export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer {
390
725
  private readonly ports;
391
726
  constructor(ports: FoundryStepExecutorPorts);
392
727
  execute(action: StepAction, context: StepExecutionContext): Promise<{
393
728
  resourceId?: string;
394
729
  } | undefined>;
730
+ /**
731
+ * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
732
+ * Cloudflare Access app that lets only `hosting.accessEmails` through.
733
+ * foundry names its Access app `foundry:<orgId>` and reuses only an app with
734
+ * that name, so a re-run after a crash does not duplicate it. It refuses,
735
+ * rather than adopt, when another Access app already holds the hostname:
736
+ * destroy would later delete it. It also refuses when its own app carries
737
+ * any policy but foundry's, since that policy could let others in.
738
+ */
739
+ private cloudflareAccess;
740
+ /**
741
+ * `plan/diff.ts`'s `planRateLimit` (#43): one Cloudflare rate limiting
742
+ * rule, scoped to `SIGN_IN_PATH_EXPRESSION`, on the org's own Cloudflare
743
+ * zone -- only ever called for an own-domain org (`planRateLimit`'s own
744
+ * gate), so `org.domain` is set and that zone is the org's alone, never
745
+ * shared with another org the way `oikos.host` is.
746
+ *
747
+ * Rate limiting rules attach to a zone's `http_ratelimit` phase entry
748
+ * point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
749
+ * has no typed method for either -- `CloudflarePort.request`'s own doc
750
+ * comment) -- so "find by name" here means reading that one ruleset's
751
+ * `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
752
+ * `cloudflareAccess` above finds its own Access app by name. The Free
753
+ * plan allows exactly one rule per zone, so a rule already there under a
754
+ * different description is refused, never adopted or replaced, mirroring
755
+ * `cloudflareAccess`'s own refusal when a foreign Access app already
756
+ * holds the hostname.
757
+ */
758
+ private cloudflareRateLimit;
395
759
  private zitadelIssuer;
396
760
  /**
397
761
  * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
@@ -412,10 +776,42 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
412
776
  */
413
777
  private zitadelCustomers;
414
778
  private zitadelApp;
779
+ /**
780
+ * The app an earlier attempt's recreate already made under the same
781
+ * name, whose response this executor never saw -- deleted before the
782
+ * create so no duplicate is left behind. Matches on name and the
783
+ * desired kind, and skips a removed app -- ZITADEL's
784
+ * search can return one that has since been deleted (`isNotFoundError`'s
785
+ * own doc comment covers why a delete can be retried into a 404), and
786
+ * that is not the survivor this is looking for.
787
+ */
788
+ private findRecreatedApp;
415
789
  private ensureZitadelProject;
416
790
  private ensureCoolifyProject;
417
791
  private resolveServer;
418
792
  private coolifyApp;
793
+ /**
794
+ * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
795
+ * is already there. Found by `mount_path`, not a recorded id — no
796
+ * `serviceIds` entry exists for a storage the way one does for the app or
797
+ * database itself, and Coolify's own listing is cheap enough to check
798
+ * first every time, the same way `coolifyDatabase` finds its database by
799
+ * name before creating one. Never called from the `enabled === false`
800
+ * (park) branch above, so parking an app never touches its volume either.
801
+ *
802
+ * Mounting a volume on an application already running does not attach it
803
+ * to the live container -- Coolify only binds a storage in at the
804
+ * container's next start (Finding, refuted against the first version of
805
+ * this method: it created the storage and let a passing `listStorages`
806
+ * read report `hasLogVolume: true` on the very next plan, with the
807
+ * container itself still writing nowhere). `wasAlreadyRunning` tells this
808
+ * method whether `appId` names an app `coolifyApp` found already there
809
+ * (restart it once the volume is created) or one it just created fresh
810
+ * (already about to get its first deploy/start from
811
+ * `createGithubAppApplication`'s own instant-deploy path, so a restart
812
+ * here would only be a redundant extra one).
813
+ */
814
+ private ensureLogVolume;
419
815
  private coolifyDatabase;
420
816
  /**
421
817
  * Pushes the database's connection string into the app's environment as
@@ -440,8 +836,349 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
440
836
  * never interpolated into a thrown error.
441
837
  */
442
838
  private pushDatabaseUrl;
839
+ /**
840
+ * Pushes what the org's app needs to sign a user in through ZITADEL (gap
841
+ * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
842
+ * call rather than folding into `pushDatabaseUrl`'s, since this one runs
843
+ * unconditionally (`coolifyApp`) where that one runs only once
844
+ * `hosting.database` is declared and provisioned.
845
+ *
846
+ * `APP_URL` and `LOG_DIR` (#75) are pushed unconditionally; every other
847
+ * value is pushed only once its own source step has actually recorded it
848
+ * (`context.recordedIds()`) -- on a first-ever apply,
849
+ * run one step at a time in `diff.ts`'s `computePlanDetail` order, that is
850
+ * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
851
+ * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
852
+ * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
853
+ * reason. AUTH_APP_KEY (waits on an auth broker registration API) and
854
+ * DATABASE_URL_OWNER (a runtime-role split not built yet, `pushDatabaseUrl`'s
855
+ * own doc comment) are out of scope here (task brief).
856
+ */
857
+ private pushAppEnv;
858
+ /**
859
+ * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
860
+ * only the first time (task brief), never regenerated or overwritten, so a
861
+ * later apply's `bulkUpsertEnvs` call simply omits the key rather than
862
+ * upsert a fresh value over it.
863
+ */
864
+ private ensureAuthCookieSecret;
865
+ /**
866
+ * #140 rework: the hosted app's own Resend sending key, pushed as
867
+ * `RESEND_API_KEY` straight into the app's env, the one place its token
868
+ * ever lives (Resend shows a key's token once, at creation). A no-op until
869
+ * `coolify:mail` has recorded a {@link RESEND_DOMAIN_ID_SCOPE_KEY} for this
870
+ * org; the next apply fills it in once `coolify:mail` runs first
871
+ * (`diff.ts`'s `computePlanDetail` order).
872
+ *
873
+ * The key is named `<slug>-app@<resendDomainId>`, so a key limited to a
874
+ * mail domain the org no longer uses (a `usesDomain` flip moves it between
875
+ * `org.domain` and `<slug>.<apex>`) never passes for the current one.
876
+ * `RESEND_API_KEY_ID` sits beside the token in the env -- an id, not a
877
+ * secret -- so the env can say which key its token belongs to.
878
+ *
879
+ * The mint rule (`.plans/2026-09-27-org-mail-resend-design.md` "One rule
880
+ * for minting"): healthy when the env holds a token and its
881
+ * `RESEND_API_KEY_ID` is the one key Resend lists under the current name.
882
+ * Otherwise one fresh key is minted and written to the env first, and only
883
+ * then is every other `<slug>-app@` key deleted -- a lost mint response, a
884
+ * duplicate, a hand-made key, and a previous domain's key all end the same
885
+ * way. An app that was already running is restarted before that delete,
886
+ * since a container reads its env only at start (`running`, the same
887
+ * reason {@link ensureLogVolume} restarts one). The restart is not
888
+ * awaited to completion, so the old container can briefly outlive its key.
889
+ *
890
+ * `MAIL_FROM` is upserted every time: it depends only on the mail domain
891
+ * `coolify:mail` resolved (`RESEND_MAIL_DOMAIN_SCOPE_KEY`).
892
+ *
893
+ * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
894
+ * returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
895
+ * comment), never logged, never interpolated into a thrown error -- the
896
+ * same discipline `pushDatabaseUrl`'s doc comment states for a database's
897
+ * own connection string.
898
+ */
899
+ private ensureAppResendKey;
900
+ /**
901
+ * `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
902
+ * database and site app per org"): the 2026-09-25 decision
903
+ * (https://github.com/wtfalch/cms/blob/main/.plans/2026-09-25-org-sites.md)
904
+ * means foundry never calls a cms admin API for the org's site -- it
905
+ * provisions two ordinary Coolify applications from the one
906
+ * https://github.com/wtfalch/cms repo, the same way `coolifyApp` above
907
+ * provisions the org's own hosted app: a site app (`apps/site`, the
908
+ * reference renderer) at `org.domain`, built from
909
+ * `CMS_SITE_APP_DOCKERFILE`, and a cms app (`apps/host`, single-org mode,
910
+ * https://github.com/wtfalch/cms/blob/main/docs/adr/0013-one-cms-per-org.md)
911
+ * at {@link cmsHostnameFor}, built from the repo's own root
912
+ * `CMS_APP_DOCKERFILE` -- one repo, two applications, told apart by
913
+ * `dockerfile_location`.
914
+ *
915
+ * `action.key` is `org.id` (`planSite`'s own singleton key), so `execute`'s
916
+ * own auto-record has only one `resourceId` slot for this step
917
+ * (`cms:site:<orgId>`) -- returned here as the cms app's id, the resource
918
+ * this step is named for. The site app's id has no such slot, so both ids
919
+ * are also recorded as scope ids ({@link CMS_APP_SCOPE_KEY}/
920
+ * {@link SITE_APP_SCOPE_KEY}), read back by the park branch below on a
921
+ * later apply, when nothing gets provisioned to return one -- read
922
+ * independently of each other there, since a crash partway through a
923
+ * first-ever provision can leave only one of the two actually recorded.
924
+ *
925
+ * #117: this step also finds or creates the cms app's own Postgres
926
+ * database ({@link ensureCmsDatabase}) and pushes its owner and runtime
927
+ * connection strings ({@link ensureCmsRuntimeUrl}) onto the cms app, in the
928
+ * same `bulkUpsertEnvs` call as `APP_URL`/`CMS_ORGANISATION_ID`/
929
+ * `CMS_SITE_URL` below. The database's id is recorded as a third flat scope
930
+ * id ({@link CMS_DATABASE_SCOPE_KEY}), for the same reason the two app ids
931
+ * are: no `system:kind:key` slot of its own. Never touched from the park
932
+ * branch above -- parking stops the two apps only, same as #116's own
933
+ * destroy note; this step never parks or destroys the database.
934
+ */
935
+ private cmsSite;
936
+ /**
937
+ * `cmsSite`'s own find-or-create of a Postgres database for the cms app,
938
+ * modeled on `coolifyDatabase` above: `ensureCoolifyProject` (already run
939
+ * by `cmsSite` before this is called) and `findOrCreateEnvironment` give
940
+ * the project/environment this reuses, then `listDatabases` finds one
941
+ * already created by name before `createPostgresDatabase` makes one.
942
+ * Named `<org.slug>-cms-db`, distinct from `coolifyDatabase`'s own
943
+ * `<org.slug>-db`, so the two never collide in the same project for an org
944
+ * that has both.
945
+ *
946
+ * `context.recordedIds()` is checked first, the same short-circuit
947
+ * `coolifyDatabase` itself does not have (that step's own `resourceId` slot
948
+ * already covers it) -- here it is load-bearing, since this database has
949
+ * no `system:kind:key` slot of its own ({@link CMS_DATABASE_SCOPE_KEY}'s
950
+ * doc comment) and `cms:site` runs on every apply once `org.site.enabled`,
951
+ * not just the first.
952
+ *
953
+ * Destroying this database is out of scope here (#117, same as #116's own
954
+ * destroy note for the cms/site apps).
955
+ */
956
+ private ensureCmsDatabase;
957
+ /**
958
+ * `null` once the cms app already has a `DATABASE_URL` -- never rotated,
959
+ * the same rule `ensureAuthCookieSecret` follows for `AUTH_COOKIE_SECRET`,
960
+ * and for the same kind of reason: `@wtfalch/cms`'s own
961
+ * `apps/host/scripts/migrate.mjs` (`ensureRuntimeRole`) creates or updates
962
+ * the live `cms_rt` Postgres role from exactly the username/password this
963
+ * method mints, once, at container boot -- overwriting `DATABASE_URL` on a
964
+ * later apply would leave that already-created role's real password out of
965
+ * sync with whatever foundry hands the app next.
966
+ *
967
+ * The password is minted with `randomBytes` (the same import
968
+ * `ensureAuthCookieSecret` already uses) and encoded `base64url` -- `+`/`/`
969
+ * swapped for `-`/`_`, no `=` padding -- so it is already URL-safe in a
970
+ * `postgres://` URL's userinfo segment with no further percent-encoding
971
+ * needed. `dbInternalUrl` (`getDatabase`'s own `internal_db_url`) is parsed
972
+ * with `URL` for its host, path and query string; its own superuser
973
+ * username and password are discarded -- this mints a distinct, restricted
974
+ * role's own credential, never reuses the superuser's. The query string
975
+ * (`search`) is kept as-is and appended unchanged (#117 review round 2):
976
+ * a parameter there (e.g. `?sslmode=require`) is a connection requirement
977
+ * Coolify itself put on this database, not something specific to the
978
+ * superuser role, so the migration (`DATABASE_URL_OWNER`) and runtime
979
+ * (`DATABASE_URL`) connections must carry the same one.
980
+ *
981
+ * The password is a live credential: built here and handed straight to
982
+ * `cmsSite`'s own `bulkUpsertEnvs` call and nowhere else in this class --
983
+ * never returned, recorded, logged, or interpolated into a thrown error
984
+ * (the same rule `pushDatabaseUrl`'s doc comment states). That rule is why
985
+ * `URL`'s own parse failure is caught below rather than left to propagate:
986
+ * nothing here states or controls the superuser password's alphabet
987
+ * (Coolify's own, unlike the `cms_rt` one minted just below it), and
988
+ * Node's `TypeError [ERR_INVALID_URL]` on an unescaped `/`, `#` or `?` in
989
+ * it carries the *whole* input -- credential included -- on its own
990
+ * `.input` property (never in `.message`, but still reachable by a logger
991
+ * that inspects the error object, not just its message). Caught and
992
+ * turned into "skip `DATABASE_URL` for this apply" instead: `cmsSite`
993
+ * still pushes `DATABASE_URL_OWNER` and its other envs, and a later apply
994
+ * retries this once `listEnvs` still shows no `DATABASE_URL`.
995
+ */
996
+ private ensureCmsRuntimeUrl;
997
+ /**
998
+ * `cmsSite`'s own find-by-name-then-create flow for one of its two
999
+ * applications -- the same shape `coolifyApp` above inlines for its own,
1000
+ * single, application, extracted here since `cmsSite` needs it twice in
1001
+ * one step. Both of `cmsSite`'s apps share `CMS_REPO`'s own default branch
1002
+ * (`main`) -- there is no per-org `gitBranch`/`buildPack` for this repo
1003
+ * the way `org.hosting` carries for the org's *own* app, since the cms
1004
+ * site is not that app.
1005
+ *
1006
+ * An app found by name (rather than just created) is started back up if
1007
+ * it is not already running or on its way there (#116 review round 3,
1008
+ * Finding 1): parking (`cmsSite`'s own `enabled: false` branch) stops both
1009
+ * apps without deleting them, so re-enabling finds them here, still
1010
+ * stopped, and `ProvidersCmsReader` would otherwise keep reporting
1011
+ * `enabled: false` forever -- the plan never converging back to on.
1012
+ * `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
1013
+ * app either) has since closed the same way, inline in that method (#130);
1014
+ * this method is `cmsSite`'s only caller.
1015
+ */
1016
+ private findOrCreateCmsApp;
1017
+ /**
1018
+ * `plan/diff.ts`'s `planCalls`: the org's own calls Matrix stack, entirely
1019
+ * separate from chat's (`calls-matrix.ts`'s own top doc comment) -- two
1020
+ * Coolify-managed Postgres databases, a Synapse app and an MAS app, no
1021
+ * public hostname of its own (neither app is reachable from outside
1022
+ * Coolify's own Docker network) -- `coolifyCallsService` below (#145, part 2
1023
+ * of #128) is the one that gets a public hostname, since it is the one a
1024
+ * browser actually talks to. Park stops Synapse and MAS only, the same
1025
+ * park-not-delete rule `coolifyApp`/`cmsSite` already follow (#7
1026
+ * corrections 1) -- their databases are never touched from that branch, the
1027
+ * same way `cmsSite`'s own database never is.
1028
+ */
1029
+ private coolifyCalls;
1030
+ /**
1031
+ * `coolifyCalls`'s own find-or-create for Synapse/MAS, restarting a
1032
+ * found-but-stopped app (#116 review round 3, Finding 1's same fix). Reused
1033
+ * by `coolifyCallsService` (#145) for the calls service app itself -- the
1034
+ * same find-by-name-then-start-or-create shape applies unchanged.
1035
+ */
1036
+ private findOrCreateCallsApp;
1037
+ /**
1038
+ * `coolifyCalls`'s own database find-or-create, modeled on
1039
+ * `ensureCmsDatabase`: `context.recordedIds()` is checked first since
1040
+ * neither database has a `system:kind:key` slot of its own (this step's one
1041
+ * auto-recorded `resourceId` is the MAS app, not either database --
1042
+ * {@link CALLS_SYNAPSE_SCOPE_KEY}'s own doc comment), then `listDatabases`
1043
+ * finds one already created by name before `createPostgresDatabase` makes
1044
+ * one. Reused by `coolifyCallsService` (#145) for the calls service's own
1045
+ * database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
1046
+ * `system:kind:key` slot of its own for the same reason.
1047
+ */
1048
+ private ensureCallsDatabase;
1049
+ /**
1050
+ * Reads back {@link CALLS_SECRET_ENV_KEYS} from the MAS app's own envs
1051
+ * before minting fresh ones -- this stack's only memory across a re-run
1052
+ * (`calls-matrix.ts`'s `CallsMatrixSecrets` doc comment). `changed: true`
1053
+ * only when nothing usable was found: a partial set (a crash mid-mint) is
1054
+ * still treated as "mint fresh", since MAS's shared secret and Synapse's
1055
+ * copy of it must always agree.
1056
+ */
1057
+ private ensureCallsMatrixSecrets;
1058
+ /**
1059
+ * Upserts a file storage's content, creating it if the application has none
1060
+ * at that mount path yet -- there is no way to read a file storage's
1061
+ * current content back from Coolify to diff it, the same limit
1062
+ * `archon/bootstrap/steps/chat-instance.ts`'s own `ensureFileStorage` notes,
1063
+ * so an existing one is always re-written.
1064
+ */
1065
+ private ensureCallsFileStorage;
1066
+ /**
1067
+ * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
1068
+ * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
1069
+ * {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
1070
+ * Static config checks run first, before any Coolify call (#128's own
1071
+ * carried-over PR 344 review fix, restated in #145's own issue body): the
1072
+ * calls Matrix stack must already be recorded, its MAS app must already
1073
+ * carry its client id/secret, and the shared SFU's LiveKit credentials must
1074
+ * already be resolvable -- none of the three is minted or guessed here.
1075
+ * Park stops the app only, the same park-not-delete rule
1076
+ * {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
1077
+ * 1); its own database is never touched from that branch.
1078
+ */
1079
+ private coolifyCallsService;
1080
+ /**
1081
+ * Reads back the calls Matrix stack's MAS client id/secret from the MAS
1082
+ * app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
1083
+ * either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
1084
+ * here means that stack has not finished provisioning yet, not something
1085
+ * this step can fix on its own.
1086
+ */
1087
+ private readCallsMasClientCredentials;
1088
+ /**
1089
+ * `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
1090
+ * never rotated, the same rule {@link ensureAuthCookieSecret} follows for
1091
+ * `AUTH_COOKIE_SECRET`: it wraps the provider-token ciphertext already
1092
+ * stored under any binding minted so far
1093
+ * (`calls/packages/service/src/config.mjs`'s own doc comment on
1094
+ * `CALLS_BINDING_SECRET`), so overwriting it would strand every binding
1095
+ * minted under the old value. 32 raw bytes, hex-encoded -- the exact shape
1096
+ * `config.mjs`'s own regex requires.
1097
+ */
1098
+ private ensureCallsBindingSecret;
443
1099
  private cloudflareTunnel;
1100
+ /**
1101
+ * `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
1102
+ * org's own domain. Refuses to adopt a zone the store already records
1103
+ * under a *different* org's applied state (#131), the same
1104
+ * do-not-adopt-a-foreign-resource rule `cloudflareAccess` above enforces --
1105
+ * but where that method tells "foundry's own" from "foreign" by reading
1106
+ * Cloudflare's own Access-app name live, a Cloudflare zone carries no
1107
+ * org-identifying field of its own, so this asks the store instead. That
1108
+ * matters because two different, DB-unique `domain` values (`org.ts`'s
1109
+ * `validateDomain` plus its unique index) can still resolve to the same
1110
+ * Cloudflare zone -- a zone is keyed by apex, and one org's domain can be a
1111
+ * subdomain of another org's -- which is exactly the shared-zone,
1112
+ * shared-hostname collision this ticket exists to refuse. An org re-running
1113
+ * its own already-recorded zone is unaffected: the store then names this
1114
+ * same org as the owner.
1115
+ */
444
1116
  private cloudflareZone;
1117
+ /**
1118
+ * `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
1119
+ * or parks it on the one shared Stalwart instance (`this.ports.mail`,
1120
+ * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
1121
+ * publishes whatever DNS records the call returns, then registers the same
1122
+ * mail domain with Resend (#138) -- the shared Stalwart relays every
1123
+ * outbound message through Resend, and Resend refuses mail from a domain
1124
+ * it has not verified. No step here ever deletes the app's Resend key:
1125
+ * `diff.ts`'s `planMail` plans nothing for mode `'off'`, so turning mail
1126
+ * off leaves the key live, like every other resource foundry stops
1127
+ * managing (#10). Switching the mail domain replaces the key instead
1128
+ * (`ensureAppResendKey`).
1129
+ */
1130
+ private coolifyMail;
1131
+ /**
1132
+ * Upserts each DNS record `applyOrgMail` says foundry must publish, found
1133
+ * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
1134
+ * comment) so update never mistakes a stale record of a different type for
1135
+ * a match. A `DKIM` record can be absent on the first call, since Stalwart
1136
+ * mints the key on a background task; the next apply publishes it. Returns
1137
+ * the zone id so `registerResendDomain` publishes Resend's own records on
1138
+ * the same zone without a second lookup.
1139
+ */
1140
+ private publishMailDnsRecords;
1141
+ /**
1142
+ * #138: registers `domain` with Resend -- the custom domain, or
1143
+ * `<slug>.<apex>` for a level-1 org, exactly what `publishMailDnsRecords`
1144
+ * just published the Stalwart records for. Idempotent by name, the same way
1145
+ * `applyOrgMail` itself is: found by `findDomain` rather than any id this
1146
+ * class recorded, so a crash between here and the next apply just repeats
1147
+ * the same find-or-create. Never blocks on DNS propagation (task brief):
1148
+ * `verifyDomain` asks Resend to re-check once, and its returned status is
1149
+ * neither polled nor awaited -- an unverified domain here is exactly what
1150
+ * the next apply corrects.
1151
+ *
1152
+ * #140 rework: mints no key of its own any more. It records the domain's
1153
+ * own id and the resolved mail domain as scope ids so `ensureAppResendKey`
1154
+ * (`coolifyApp`) can mint the app's own `<slug>-app@<domainId>` key once the app
1155
+ * exists, without this step or the app step calling Resend to look the
1156
+ * domain back up.
1157
+ */
1158
+ private registerResendDomain;
1159
+ /**
1160
+ * Upserts each DNS record Resend's domain names, the same find-then-upsert
1161
+ * `publishMailDnsRecords` above uses for Stalwart's own records -- except
1162
+ * SPF is merged into whatever record that call just published rather than
1163
+ * created a second time (RFC 7208 treats two `v=spf1` TXT records on one
1164
+ * name as a permerror and silently breaks SPF for every sender on the
1165
+ * domain), and an existing `_dmarc` record is never overwritten. Prior art:
1166
+ * `wtfalch/app-template`'s `bootstrap/steps/resend-email.ts`.
1167
+ */
1168
+ private publishResendDnsRecords;
1169
+ /**
1170
+ * Inserts Resend's `include:` token into whatever SPF record
1171
+ * `publishMailDnsRecords` already published for this name, instead of
1172
+ * creating a second `v=spf1` TXT record -- the classic foot-gun that makes
1173
+ * every receiver treat SPF as a permerror and skip it entirely.
1174
+ */
1175
+ private mergeResendSpfRecord;
1176
+ /**
1177
+ * Never overwrites an existing `_dmarc` record -- a stricter policy an org
1178
+ * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
1179
+ * Resend's own `p=none` default.
1180
+ */
1181
+ private ensureResendDmarcRecord;
445
1182
  /**
446
1183
  * #80: provisions the org's own organisation on Files, before
447
1184
  * {@link filesStorage} can set its spend limit. `provisionOrganisation` is