@wtfalch/foundry 0.15.0 → 0.16.1

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.
Files changed (39) hide show
  1. package/dist/bootstrap/declare.d.ts +2 -2
  2. package/dist/bootstrap/declare.js +6 -6
  3. package/dist/index.d.ts +3 -3
  4. package/dist/index.js +2 -2
  5. package/dist/migrations/0034_owners.sql +42 -0
  6. package/dist/migrations/0035_chat_on_by_default.sql +10 -0
  7. package/dist/org.d.ts +80 -17
  8. package/dist/org.js +221 -18
  9. package/dist/plan/agora.d.ts +19 -1
  10. package/dist/plan/agora.js +37 -2
  11. package/dist/plan/chat-matrix.d.ts +99 -0
  12. package/dist/plan/chat-matrix.js +194 -0
  13. package/dist/plan/diff.d.ts +5 -3
  14. package/dist/plan/diff.js +143 -83
  15. package/dist/plan/entry-ai-key.d.ts +58 -0
  16. package/dist/plan/entry-ai-key.js +209 -0
  17. package/dist/plan/entry-files-key.d.ts +82 -0
  18. package/dist/plan/entry-files-key.js +327 -0
  19. package/dist/plan/executor.d.ts +244 -16
  20. package/dist/plan/executor.js +866 -84
  21. package/dist/plan/hostnames.d.ts +14 -1
  22. package/dist/plan/hostnames.js +26 -1
  23. package/dist/plan/index.d.ts +1 -0
  24. package/dist/plan/index.js +1 -0
  25. package/dist/plan/plan-apply.d.ts +2 -0
  26. package/dist/plan/plan-apply.js +42 -2
  27. package/dist/plan/platform-org.d.ts +17 -5
  28. package/dist/plan/platform-org.js +15 -7
  29. package/dist/plan/platform-providers.js +4 -3
  30. package/dist/plan/proxy-labels.d.ts +42 -0
  31. package/dist/plan/proxy-labels.js +73 -0
  32. package/dist/plan/zitadel-machine-user.d.ts +5 -1
  33. package/dist/plan/zitadel-machine-user.js +5 -1
  34. package/dist/plan/zitadel-project-roles.d.ts +25 -0
  35. package/dist/plan/zitadel-project-roles.js +104 -0
  36. package/dist/store/postgres.js +4 -17
  37. package/dist/store/schema.d.ts +13 -45
  38. package/dist/store/schema.js +5 -8
  39. package/package.json +1 -1
@@ -1,15 +1,19 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
- import { APP_ENTRY_KEY, appEntry } from '../org.js';
5
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
4
+ import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
5
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
6
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
7
7
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
8
+ import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
8
9
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
9
- import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
10
+ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
11
+ import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
12
+ import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
10
13
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
11
14
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
12
15
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
16
+ import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
13
17
  /**
14
18
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
15
19
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
@@ -36,8 +40,8 @@ export function adaptZitadelListApps(client) {
36
40
  // ---------------------------------------------------------------------------
37
41
  /**
38
42
  * Thrown by name for a step this executor deliberately does not implement:
39
- * chat (no admin API exists yet), plus moving the issuer onto or off the
40
- * org's domain (built, but not in v1 — domain verification and passkey
43
+ * moving chat or the issuer onto or off the org's domain (chat's re-key is
44
+ * not built; the issuer's is built, but not in v1 — domain verification and passkey
41
45
  * invalidation need their own design). Never carries a token, secret or key
42
46
  * (task brief: "errors carry the step and the provider's status only").
43
47
  */
@@ -274,6 +278,15 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
274
278
  * `resend_mail_domain`.
275
279
  */
276
280
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
281
+ /**
282
+ * A `TXT` record's leading tag, lowercased: `v=spf1`, `v=dkim1`, `v=dmarc1`,
283
+ * or for an untagged value its first word. Cloudflare may hand content back
284
+ * wrapped in quotes, so those are dropped first.
285
+ */
286
+ function txtRecordTag(content) {
287
+ const bare = content.trim().replace(/^"/, '').trimStart();
288
+ return (bare.split(/[\s;]/, 1)[0] ?? '').toLowerCase();
289
+ }
277
290
  /**
278
291
  * #235: the Coolify app running `cloudflared` for this org's own tunnel --
279
292
  * one connector per org tunnel (Decided, William, 2026-10-01), the same
@@ -397,19 +410,8 @@ function parseTunnelPreviousIds(recorded) {
397
410
  function tunnelPreviousIdsState(ids) {
398
411
  return [...ids].sort().join(',');
399
412
  }
400
- /**
401
- * #162: the owner's own mailbox, created once inside `coolify:mail` after
402
- * `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
403
- * flat scope id for the same reason, no `:`. Recorded with the address
404
- * itself (not just a boolean) so a future step could read it back the same
405
- * way {@link MAIL_DOMAIN_SCOPE_KEY} lets `ensureAppLettermintMail` read the
406
- * domain back; `ensureOwnerMailbox` only ever checks that this key is set at
407
- * all before deciding whether to call `MailPort.createMailbox` again.
408
- * `plan/diff.ts` keeps its own copy of this literal (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
409
- * there) rather than importing it, the same restated-not-shared rule this
410
- * file's own `LOG_DIR` doc comment names for `diff.ts`.
411
- */
412
- const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
413
+ /** The role on the org's `<slug>-messaging` project that opens /_chat/manage. */
414
+ const CHAT_ADMIN_ROLE = 'chat.admin';
413
415
  /**
414
416
  * #149 review finding: `MailPort.hasOrgRelay` only confirms Stalwart's route
415
417
  * holds *a* secret, not that it is the one {@link FoundryStepExecutor
@@ -418,7 +420,7 @@ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
418
420
  * project that has since been lost and reprovisioned under the same name
419
421
  * would otherwise read as healthy. Recorded as `<project id>@<mail domain>`
420
422
  * right after a successful `MailPort.applyOrgRelay`, a flat scope id (no
421
- * `:`) the same way {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is. The domain half
423
+ * `:`) the same way `mail_owner_mailbox` is. The domain half
422
424
  * also catches a domain switch: the healthy branch never calls
423
425
  * `applyOrgRelay` again, so without this the Stalwart route would stay
424
426
  * restricted to the org's previous domain forever.
@@ -440,6 +442,12 @@ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
440
442
  const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
441
443
  /** How many times `aiKey` tries `deliverSecret` before giving up. */
442
444
  const AI_KEY_DELIVERY_ATTEMPTS = 3;
445
+ const DAY_MS = 24 * 60 * 60 * 1000;
446
+ /** What a failed files call says about itself: its HTTP status, never its message, which can echo a value. */
447
+ function filesErrorCause(error) {
448
+ const status = error?.status;
449
+ return typeof status === 'number' ? `HTTP ${status}` : 'no HTTP status';
450
+ }
443
451
  /**
444
452
  * #173: the id of a jobs project `jobsRegistration` has registered but not
445
453
  * yet delivered the credentials of, a flat scope id (no `:`). `serviceIds` is
@@ -491,6 +499,25 @@ const CALLS_SECRET_ENV_KEYS = {
491
499
  masRsaKeyPem: 'CALLS_MATRIX_MAS_RSA_KEY_PEM',
492
500
  masEcKeyPem: 'CALLS_MATRIX_MAS_EC_KEY_PEM',
493
501
  };
502
+ /**
503
+ * `coolifyChat`'s flat scope ids, for the same reason as the calls stack's
504
+ * above: this step's one auto-recorded `resourceId` (under
505
+ * `coolify:chat:<orgId>`) is the MAS app's id, the one every secret lives on.
506
+ */
507
+ const CHAT_SYNAPSE_SCOPE_KEY = 'coolify_chat_synapse';
508
+ const CHAT_SERVICE_SCOPE_KEY = 'coolify_chat_service';
509
+ const CHAT_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_synapse_database';
510
+ const CHAT_MAS_DB_SCOPE_KEY = 'coolify_chat_mas_database';
511
+ const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
512
+ /** {@link CALLS_SECRET_ENV_KEYS}' counterpart on chat's own MAS app. */
513
+ const CHAT_SECRET_ENV_KEYS = {
514
+ masSynapseSharedSecret: 'CHAT_MATRIX_MAS_SYNAPSE_SHARED_SECRET',
515
+ masClientId: 'CHAT_MATRIX_MAS_CLIENT_ID',
516
+ masClientSecret: 'CHAT_MATRIX_MAS_CLIENT_SECRET',
517
+ masEncryptionKey: 'CHAT_MATRIX_MAS_ENCRYPTION_KEY',
518
+ masRsaKeyPem: 'CHAT_MATRIX_MAS_RSA_KEY_PEM',
519
+ masEcKeyPem: 'CHAT_MATRIX_MAS_EC_KEY_PEM',
520
+ };
494
521
  /** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
495
522
  const CALLS_MAS_DOCKERFILE = [
496
523
  `FROM ${MAS_IMAGE}`,
@@ -564,7 +591,12 @@ export class FoundryStepExecutor {
564
591
  case 'cloudflare:ratelimit':
565
592
  return this.cloudflareRateLimit(org);
566
593
  case 'files:organisation':
567
- return this.filesOrganisation(action, org);
594
+ return this.filesOrganisation(action, org, context);
595
+ case 'files:key':
596
+ // The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6b).
597
+ return action.key.includes(':')
598
+ ? this.filesEntryKey(action, org, context)
599
+ : this.filesRootKey(action, org, context);
568
600
  case 'files:storage':
569
601
  return this.filesStorage(action, org);
570
602
  case 'ai:organisation':
@@ -572,13 +604,16 @@ export class FoundryStepExecutor {
572
604
  case 'ai:budget':
573
605
  return this.aiBudget(action, org);
574
606
  case 'ai:key':
575
- return this.aiKey(action, org, context);
607
+ // The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6a).
608
+ return action.key.includes(':')
609
+ ? this.aiEntryKey(action, org, context)
610
+ : this.aiKey(action, org, context);
576
611
  case 'coolify:mail':
577
612
  return this.coolifyMail(action, org, context);
578
613
  case 'coolify:mail-copy':
579
614
  return this.mailCopy(org, context);
580
615
  case 'coolify:chat':
581
- 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");
616
+ return this.coolifyChat(action, org, context);
582
617
  case 'cms:site':
583
618
  return this.cmsSite(action, org, context);
584
619
  case 'coolify:agora':
@@ -701,7 +736,7 @@ export class FoundryStepExecutor {
701
736
  * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
702
737
  * org by email. Never logs, returns or throws the email itself -- only the
703
738
  * invited user id, handed back as `resourceId` so `runSteps` records it
704
- * under `zitadel:owner:<orgId>`, which is exactly what makes the next
739
+ * under `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
705
740
  * `plan()` call idempotent (`planOwner` plans nothing once that key is
706
741
  * recorded).
707
742
  */
@@ -715,6 +750,18 @@ export class FoundryStepExecutor {
715
750
  familyName: action.desired.familyName,
716
751
  email: action.desired.email,
717
752
  });
753
+ // An owner invited after chat was built never reaches `coolifyChat`
754
+ // again, so the role is granted here once the messaging project exists.
755
+ const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
756
+ if (org.chat.enabled && messagingProjectId) {
757
+ await this.ports.zitadel.ensureProjectRole(zitadelOrgId, messagingProjectId, {
758
+ key: CHAT_ADMIN_ROLE,
759
+ displayName: 'Chat admin',
760
+ });
761
+ await this.ports.zitadel.grantProjectRoles(zitadelOrgId, userId, messagingProjectId, [
762
+ CHAT_ADMIN_ROLE,
763
+ ]);
764
+ }
718
765
  return { resourceId: userId };
719
766
  }
720
767
  /**
@@ -1161,7 +1208,7 @@ export class FoundryStepExecutor {
1161
1208
  git_branch: entry.branch,
1162
1209
  build_pack: entry.buildPack,
1163
1210
  name: appNameFor(org, entry),
1164
- domains: names.length > 0 ? names.join(',') : undefined,
1211
+ domains: names.length > 0 ? coolifyDomains(names) : undefined,
1165
1212
  autogenerate_domain: names.length > 0 ? undefined : false,
1166
1213
  // A static site is served by Coolify's own web server on port 80.
1167
1214
  ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
@@ -1192,10 +1239,31 @@ export class FoundryStepExecutor {
1192
1239
  // here (rule 3). The internal name is set after create, as the calls
1193
1240
  // stack's apps do.
1194
1241
  if (JSON.stringify(names) !== JSON.stringify(haveNames))
1195
- patch.domains = names.join(',');
1242
+ patch.domains = coolifyDomains(names);
1196
1243
  if (names.length === 0 && (!existing || patch.domains !== undefined)) {
1197
1244
  patch.custom_internal_name = appNameFor(org, entry);
1198
1245
  }
1246
+ // A `*` entry's proxy labels: Coolify takes `custom_labels` base64 encoded
1247
+ // and uses them instead of the labels it generates from `domains`. Rule 4:
1248
+ // decided from the live app. A created app has none, so it is always
1249
+ // patched; a live app is patched when its labels differ from the wanted
1250
+ // set (a hand edit included). Only when Coolify does not report
1251
+ // `custom_labels` does the recorded hash decide. The hash is recorded
1252
+ // below, after the redeploy, so a failed run repeats this.
1253
+ const labelsKey = appLabelsAppliedScopeKey(entry.key);
1254
+ const haveLabelsHash = context.recordedIds()[labelsKey];
1255
+ const labelSpec = entryLabelSpec(org, entry, haveLabelsHash !== undefined);
1256
+ const wantLabelsHash = labelSpec ? entryLabelHash(labelSpec) : undefined;
1257
+ const wantLabels = labelSpec ? entryProxyLabels(appId, labelSpec) : undefined;
1258
+ const liveLabels = existing ? live?.custom_labels : undefined;
1259
+ const labelsDiffer = wantLabels !== undefined &&
1260
+ (!existing ||
1261
+ (liveLabels !== undefined
1262
+ ? !sameLabelSets(Buffer.from(liveLabels ?? '', 'base64').toString('utf8'), wantLabels)
1263
+ : wantLabelsHash !== haveLabelsHash));
1264
+ if (wantLabels !== undefined && labelsDiffer) {
1265
+ patch.custom_labels = Buffer.from(wantLabels).toString('base64');
1266
+ }
1199
1267
  if (Object.keys(patch).length > 0)
1200
1268
  await this.ports.coolify.updateApplication(appId, patch);
1201
1269
  // The container builds and listens from these only at its next deploy.
@@ -1203,7 +1271,8 @@ export class FoundryStepExecutor {
1203
1271
  patch.publish_directory ||
1204
1272
  patch.ports_exposes ||
1205
1273
  patch.domains !== undefined ||
1206
- patch.custom_internal_name);
1274
+ patch.custom_internal_name ||
1275
+ patch.custom_labels);
1207
1276
  // The database plans before the app (`diff.ts`'s `computePlanDetail`,
1208
1277
  // hosting-wiring campaign) so that, on the same apply, the database's
1209
1278
  // resource id is already recorded by the time this runs -- but this also
@@ -1247,6 +1316,8 @@ export class FoundryStepExecutor {
1247
1316
  // one and `planApp` reports it instead of planning it.
1248
1317
  context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
1249
1318
  context.recordScopeId(domainsKey, JSON.stringify(names));
1319
+ if (wantLabelsHash)
1320
+ context.recordScopeId(labelsKey, wantLabelsHash);
1250
1321
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
1251
1322
  if (entry.uses?.auth) {
1252
1323
  context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
@@ -1431,9 +1502,9 @@ export class FoundryStepExecutor {
1431
1502
  * `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
1432
1503
  * (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
1433
1504
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
1434
- * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
1435
- * is its own design question, left out of #204's own "How" rather than
1436
- * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1505
+ * reason. `AUTH_APP_KEY` is pushed here too, for an entry that declares
1506
+ * `uses: [auth]` (`ensureAuthAppKey`); the host's `BrokerPort` decides which
1507
+ * broker the org's app registers with (#206). `DATABASE_URL_OWNER`/
1437
1508
  * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1438
1509
  * that call runs only once the `app` entry's `database` is declared and
1439
1510
  * provisioned, where this one is unconditional (#239).
@@ -1477,7 +1548,10 @@ export class FoundryStepExecutor {
1477
1548
  // them its own owner in authz on first sign-in. `runSteps` records this
1478
1549
  // under the same `system:kind:key` shape every step's `resourceId` uses
1479
1550
  // (`zitadelOwner`'s own doc comment), not a scope id.
1480
- const ownerUserId = recorded[`zitadel:owner:${org.id}`];
1551
+ const firstOwner = org.owners[0];
1552
+ const ownerUserId = firstOwner
1553
+ ? recorded[`zitadel:owner:${org.id}:${firstOwner.email.toLowerCase()}`]
1554
+ : undefined;
1481
1555
  if (ownerUserId)
1482
1556
  envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
1483
1557
  // Idempotent the same way `pushDatabaseUrl`'s credential is treated:
@@ -2104,7 +2178,7 @@ export class FoundryStepExecutor {
2104
2178
  git_branch: 'main',
2105
2179
  build_pack: 'dockerfile',
2106
2180
  name: input.name,
2107
- domains: input.domain,
2181
+ domains: coolifyDomains([input.domain]),
2108
2182
  dockerfile_location: input.dockerfileLocation,
2109
2183
  });
2110
2184
  return { appId: created.uuid, existed: false };
@@ -2271,14 +2345,19 @@ export class FoundryStepExecutor {
2271
2345
  * by `coolifyCallsService` (#145) for the calls service app itself -- the
2272
2346
  * same find-by-name-then-start-or-create shape applies unchanged.
2273
2347
  */
2274
- async findOrCreateCallsApp(name, environmentId, create) {
2348
+ async findOrCreateCallsApp(name, environmentId, create, options = {}) {
2275
2349
  const existing = await this.ports.coolify.findApplicationByName(name, {
2276
2350
  environmentIds: [environmentId],
2277
2351
  });
2278
2352
  if (existing) {
2279
- const live = await this.ports.coolify.getApplication(existing.uuid);
2280
- if (!isRunningOrStarting(live.status)) {
2281
- await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
2353
+ // `startIfStopped: false` (chat): the caller writes the app's config
2354
+ // first and lets its own deploy start it, so a stopped app is never
2355
+ // started with config that is not there yet.
2356
+ if (options.startIfStopped !== false) {
2357
+ const live = await this.ports.coolify.getApplication(existing.uuid);
2358
+ if (!isRunningOrStarting(live.status)) {
2359
+ await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
2360
+ }
2282
2361
  }
2283
2362
  return { appId: existing.uuid, existed: true };
2284
2363
  }
@@ -2324,10 +2403,10 @@ export class FoundryStepExecutor {
2324
2403
  * still treated as "mint fresh", since MAS's shared secret and Synapse's
2325
2404
  * copy of it must always agree.
2326
2405
  */
2327
- async ensureCallsMatrixSecrets(masAppId) {
2406
+ async ensureCallsMatrixSecrets(masAppId, secretEnvKeys = CALLS_SECRET_ENV_KEYS) {
2328
2407
  const envs = await this.ports.coolify.listEnvs(masAppId);
2329
2408
  const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
2330
- const fields = Object.entries(CALLS_SECRET_ENV_KEYS);
2409
+ const fields = Object.entries(secretEnvKeys);
2331
2410
  const existing = {};
2332
2411
  for (const [field, envKey] of fields) {
2333
2412
  const value = read(envKey);
@@ -2365,6 +2444,209 @@ export class FoundryStepExecutor {
2365
2444
  });
2366
2445
  }
2367
2446
  }
2447
+ // ---- chat -----------------------------------------------------------------
2448
+ /**
2449
+ * Creates the `chat.admin` role on the org's messaging project and grants
2450
+ * it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
2451
+ * email lowercased).
2452
+ */
2453
+ async ensureChatAdmins(org, zitadelOrgId, projectId, recorded) {
2454
+ await this.ports.zitadel.ensureProjectRole(zitadelOrgId, projectId, {
2455
+ key: CHAT_ADMIN_ROLE,
2456
+ displayName: 'Chat admin',
2457
+ });
2458
+ for (const owner of org.owners) {
2459
+ const userId = recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`];
2460
+ if (userId) {
2461
+ await this.ports.zitadel.grantProjectRoles(zitadelOrgId, userId, projectId, [
2462
+ CHAT_ADMIN_ROLE,
2463
+ ]);
2464
+ }
2465
+ }
2466
+ }
2467
+ /**
2468
+ * `plan/diff.ts`'s `planChat`: the org's chat stack at level 1 -- three
2469
+ * Coolify-managed Postgres databases (Synapse, MAS, chat), Synapse with the
2470
+ * `wtfalch_chat_gate` module and MAS (both with a config file this step
2471
+ * writes, `chat-matrix.ts`), and the chat service. All four apps are
2472
+ * internal: Agora is the one public origin and proxies to them
2473
+ * (`AGORA_CHAT_UPSTREAM`, `AGORA_MATRIX_UPSTREAM`, `AGORA_MAS_UPSTREAM`).
2474
+ * It also finds or creates the org's ZITADEL messaging project, the
2475
+ * audience the chat service requires, and records its id for Agora.
2476
+ *
2477
+ * Idempotent: every resource is found by name or recorded id before it is
2478
+ * created, minted secrets are read back from the MAS app's envs, and an app
2479
+ * is deployed when it is new, its config was freshly minted, or it is not running. A
2480
+ * recorded chat resource means the stack already exists, so a second
2481
+ * `coolify:chat` step for it is a re-key (onto the company domain, or back)
2482
+ * and neither is built: both are refused by name, before anything is touched.
2483
+ * No secret reaches an error: errors name the org and the missing input only.
2484
+ */
2485
+ async coolifyChat(action, org, context) {
2486
+ const recorded = context.recordedIds();
2487
+ if (recorded[`coolify:chat:${action.key}`]) {
2488
+ if (action.desired.usesDomain === false) {
2489
+ throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
2490
+ }
2491
+ throw new NotBuiltError(`coolify:chat re-key for ${org.slug}`, 'moving its chat onto the company domain is not built; chat stays on its level-1 server_name');
2492
+ }
2493
+ const issuer = action.desired.issuer;
2494
+ if (typeof issuer !== 'string' || issuer === '') {
2495
+ throw new Error(`coolify:chat for ${org.slug}: the step carries no issuer`);
2496
+ }
2497
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
2498
+ if (!zitadelOrgId) {
2499
+ throw new Error(`coolify:chat for ${org.slug}: the org has no ZITADEL org recorded yet -- zitadel:issuer must run first`);
2500
+ }
2501
+ // Found or created by name, so a re-run after a crash reuses it. Its id is
2502
+ // not a secret.
2503
+ const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, `${org.slug}-messaging`);
2504
+ context.recordScopeId(ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, project.id);
2505
+ // The chat service gates /_chat/manage on this role. Without it nobody
2506
+ // passes. Every owner already invited gets it; an owner invited later
2507
+ // gets it from `zitadelOwner`. Both are idempotent.
2508
+ await this.ensureChatAdmins(org, zitadelOrgId, project.id, recorded);
2509
+ const projectUuid = await this.ensureCoolifyProject(org, context);
2510
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2511
+ const server = await this.resolveServer();
2512
+ const names = chatInternalNames(org.slug);
2513
+ const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2514
+ context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
2515
+ const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
2516
+ context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
2517
+ const serviceDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
2518
+ context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
2519
+ const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
2520
+ this.ports.coolify.getDatabase(synapseDbId),
2521
+ this.ports.coolify.getDatabase(masDbId),
2522
+ this.ports.coolify.getDatabase(serviceDbId),
2523
+ ]);
2524
+ const synapseDb = synapseDbRow.internal_db_url
2525
+ ? parseCallsDbUrl(synapseDbRow.internal_db_url)
2526
+ : null;
2527
+ const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
2528
+ if (!synapseDb || !masDb || !serviceDbRow.internal_db_url) {
2529
+ // Before any app is touched: Coolify can take a moment to report a new
2530
+ // database's url, and the next apply retries from a clean state.
2531
+ throw new Error(`coolify:chat for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
2532
+ }
2533
+ const githubApp = await this.ports.coolify.requireGithubApp();
2534
+ const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
2535
+ project_uuid: projectUuid,
2536
+ server_uuid: server.uuid,
2537
+ environment_name: environment.name,
2538
+ environment_uuid: environment.uuid,
2539
+ github_app_uuid: githubApp.uuid,
2540
+ git_repository: CHAT_REPO,
2541
+ git_branch: 'main',
2542
+ build_pack: 'dockerfile',
2543
+ name: names.synapse,
2544
+ dockerfile_location: CHAT_SYNAPSE_DOCKERFILE,
2545
+ ports_exposes: CHAT_SYNAPSE_PORT,
2546
+ autogenerate_domain: false,
2547
+ }), { startIfStopped: false });
2548
+ const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
2549
+ project_uuid: projectUuid,
2550
+ server_uuid: server.uuid,
2551
+ environment_name: environment.name,
2552
+ environment_uuid: environment.uuid,
2553
+ dockerfile: CALLS_MAS_DOCKERFILE,
2554
+ name: names.mas,
2555
+ description: `matrix-authentication-service for ${org.slug}'s chat`,
2556
+ instant_deploy: false,
2557
+ }), { startIfStopped: false });
2558
+ const service = await this.findOrCreateCallsApp(names.service, environment.id, () => this.ports.coolify.createGithubAppApplication({
2559
+ project_uuid: projectUuid,
2560
+ server_uuid: server.uuid,
2561
+ environment_name: environment.name,
2562
+ environment_uuid: environment.uuid,
2563
+ github_app_uuid: githubApp.uuid,
2564
+ git_repository: CHAT_REPO,
2565
+ git_branch: 'main',
2566
+ build_pack: 'dockerfile',
2567
+ name: names.service,
2568
+ dockerfile_location: CHAT_SERVICE_DOCKERFILE,
2569
+ ports_exposes: CHAT_SERVICE_PORT,
2570
+ autogenerate_domain: false,
2571
+ }), { startIfStopped: false });
2572
+ // Fixed internal names, set on every run: the three apps and Agora
2573
+ // address each other by them. Neither Synapse's nor MAS's health check
2574
+ // can pass before the other is up, so both are off (the calls stack's own
2575
+ // rule).
2576
+ await this.ports.coolify.updateApplication(synapse.appId, {
2577
+ custom_internal_name: names.synapse,
2578
+ ...(synapse.existed ? {} : { health_check_enabled: false }),
2579
+ });
2580
+ await this.ports.coolify.updateApplication(mas.appId, {
2581
+ custom_internal_name: names.mas,
2582
+ ...(mas.existed ? {} : { health_check_enabled: false }),
2583
+ });
2584
+ await this.ports.coolify.updateApplication(service.appId, {
2585
+ custom_internal_name: names.service,
2586
+ });
2587
+ context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
2588
+ context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
2589
+ const synapseStorages = await this.ports.coolify.listStorages(synapse.appId);
2590
+ if (!(synapseStorages.persistent_storages ?? []).some((s) => s.mount_path === '/data')) {
2591
+ await this.ports.coolify.createPersistentStorage(synapse.appId, {
2592
+ name: `${names.synapse}-data`,
2593
+ mount_path: '/data',
2594
+ });
2595
+ }
2596
+ const secrets = await this.ensureCallsMatrixSecrets(mas.appId, CHAT_SECRET_ENV_KEYS);
2597
+ const agoraHost = agoraHostnameFor(org);
2598
+ await this.ensureCallsFileStorage(synapse.appId, '/data/homeserver.yaml', chatSynapseHomeserverYaml({
2599
+ orgSlug: org.slug,
2600
+ db: synapseDb,
2601
+ masHost: names.mas,
2602
+ masSharedSecret: secrets.value.masSynapseSharedSecret,
2603
+ }));
2604
+ await this.ports.coolify.bulkUpsertEnvs(synapse.appId, [
2605
+ { key: 'SYNAPSE_CONFIG_PATH', value: '/data/homeserver.yaml' },
2606
+ { key: 'UID', value: '0' },
2607
+ { key: 'GID', value: '0' },
2608
+ ]);
2609
+ await this.ensureCallsFileStorage(mas.appId, '/config/config.yaml', chatMasConfigYaml({
2610
+ orgSlug: org.slug,
2611
+ publicHost: agoraHost,
2612
+ db: masDb,
2613
+ synapseHost: names.synapse,
2614
+ sharedSecret: secrets.value.masSynapseSharedSecret,
2615
+ clientId: secrets.value.masClientId,
2616
+ clientSecret: secrets.value.masClientSecret,
2617
+ encryptionKey: secrets.value.masEncryptionKey,
2618
+ rsaKeyPem: secrets.value.masRsaKeyPem,
2619
+ ecKeyPem: secrets.value.masEcKeyPem,
2620
+ }));
2621
+ await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
2622
+ orgSlug: org.slug,
2623
+ companyName: org.displayName,
2624
+ issuer,
2625
+ audience: project.id,
2626
+ zitadelOrgId,
2627
+ agoraHost,
2628
+ masClientId: secrets.value.masClientId,
2629
+ masClientSecret: secrets.value.masClientSecret,
2630
+ databaseUrl: serviceDbRow.internal_db_url,
2631
+ }));
2632
+ // A file-storage write alone never reaches a running container. Deploy an
2633
+ // app that is new, whose config was freshly minted, or that is not
2634
+ // running now. The last test reads the live status, not what this run
2635
+ // did, so a run that failed after creating the apps (or after deploying
2636
+ // only some of them) is repaired by the next one. An unchanged, running
2637
+ // stack is not redeployed, or this step would never converge. MAS first:
2638
+ // Synapse asks it for token introspection.
2639
+ for (const app of [mas, synapse, service]) {
2640
+ if (!app.existed ||
2641
+ secrets.changed ||
2642
+ !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
2643
+ await this.ports.coolify.deploy(app.appId);
2644
+ }
2645
+ }
2646
+ // Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
2647
+ context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
2648
+ return { resourceId: mas.appId };
2649
+ }
2368
2650
  // ---- calls service (#145, part 2 of #128) -------------------------------
2369
2651
  /**
2370
2652
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
@@ -2426,7 +2708,7 @@ export class FoundryStepExecutor {
2426
2708
  git_branch: 'main',
2427
2709
  build_pack: 'dockerfile',
2428
2710
  name,
2429
- domains: callsHostnameFor(org),
2711
+ domains: coolifyDomains([callsHostnameFor(org)]),
2430
2712
  dockerfile_location: CALLS_SERVICE_DOCKERFILE,
2431
2713
  }));
2432
2714
  // A fixed internal name, like Synapse and MAS above, so the org's Agora
@@ -2618,10 +2900,12 @@ export class FoundryStepExecutor {
2618
2900
  * `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
2619
2901
  * hosts are on and applied (#177's `platform-org.ts`'s
2620
2902
  * `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
2621
- * `agora` port overrides it) -- absent otherwise. The chat upstreams are
2622
- * still never pushed: chat has no admin API yet
2623
- * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
2624
- * routes rather than serving anything wrong. `AGORA_CALLS_UPSTREAM` is
2903
+ * `agora` port overrides it) -- absent otherwise. `AGORA_CHAT_UPSTREAM`,
2904
+ * `AGORA_MATRIX_UPSTREAM` and `AGORA_MAS_UPSTREAM` are pushed together once
2905
+ * chat is on and `coolifyChat` has recorded that its apps carry their
2906
+ * internal names (`CHAT_SERVICE_URL_SCOPE_KEY`); absent otherwise, and Agora
2907
+ * answers 502 on those routes rather than serving anything wrong.
2908
+ * `messagingProjectId` falls back to the project `coolifyChat` recorded. `AGORA_CALLS_UPSTREAM` is
2625
2909
  * pushed once calls are on and this org's calls service app carries its
2626
2910
  * internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
2627
2911
  * not the host values).
@@ -2652,7 +2936,7 @@ export class FoundryStepExecutor {
2652
2936
  git_branch: 'main',
2653
2937
  build_pack: 'dockerfile',
2654
2938
  name,
2655
- domains: agoraHostnameFor(org),
2939
+ domains: coolifyDomains([agoraHostnameFor(org)]),
2656
2940
  dockerfile_location: AGORA_APP_DOCKERFILE,
2657
2941
  }));
2658
2942
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
@@ -3259,23 +3543,21 @@ export class FoundryStepExecutor {
3259
3543
  * when the org has no owner yet, when no address is declared yet
3260
3544
  * (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
3261
3545
  * so this step's own mode/domain work still runs), or once
3262
- * {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
3546
+ * `mail_owner_mailbox` is already recorded -- checked last
3263
3547
  * so a second apply never calls `MailPort.createMailbox` again, rather
3264
3548
  * than relying on that port method's own duplicate refusal.
3265
3549
  */
3266
3550
  async ensureOwnerMailbox(org, mailOrg, options, context) {
3267
- if (!org.owner)
3268
- return;
3269
- const address = org.mail.ownerMailboxAddress ?? null;
3270
- if (!address)
3271
- return;
3272
- if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
3273
- return;
3274
- await this.ports.mail.createMailbox(mailOrg, {
3275
- address,
3276
- name: `${org.owner.givenName} ${org.owner.familyName}`,
3277
- }, options);
3278
- context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
3551
+ for (const box of ownerMailboxes(org)) {
3552
+ const { owner, address, scopeKey } = box;
3553
+ if (!address)
3554
+ continue;
3555
+ if (ownerMailboxRecorded(box, context.recordedIds()))
3556
+ continue;
3557
+ await this.ports.mail.createMailbox(mailOrg, { address, name: `${owner.givenName} ${owner.familyName}` }, options);
3558
+ // Recorded after each mailbox, so a crash between two owners repeats only the second.
3559
+ context.recordScopeId(scopeKey, address);
3560
+ }
3279
3561
  }
3280
3562
  /**
3281
3563
  * #230: creates each of the org's declared `mail.roleMailboxes` on its
@@ -3329,27 +3611,57 @@ export class FoundryStepExecutor {
3329
3611
  await this.ports.lettermint.deleteDomain(stale.id);
3330
3612
  }
3331
3613
  /**
3332
- * Upserts each DNS record `applyOrgMail` says foundry must publish, found
3333
- * by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
3334
- * comment) so update never mistakes a stale record of a different type for
3335
- * a match. A `DKIM` record can be absent on the first call, since Stalwart
3336
- * mints the key on a background task; the next apply publishes it.
3614
+ * Upserts each DNS record `applyOrgMail` says foundry must publish through
3615
+ * {@link upsertMailDnsRecord}. A `DKIM` record can be absent on the first
3616
+ * call, since Stalwart mints the key on a background task; the next apply
3617
+ * publishes it.
3337
3618
  */
3338
3619
  async publishMailDnsRecords(org, result) {
3339
- const cf = this.ports.cloudflare;
3340
- const zone = await cf.findZoneByApex(result.domain);
3620
+ const zone = await this.ports.cloudflare.findZoneByApex(result.domain);
3341
3621
  if (!zone) {
3342
3622
  throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
3343
3623
  }
3344
3624
  for (const record of result.records) {
3345
- const [existing] = await cf.findDnsRecord(zone.id, record.name, record.type);
3346
- const rec = { type: record.type, name: record.name, content: record.value };
3347
- if (existing) {
3348
- await cf.updateDnsRecord(zone.id, existing.id, rec);
3349
- }
3350
- else {
3351
- await cf.createDnsRecord(zone.id, rec);
3352
- }
3625
+ await this.upsertMailDnsRecord(zone.id, record);
3626
+ }
3627
+ }
3628
+ /**
3629
+ * Creates or updates one mail record (Stalwart's or Lettermint's).
3630
+ *
3631
+ * Always DNS-only: Cloudflare proxies only `A`, `AAAA` and `CNAME`, and a
3632
+ * proxied `CNAME` answers with Cloudflare's own addresses instead of its
3633
+ * target, so Lettermint's return-path and DKIM names would stop resolving
3634
+ * to Lettermint. `@wtfalch/providers`' `createDnsRecord` proxies by
3635
+ * default, so `proxied: false` is sent on every write, update included, so
3636
+ * a record someone proxied by hand is put back. An `MX` record carries the
3637
+ * priority it was given, which Cloudflare requires.
3638
+ *
3639
+ * Found by an exact name+type match (`CloudflarePort.findDnsRecord`'s own
3640
+ * doc comment) so a stale record of a different type is never taken for a
3641
+ * match. A name can hold several `TXT` records (an apex often holds a site
3642
+ * verification beside SPF), so a `TXT` record only replaces one that
3643
+ * starts with the same tag (`v=spf1`, `v=DKIM1`); with none, it is added
3644
+ * beside them. Before this, the first `TXT` on the name was overwritten,
3645
+ * whatever it was.
3646
+ */
3647
+ async upsertMailDnsRecord(zoneId, record) {
3648
+ const cf = this.ports.cloudflare;
3649
+ const found = await cf.findDnsRecord(zoneId, record.name, record.type);
3650
+ const existing = record.type === 'TXT'
3651
+ ? found.find((r) => txtRecordTag(r.content) === txtRecordTag(record.value))
3652
+ : found[0];
3653
+ const rec = {
3654
+ type: record.type,
3655
+ name: record.name,
3656
+ content: record.value,
3657
+ proxied: false,
3658
+ ...(record.priority === undefined ? {} : { priority: record.priority }),
3659
+ };
3660
+ if (existing) {
3661
+ await cf.updateDnsRecord(zoneId, existing.id, rec);
3662
+ }
3663
+ else {
3664
+ await cf.createDnsRecord(zoneId, rec);
3353
3665
  }
3354
3666
  }
3355
3667
  /**
@@ -3398,20 +3710,12 @@ export class FoundryStepExecutor {
3398
3710
  * handle here and is dropped rather than kept as dead code.
3399
3711
  */
3400
3712
  async publishLettermintDnsRecords(zoneId, records) {
3401
- const cf = this.ports.cloudflare;
3402
3713
  for (const record of records) {
3403
3714
  if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
3404
- await this.ensureLettermintDmarcRecord(cf, zoneId, record);
3715
+ await this.ensureLettermintDmarcRecord(this.ports.cloudflare, zoneId, record);
3405
3716
  continue;
3406
3717
  }
3407
- const [existing] = await cf.findDnsRecord(zoneId, record.name, record.type);
3408
- const rec = { type: record.type, name: record.name, content: record.value };
3409
- if (existing) {
3410
- await cf.updateDnsRecord(zoneId, existing.id, rec);
3411
- }
3412
- else {
3413
- await cf.createDnsRecord(zoneId, rec);
3414
- }
3718
+ await this.upsertMailDnsRecord(zoneId, record);
3415
3719
  }
3416
3720
  }
3417
3721
  /**
@@ -3420,10 +3724,15 @@ export class FoundryStepExecutor {
3420
3724
  * Lettermint's own `p=none` default.
3421
3725
  */
3422
3726
  async ensureLettermintDmarcRecord(cf, zoneId, record) {
3423
- const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => r.content.toLowerCase().startsWith('v=dmarc1'));
3727
+ const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => txtRecordTag(r.content) === 'v=dmarc1');
3424
3728
  if (hasDmarc)
3425
3729
  return;
3426
- await cf.createDnsRecord(zoneId, { type: 'TXT', name: record.name, content: record.value });
3730
+ await cf.createDnsRecord(zoneId, {
3731
+ type: 'TXT',
3732
+ name: record.name,
3733
+ content: record.value,
3734
+ proxied: false,
3735
+ });
3427
3736
  }
3428
3737
  // ---- files / ai --------------------------------------------------------
3429
3738
  /**
@@ -3433,15 +3742,62 @@ export class FoundryStepExecutor {
3433
3742
  * this is called every time regardless of whether it already ran --
3434
3743
  * idempotent by find-or-create, not by checking `recordedIds` first.
3435
3744
  */
3436
- async filesOrganisation(action, org) {
3745
+ async filesOrganisation(action, org, context) {
3437
3746
  const files = await this.filesPort();
3747
+ // F6b: an org whose entries use files needs an owner member in its files
3748
+ // organisation, which its own key is minted below. Only a new
3749
+ // organisation takes one, so it is passed when the owner is known now.
3750
+ const owner = org.hosting.entries.some((e) => e.uses?.files)
3751
+ ? filesOwnerUserId(org, context.recordedIds())
3752
+ : undefined;
3438
3753
  const provisioned = await files.provisionOrganisation({
3439
3754
  organisationId: org.id,
3440
3755
  ceilingBytes: action.desired.ceilingBytes ?? null,
3441
3756
  ceilingMicros: action.desired.ceilingMicros ?? null,
3757
+ ...(owner ? { name: org.displayName, owner } : {}),
3442
3758
  });
3759
+ // Recorded now, so the org key is minted below the member files made.
3760
+ if (owner && provisioned.created)
3761
+ context.recordScopeId(FILES_ORG_OWNER_SCOPE_KEY, owner);
3762
+ if (provisioned.agoraKey) {
3763
+ await this.deliverFilesAgoraKey(files, org, provisioned.agoraKey, context);
3764
+ }
3443
3765
  return { resourceId: provisioned.organisationId };
3444
3766
  }
3767
+ /**
3768
+ * Files onboards Agora when it provisions an organisation with an owner and
3769
+ * returns Agora's service key once. Foundry holds no other files key for
3770
+ * Agora, so this is the only one: its secret goes straight to
3771
+ * `deliverSecret`, its id is recorded as undelivered first, and a delivery
3772
+ * that fails revokes the key again instead of leaving a live key nobody holds.
3773
+ */
3774
+ async deliverFilesAgoraKey(files, org, key, context) {
3775
+ const at = `files:organisation for ${org.slug}`;
3776
+ context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, key.id);
3777
+ await context.persistScopeIds();
3778
+ let cause = 'no HTTP status';
3779
+ for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
3780
+ try {
3781
+ await this.ports.deliverSecret(`files:agora-key:${org.slug}`, key.secret);
3782
+ context.recordScopeId(FILES_AGORA_KEY_SCOPE_KEY, key.id);
3783
+ return;
3784
+ }
3785
+ catch (error) {
3786
+ cause = filesErrorCause(error);
3787
+ }
3788
+ }
3789
+ let revoked = false;
3790
+ try {
3791
+ await files.revokeOrganisationKey(org.id, key.id);
3792
+ revoked = true;
3793
+ }
3794
+ catch {
3795
+ // Left on the undelivered id, which the plan reports.
3796
+ }
3797
+ if (revoked)
3798
+ context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, '');
3799
+ throw new Error(`${at}: Agora's key ${key.id} could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause}); ${revoked ? 'it was revoked again' : 'revoke it in files'}`);
3800
+ }
3445
3801
  async filesStorage(action, org) {
3446
3802
  const files = await this.filesPort();
3447
3803
  await files.setQuota(org.id, action.desired.limitBytes ?? null);
@@ -3514,6 +3870,418 @@ export class FoundryStepExecutor {
3514
3870
  // echoes the secret.
3515
3871
  throw new Error(`ai:key for ${org.slug}: key ${issued.id} was minted but its secret could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); revoke it in ai before a new one is minted`);
3516
3872
  }
3873
+ /**
3874
+ * F6a: mints, renews, finishes, re-writes or retires one entry's ai key.
3875
+ *
3876
+ * Order keeps the app on a valid key throughout: the new key is minted below
3877
+ * the org's key, its id is recorded and persisted as undelivered, the env is
3878
+ * written (up to three tries), that write is recorded, the app is restarted,
3879
+ * and only then is the old key revoked. A failed write keeps the old key in
3880
+ * use and the planner blocks instead of minting again. A failed restart
3881
+ * after the write plans `finish`, which restarts and records, minting
3882
+ * nothing. The webhook secret, which ai shows once, is written the same way
3883
+ * before the mint. A secret reaches `bulkUpsertEnvs` and nothing else:
3884
+ * errors name ids, never the cause, which can echo one.
3885
+ */
3886
+ async aiEntryKey(action, org, context) {
3887
+ const entryKey = action.key.slice(org.id.length + 1);
3888
+ const at = `ai:key for ${org.slug} entry '${entryKey}'`;
3889
+ const desired = action.desired;
3890
+ const ai = await this.aiPort();
3891
+ if (desired.mode === 'retire') {
3892
+ const keyId = context.recordedIds()[`ai:key:${action.key}`];
3893
+ if (!keyId)
3894
+ return {};
3895
+ await this.revokeEntryKey(ai, org, entryKey, keyId, context);
3896
+ context.recordScopeId(entryAiRetiredScopeKey(entryKey), keyId);
3897
+ return {};
3898
+ }
3899
+ const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.ai);
3900
+ if (!entry)
3901
+ throw new Error(`${at}: the entry no longer declares uses.ai`);
3902
+ const d = action.desired;
3903
+ const recorded = context.recordedIds();
3904
+ const appId = recorded[`coolify:app:${action.key}`];
3905
+ if (!appId)
3906
+ throw new Error(`${at}: the entry's app is not recorded yet`);
3907
+ const writeEnvs = async (what, ids, envs) => {
3908
+ for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
3909
+ try {
3910
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
3911
+ return;
3912
+ }
3913
+ catch {
3914
+ // The error can echo a value; it is dropped.
3915
+ }
3916
+ }
3917
+ throw new Error(`${at}: ${what} ${ids} could not be written to the app's env after ${AI_KEY_DELIVERY_ATTEMPTS} attempts`);
3918
+ };
3919
+ const previousKeyId = recorded[`ai:key:${action.key}`];
3920
+ let resourceId = previousKeyId;
3921
+ let expiresAt;
3922
+ if (d.mode === 'finish') {
3923
+ // The env already holds the key recorded as undelivered (`planEntryAiKeys`).
3924
+ resourceId = recorded[entryAiUndeliveredScopeKey(entryKey)];
3925
+ expiresAt = recorded[entryAiPendingExpiresScopeKey(entryKey)];
3926
+ await this.ports.coolify.restart(appId);
3927
+ }
3928
+ else {
3929
+ const appliedBefore = recorded[entryAiAppliedScopeKey(entryKey)];
3930
+ let webhookBefore;
3931
+ try {
3932
+ webhookBefore = appliedBefore
3933
+ ? JSON.parse(appliedBefore).webhook
3934
+ : undefined;
3935
+ }
3936
+ catch {
3937
+ webhookBefore = undefined;
3938
+ }
3939
+ if (d.webhook && d.webhook !== webhookBefore) {
3940
+ const pending = entryAiWebhookPendingScopeKey(entryKey);
3941
+ // Kept only while a secret could be lost: from here until the
3942
+ // registration answers without one or the secret is in the env.
3943
+ context.recordScopeId(pending, '1');
3944
+ await context.persistScopeIds();
3945
+ let registered;
3946
+ try {
3947
+ registered = await ai.setWebhookAddress(org.id, d.webhook);
3948
+ }
3949
+ catch (error) {
3950
+ // Refused: no secret exists to lose.
3951
+ context.recordScopeId(pending, '');
3952
+ await context.persistScopeIds();
3953
+ throw error;
3954
+ }
3955
+ if (registered.secret) {
3956
+ await writeEnvs('the webhook secret', 'of the registered webhook', [
3957
+ { key: 'AI_WEBHOOK_SECRET', value: registered.secret },
3958
+ ]);
3959
+ }
3960
+ context.recordScopeId(pending, '');
3961
+ await context.persistScopeIds();
3962
+ }
3963
+ const envs = [
3964
+ { key: 'AI_URL', value: d.aiUrl },
3965
+ { key: 'AI_ORGANISATION_ID', value: org.id },
3966
+ { key: 'AI_OFFERINGS_JSON', value: JSON.stringify(d.offerings) },
3967
+ ];
3968
+ if (d.mode !== 'settings') {
3969
+ const minterKeyId = recorded[`ai:key:${org.id}`];
3970
+ if (!minterKeyId)
3971
+ throw new Error(`${at}: the org's own ai key is not recorded yet`);
3972
+ const minted = await ai.mintNestedKey({
3973
+ organisationId: org.id,
3974
+ minterKeyId,
3975
+ name: d.name,
3976
+ expiresDays: d.expiresDays,
3977
+ permissions: d.permissions,
3978
+ });
3979
+ context.recordScopeId(entryAiUndeliveredScopeKey(entryKey), minted.id);
3980
+ context.recordScopeId(entryAiPendingExpiresScopeKey(entryKey), minted.expiresAt);
3981
+ await context.persistScopeIds();
3982
+ await writeEnvs('key', minted.id, [...envs, { key: 'AI_API_KEY', value: minted.secret }]);
3983
+ // The env holds the key now; a restart that fails from here plans `finish`.
3984
+ context.recordScopeId(entryAiWrittenScopeKey(entryKey), minted.id);
3985
+ await context.persistScopeIds();
3986
+ resourceId = minted.id;
3987
+ expiresAt = minted.expiresAt;
3988
+ }
3989
+ else {
3990
+ await writeEnvs('settings', 'of the entry', envs);
3991
+ }
3992
+ await this.ports.coolify.restart(appId);
3993
+ }
3994
+ if (expiresAt) {
3995
+ context.recordScopeId(entryAiExpiresScopeKey(entryKey), expiresAt);
3996
+ // ai clamps the expiry to the org key's; a key already inside the
3997
+ // renewal window must not be replaced by another as short.
3998
+ const minter = recorded[`ai:key:${org.id}`] ?? '';
3999
+ const clamped = !(Date.parse(expiresAt) - Date.now() >=
4000
+ AI_ENTRY_RENEW_WINDOW_DAYS * 24 * 60 * 60 * 1000);
4001
+ context.recordScopeId(entryAiClampedScopeKey(entryKey), clamped ? JSON.stringify({ expiresAt, minter }) : '');
4002
+ context.recordScopeId(entryAiUndeliveredScopeKey(entryKey), '');
4003
+ context.recordScopeId(entryAiWrittenScopeKey(entryKey), '');
4004
+ }
4005
+ // Follows the wanted state, so a removed `webhookPath` plans nothing again.
4006
+ context.recordScopeId(entryAiAppliedScopeKey(entryKey), JSON.stringify({
4007
+ permissions: d.permissions,
4008
+ offerings: d.offerings,
4009
+ webhook: d.webhook,
4010
+ aiUrl: d.aiUrl,
4011
+ }));
4012
+ if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
4013
+ await this.revokeEntryKey(ai, org, entryKey, previousKeyId, context);
4014
+ }
4015
+ return { resourceId };
4016
+ }
4017
+ /**
4018
+ * REVOKE SEAM (ai#157): revokes through `revokeKey` when the port has one.
4019
+ * Without it, or when it fails, the id joins the entry's list of keys left
4020
+ * live, which the plan shows, and the step goes on: the app already has its
4021
+ * new key.
4022
+ */
4023
+ async revokeEntryKey(ai, org, entryKey, keyId, context) {
4024
+ if (ai.revokeKey) {
4025
+ try {
4026
+ await ai.revokeKey(org.id, keyId);
4027
+ return;
4028
+ }
4029
+ catch {
4030
+ // Listed below instead; the cause is dropped.
4031
+ }
4032
+ }
4033
+ const listKey = entryAiUnrevokedScopeKey(entryKey);
4034
+ const ids = parseIdList(context.recordedIds()[listKey]);
4035
+ if (!ids.includes(keyId))
4036
+ ids.push(keyId);
4037
+ context.recordScopeId(listKey, JSON.stringify(ids));
4038
+ }
4039
+ /**
4040
+ * F6b: mints or renews the org's own files key, or revokes the ones a
4041
+ * renewal replaced. The key is minted below the files organisation's owner
4042
+ * (the member `files_org_owner` records) and its one-time secret goes
4043
+ * straight to {@link FoundryStepExecutorPorts.deliverSecret}. The id is
4044
+ * recorded as undelivered first, so a delivery that fails never leads to a
4045
+ * second mint. Entries are minted below it with `issueKeyBelow`, which needs
4046
+ * no secret.
4047
+ *
4048
+ * A renewal keeps the old key live: its id goes to `files_key_previous`, the
4049
+ * entries are re-minted below the new key by their own steps, and
4050
+ * `revoke-previous` revokes the old key only when every entry holds a key
4051
+ * below the new one. A failed revoke is listed, not retried.
4052
+ */
4053
+ async filesRootKey(action, org, context) {
4054
+ const at = `files:key for ${org.slug}`;
4055
+ const files = await this.filesPort();
4056
+ const mode = action.desired.mode ?? 'mint';
4057
+ const recorded = context.recordedIds();
4058
+ if (mode === 'revoke-previous') {
4059
+ const previous = parseFilesIdList(recorded[FILES_ROOT_PREVIOUS_SCOPE_KEY]);
4060
+ const rootId = recorded[`files:key:${org.id}`];
4061
+ if (previous.length === 0 || !rootId)
4062
+ return {};
4063
+ // Every entry must already hold a key below the new org key; otherwise
4064
+ // an app would lose its key. The next plan asks again.
4065
+ const ready = org.hosting.entries
4066
+ .filter((e) => e.uses?.files)
4067
+ .every((e) => recorded[`files:key:${org.id}:${e.key}`] &&
4068
+ recorded[entryFilesMinterScopeKey(e.key)] === rootId &&
4069
+ !recorded[entryFilesUndeliveredScopeKey(e.key)]);
4070
+ if (!ready)
4071
+ return {};
4072
+ const unrevoked = parseFilesIdList(recorded[FILES_ROOT_UNREVOKED_SCOPE_KEY]);
4073
+ for (const id of previous) {
4074
+ try {
4075
+ await files.revokeOrganisationKey(org.id, id);
4076
+ }
4077
+ catch {
4078
+ if (!unrevoked.includes(id))
4079
+ unrevoked.push(id);
4080
+ }
4081
+ }
4082
+ context.recordScopeId(FILES_ROOT_UNREVOKED_SCOPE_KEY, unrevoked.length ? JSON.stringify(unrevoked) : '');
4083
+ context.recordScopeId(FILES_ROOT_PREVIOUS_SCOPE_KEY, '');
4084
+ return {};
4085
+ }
4086
+ const issuer = recorded[FILES_ORG_OWNER_SCOPE_KEY];
4087
+ if (!issuer) {
4088
+ throw new Error(`${at}: the files organisation has no recorded owner to mint the key below`);
4089
+ }
4090
+ let issued;
4091
+ try {
4092
+ issued = await files.issueOrganisationKey(org.id, {
4093
+ name: action.desired.name,
4094
+ kind: 'service',
4095
+ issuer,
4096
+ permissions: action.desired.permissions,
4097
+ expiresDays: action.desired.expiresDays,
4098
+ });
4099
+ }
4100
+ catch (error) {
4101
+ throw new Error(`${at}: files refused the key (${filesErrorCause(error)})`);
4102
+ }
4103
+ context.recordScopeId(FILES_ROOT_UNDELIVERED_SCOPE_KEY, issued.id);
4104
+ await context.persistScopeIds();
4105
+ let cause = 'no HTTP status';
4106
+ for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
4107
+ try {
4108
+ await this.ports.deliverSecret(`files:key:${org.slug}`, issued.secret);
4109
+ // The expiry and the replaced id follow the delivery: before it, the
4110
+ // recorded key is still the one in use.
4111
+ context.recordScopeId(FILES_ROOT_EXPIRES_SCOPE_KEY, issued.expiresAt);
4112
+ const replaced = recorded[`files:key:${org.id}`];
4113
+ if (mode === 'renew' && replaced) {
4114
+ const previous = parseFilesIdList(recorded[FILES_ROOT_PREVIOUS_SCOPE_KEY]);
4115
+ if (!previous.includes(replaced))
4116
+ previous.push(replaced);
4117
+ context.recordScopeId(FILES_ROOT_PREVIOUS_SCOPE_KEY, JSON.stringify(previous));
4118
+ }
4119
+ return { resourceId: issued.id };
4120
+ }
4121
+ catch (error) {
4122
+ cause = filesErrorCause(error);
4123
+ }
4124
+ }
4125
+ throw new Error(`${at}: key ${issued.id} was minted but its secret could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause}); revoke it in files before a new one is minted`);
4126
+ }
4127
+ /**
4128
+ * F6b: mints, renews, finishes, re-writes or retires one entry's files key.
4129
+ *
4130
+ * Same order as the ai entry key: the collections are ensured first (they
4131
+ * hold no secret and can be repeated), the new key is minted below the
4132
+ * org's own key, its id is recorded and persisted as undelivered, the env
4133
+ * is written (up to three tries), that write is recorded, the app is
4134
+ * restarted, and only then is the old key revoked. A failed write keeps the
4135
+ * old key in use and the planner blocks instead of minting again. A failed
4136
+ * restart after the write plans `finish`, which restarts and records,
4137
+ * minting nothing. A secret reaches `bulkUpsertEnvs` and nothing else.
4138
+ */
4139
+ async filesEntryKey(action, org, context) {
4140
+ const entryKey = action.key.slice(org.id.length + 1);
4141
+ const at = `files:key for ${org.slug} entry '${entryKey}'`;
4142
+ const desired = action.desired;
4143
+ const files = await this.filesPort();
4144
+ if (desired.mode === 'retire') {
4145
+ const recordedNow = context.recordedIds();
4146
+ const keyId = recordedNow[`files:key:${action.key}`];
4147
+ // A key minted but not delivered, or written but not yet restarted, is live too.
4148
+ const looseId = recordedNow[entryFilesUndeliveredScopeKey(entryKey)];
4149
+ for (const id of new Set([keyId, looseId].filter((v) => Boolean(v)))) {
4150
+ await this.revokeFilesEntryKey(files, org, entryKey, id, context);
4151
+ }
4152
+ if (keyId)
4153
+ context.recordScopeId(entryFilesRetiredScopeKey(entryKey), keyId);
4154
+ context.recordScopeId(entryFilesUndeliveredScopeKey(entryKey), '');
4155
+ context.recordScopeId(entryFilesWrittenScopeKey(entryKey), '');
4156
+ return {};
4157
+ }
4158
+ const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.files);
4159
+ if (!entry)
4160
+ throw new Error(`${at}: the entry no longer declares uses.files`);
4161
+ const d = action.desired;
4162
+ const recorded = context.recordedIds();
4163
+ const appId = recorded[`coolify:app:${action.key}`];
4164
+ if (!appId)
4165
+ throw new Error(`${at}: the entry's app is not recorded yet`);
4166
+ const writeEnvs = async (what, ids, envs) => {
4167
+ let cause = 'no HTTP status';
4168
+ for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
4169
+ try {
4170
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
4171
+ return;
4172
+ }
4173
+ catch (error) {
4174
+ // Only the status is kept: the message can echo a value.
4175
+ cause = filesErrorCause(error);
4176
+ }
4177
+ }
4178
+ throw new Error(`${at}: ${what} ${ids} could not be written to the app's env after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause})`);
4179
+ };
4180
+ const previousKeyId = recorded[`files:key:${action.key}`];
4181
+ let resourceId = previousKeyId;
4182
+ let expiresAt;
4183
+ if (d.mode === 'finish') {
4184
+ // The env already holds the key recorded as undelivered (`planFilesKeys`).
4185
+ resourceId = recorded[entryFilesUndeliveredScopeKey(entryKey)];
4186
+ expiresAt = recorded[entryFilesPendingExpiresScopeKey(entryKey)];
4187
+ await this.ports.coolify.restart(appId);
4188
+ }
4189
+ else {
4190
+ // Repeatable by name, so each apply asks again and a collection is never created twice.
4191
+ const envs = [
4192
+ { key: 'FILES_URL', value: d.filesUrl },
4193
+ { key: 'FILES_ORGANISATION_ID', value: org.id },
4194
+ ];
4195
+ for (const name of d.collections) {
4196
+ let collection;
4197
+ try {
4198
+ collection = await files.ensureCollection(org.id, name);
4199
+ }
4200
+ catch (error) {
4201
+ throw new Error(`${at}: collection ${name} could not be ensured (${filesErrorCause(error)})`);
4202
+ }
4203
+ envs.push({ key: filesCollectionEnvName(name), value: collection.id });
4204
+ }
4205
+ if (d.mode !== 'settings') {
4206
+ const minterId = recorded[`files:key:${org.id}`];
4207
+ if (!minterId)
4208
+ throw new Error(`${at}: the org's own files key is not recorded yet`);
4209
+ // files refuses a key that outlives its minter. A minute is held back
4210
+ // so the call, which files dates later, still lands inside it.
4211
+ const rootExpires = Date.parse(recorded[FILES_ROOT_EXPIRES_SCOPE_KEY] ?? '');
4212
+ if (Number.isNaN(rootExpires)) {
4213
+ throw new Error(`${at}: the org's own files key expiry is not recorded`);
4214
+ }
4215
+ const expiresDays = Math.min(FILES_KEY_DAYS, Math.floor((rootExpires - (context.now?.() ?? Date.now()) - 60_000) / DAY_MS));
4216
+ if (expiresDays < FILES_KEY_RENEW_WINDOW_DAYS) {
4217
+ throw new Error(`${at}: the org's own files key leaves under ${FILES_KEY_RENEW_WINDOW_DAYS} days; mint a new org key first`);
4218
+ }
4219
+ let minted;
4220
+ try {
4221
+ minted = await files.issueKeyBelow(org.id, minterId, {
4222
+ name: d.name,
4223
+ kind: 'service',
4224
+ permissions: d.permissions,
4225
+ expiresDays,
4226
+ });
4227
+ }
4228
+ catch (error) {
4229
+ throw new Error(`${at}: files refused the key (${filesErrorCause(error)})`);
4230
+ }
4231
+ context.recordScopeId(entryFilesUndeliveredScopeKey(entryKey), minted.id);
4232
+ context.recordScopeId(entryFilesPendingExpiresScopeKey(entryKey), minted.expiresAt);
4233
+ await context.persistScopeIds();
4234
+ await writeEnvs('key', minted.id, [
4235
+ ...envs,
4236
+ { key: 'FILES_API_KEY', value: minted.secret },
4237
+ ]);
4238
+ // The env holds the key now; a restart that fails from here plans `finish`.
4239
+ context.recordScopeId(entryFilesWrittenScopeKey(entryKey), minted.id);
4240
+ context.recordScopeId(entryFilesMinterScopeKey(entryKey), minterId);
4241
+ await context.persistScopeIds();
4242
+ resourceId = minted.id;
4243
+ expiresAt = minted.expiresAt;
4244
+ }
4245
+ else {
4246
+ await writeEnvs('settings', 'of the entry', envs);
4247
+ }
4248
+ await this.ports.coolify.restart(appId);
4249
+ }
4250
+ if (expiresAt) {
4251
+ context.recordScopeId(entryFilesExpiresScopeKey(entryKey), expiresAt);
4252
+ context.recordScopeId(entryFilesUndeliveredScopeKey(entryKey), '');
4253
+ context.recordScopeId(entryFilesWrittenScopeKey(entryKey), '');
4254
+ }
4255
+ // Follows the wanted state, so an unchanged declaration plans nothing again.
4256
+ context.recordScopeId(entryFilesAppliedScopeKey(entryKey), JSON.stringify({
4257
+ permissions: d.permissions,
4258
+ collections: d.collections,
4259
+ filesUrl: d.filesUrl,
4260
+ }));
4261
+ if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
4262
+ await this.revokeFilesEntryKey(files, org, entryKey, previousKeyId, context);
4263
+ }
4264
+ return { resourceId };
4265
+ }
4266
+ /**
4267
+ * Revokes one entry key. When the call fails the id joins the entry's list
4268
+ * of keys left live, which the plan shows, and the step goes on: the app
4269
+ * already has its new key.
4270
+ */
4271
+ async revokeFilesEntryKey(files, org, entryKey, keyId, context) {
4272
+ try {
4273
+ await files.revokeOrganisationKey(org.id, keyId);
4274
+ return;
4275
+ }
4276
+ catch {
4277
+ // Listed below instead; the cause is dropped.
4278
+ }
4279
+ const listKey = entryFilesUnrevokedScopeKey(entryKey);
4280
+ const ids = parseFilesIdList(context.recordedIds()[listKey]);
4281
+ if (!ids.includes(keyId))
4282
+ ids.push(keyId);
4283
+ context.recordScopeId(listKey, JSON.stringify(ids));
4284
+ }
3517
4285
  // ---- destroy -------------------------------------------------------------
3518
4286
  /**
3519
4287
  * {@link StepDestroyer.destroy}: deletes the one resource
@@ -3652,6 +4420,20 @@ export class FoundryStepExecutor {
3652
4420
  case 'cloudflare:access':
3653
4421
  await this.ports.cloudflare.deleteAccessApp(target.id);
3654
4422
  return;
4423
+ case 'ai:key': {
4424
+ // F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a
4425
+ // port with no `revokeKey` leaves it live until it expires.
4426
+ const ai = await this.aiPort();
4427
+ if (ai.revokeKey)
4428
+ await ai.revokeKey(org.id, target.id);
4429
+ return;
4430
+ }
4431
+ case 'files:key': {
4432
+ // F6b: an entry's key (`<orgId>:<entry>`); the org's own key is left alone.
4433
+ const files = await this.filesPort();
4434
+ await files.revokeOrganisationKey(org.id, target.id);
4435
+ return;
4436
+ }
3655
4437
  case 'coolify:app':
3656
4438
  await this.ports.coolify.deleteApplication(target.id);
3657
4439
  return;