@wtfalch/foundry 0.31.0 → 0.33.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.
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
+ import { redactSecrets } from '../calls-sfu-deploy-support.js';
3
4
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
4
5
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
6
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_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, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
+ import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
8
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
9
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
10
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -27,6 +28,7 @@ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyC
27
28
  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, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
28
29
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
29
30
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
31
+ import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
30
32
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
31
33
  /**
32
34
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -51,7 +53,7 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
51
53
  */
52
54
  /** #569: the description foundry gives the Coolify project it creates for an org. */
53
55
  const COOLIFY_PROJECT_MARK_PREFIX = 'foundry org ';
54
- function coolifyProjectMark(org) {
56
+ export function coolifyProjectMark(org) {
55
57
  return `${COOLIFY_PROJECT_MARK_PREFIX}${org.id}`;
56
58
  }
57
59
  /**
@@ -230,24 +232,6 @@ const RATE_LIMIT_RULE_DESCRIPTION = 'foundry: rate limit sign-in paths';
230
232
  */
231
233
  const RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS = 10;
232
234
  const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
233
- /**
234
- * The shared standard app a level-1 org with no `bespokeAppRepo` gets
235
- * deployed (gap P1, `.plans/2026-09-26-level-1-org-map.md`): one repo, the
236
- * same for every such org, decided by William 2026-09-26. `coolifyApp` below
237
- * names it as the git source in exactly the shape `bespokeAppRepo` itself
238
- * uses (`org.ts`'s `DeclaredOrg.bespokeAppRepo: string | null`) whenever an
239
- * org declares none of its own — the repo may not exist yet at decision
240
- * time, and Coolify's own GitHub App create call is what will fail loudly
241
- * against it, the same as any other repo name that does not exist.
242
- *
243
- * Hestia moved into this repo's own `apps/hestia` (estate DECISIONS.md,
244
- * 2026-09-28; the hestia repo archives once this move lands), so the
245
- * standard app now builds from foundry's own repo, told apart from
246
- * `packages/foundry` by `dockerfile_location` — the same one-repo,
247
- * multiple-apps shape `cmsSite`/`CMS_APP_DOCKERFILE` already use below.
248
- */
249
- const DEFAULT_APP_REPO = 'wtfalch/foundry';
250
- const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
251
235
  /**
252
236
  * The shared repo `cmsSite` builds its cms admin app from, always, and its
253
237
  * site app from by default (https://github.com/wtfalch/cms) -- the
@@ -275,7 +259,7 @@ const CMS_APP_PORT = 3000;
275
259
  * #164: the repo `coolifyAgora` builds the org's Agora from
276
260
  * (https://github.com/wtfalch/agora) -- the issue's own "How" names this
277
261
  * exact Dockerfile, and it is the only build strategy any app this file
278
- * provisions ever uses ({@link DEFAULT_APP_DOCKERFILE}/{@link CMS_APP_DOCKERFILE}/
262
+ * provisions ever uses ({@link CMS_APP_DOCKERFILE}/
279
263
  * `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
280
264
  * `createGithubAppApplication` build-from-repo call, never a registry
281
265
  * image): "Coolify builds each org's Agora from the agora repo" is not a
@@ -779,9 +763,14 @@ export class FoundryStepExecutor {
779
763
  edgeRateLimits;
780
764
  edgeZones;
781
765
  edgeWriteSlot;
766
+ /** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
767
+ handledSecrets = new Set();
782
768
  constructor(ports) {
783
769
  this.ports = ports;
784
770
  }
771
+ secretsHandled() {
772
+ return [...this.handledSecrets];
773
+ }
785
774
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
786
775
  ownEdgePorts() {
787
776
  return {
@@ -797,6 +786,10 @@ export class FoundryStepExecutor {
797
786
  switch (`${action.system}:${action.kind}`) {
798
787
  case 'zitadel:issuer':
799
788
  return this.zitadelIssuer(action, org, context);
789
+ case 'zitadel:registration-closed':
790
+ return this.zitadelRegistrationClosed(org, context);
791
+ case 'zitadel:domain':
792
+ return this.zitadelDomain(action, org, context);
800
793
  case 'zitadel:owner':
801
794
  return this.zitadelOwner(action, org, context);
802
795
  case 'zitadel:login-host':
@@ -1308,17 +1301,60 @@ export class FoundryStepExecutor {
1308
1301
  // their own design" -- not built in v1).
1309
1302
  throw new NotBuiltError(`zitadel:issuer move for ${org.slug}`, 'moving the issuer onto or off the org\'s own domain is not built in v1; see foundry\'s .claude/decisions-2026-09-24-executor.md, "Executor decisions"');
1310
1303
  }
1311
- const { org: zOrg } = await this.ports.zitadel.findOrCreateOrg(org.slug);
1312
- context.recordScopeId('zitadel_org', zOrg.id);
1313
- return { resourceId: zOrg.id };
1304
+ const { orgId } = await ensureSignInOrg({
1305
+ zitadel: this.ports.zitadel,
1306
+ name: org.slug,
1307
+ domain: signInOrgDomain(org),
1308
+ recordScopeId: (key, id) => context.recordScopeId(key, id),
1309
+ persistScopeIds: () => context.persistScopeIds(),
1310
+ registrationClosedScopeKey: ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
1311
+ });
1312
+ return { resourceId: orgId };
1313
+ }
1314
+ /**
1315
+ * `plan/diff.ts`'s `planOrgRegistrationClosed`: closes self-registration on
1316
+ * the recorded ZITADEL org, for an org made before foundry did this or whose
1317
+ * close failed in the issuer step. Recorded only after the close returned.
1318
+ */
1319
+ async zitadelRegistrationClosed(org, context) {
1320
+ const zitadelOrgId = context.recordedIds().zitadel_org;
1321
+ if (!zitadelOrgId) {
1322
+ throw new Error(`zitadel:registration-closed for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1323
+ }
1324
+ await closeSignInOrgRegistration(this.ports.zitadel, zitadelOrgId, (key, id) => context.recordScopeId(key, id));
1325
+ return {};
1314
1326
  }
1315
1327
  /**
1316
- * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
1317
- * org by email. Never logs, returns or throws the email itself -- only the
1318
- * invited user id, handed back as `resourceId` so `runSteps` records it
1319
- * under `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1320
- * `plan()` call idempotent (`planOwner` plans nothing once that key is
1321
- * recorded).
1328
+ * `plan/diff.ts`'s `planOrgDomain`: registers the org's sign-in domain on
1329
+ * its recorded ZITADEL org and makes it primary, for an org whose ZITADEL
1330
+ * org was made before foundry did this. Records the domain only after both
1331
+ * calls succeeded.
1332
+ */
1333
+ async zitadelDomain(action, org, context) {
1334
+ const zitadelOrgId = context.recordedIds().zitadel_org;
1335
+ if (!zitadelOrgId) {
1336
+ throw new Error(`zitadel:domain for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1337
+ }
1338
+ const domain = action.desired.domain;
1339
+ // A refusal (the sign-in server's domain policy wants proof) is recorded and reported as
1340
+ // blocked by the planner; it does not fail every apply.
1341
+ const registered = await registerSignInDomainOrBlock(this.ports.zitadel, zitadelOrgId, domain, (key, id) => context.recordScopeId(key, id));
1342
+ if (registered)
1343
+ context.recordScopeId(ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
1344
+ return {};
1345
+ }
1346
+ /**
1347
+ * `plan/diff.ts`'s `planOwners`: makes the owner's account in the org's
1348
+ * ZITADEL org and mails the first-sign-in link to their personal address.
1349
+ * The account's username is the local part of `mailboxAddress` and its email
1350
+ * is `mailboxAddress`, so the owner signs in as `name@<org domain>`; the
1351
+ * personal address is kept as the `personal_email` metadata. The invite code
1352
+ * ZITADEL returns goes only into that mail. Nothing here logs, returns or
1353
+ * throws the personal address or the code -- only the invited user id, handed
1354
+ * back as `resourceId` so `runSteps` records it under
1355
+ * `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1356
+ * `plan()` call idempotent. A failure after the account exists records
1357
+ * nothing: the next apply finds the account by username and mints a new code.
1322
1358
  */
1323
1359
  async zitadelOwner(action, org, context) {
1324
1360
  const zitadelOrgId = context.recordedIds().zitadel_org;
@@ -1331,11 +1367,31 @@ export class FoundryStepExecutor {
1331
1367
  if (!authIssuer) {
1332
1368
  throw new Error(`zitadel:owner for ${org.slug}: no invite template issuer -- refusing to send ZITADEL's default invite link, which 404s on this platform`);
1333
1369
  }
1334
- const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
1335
- givenName: action.desired.givenName,
1336
- familyName: action.desired.familyName,
1337
- email: action.desired.email,
1338
- }, `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`);
1370
+ const mailboxAddress = action.desired.mailboxAddress;
1371
+ if (!mailboxAddress) {
1372
+ throw new Error(`zitadel:owner for ${org.slug}: no mailboxAddress -- the planner reports this as blocked, so it should not reach the executor`);
1373
+ }
1374
+ const inviteMail = this.ports.ownerInviteMail;
1375
+ if (!inviteMail) {
1376
+ throw new Error(`zitadel:owner for ${org.slug}: no ownerInviteMail port -- the owner's work mailbox cannot be opened yet, so the invite is not sent there`);
1377
+ }
1378
+ // foundry's own record of this owner's account, so a returning owner is not mistaken for a stranger.
1379
+ const invitedBefore = context.recordedIds()[`zitadel:owner:${action.key}`];
1380
+ const { userId } = await inviteOwnerWithLink({
1381
+ zitadel: this.ports.zitadel,
1382
+ mail: inviteMail,
1383
+ orgId: zitadelOrgId,
1384
+ authIssuer,
1385
+ owner: {
1386
+ givenName: action.desired.givenName,
1387
+ familyName: action.desired.familyName,
1388
+ mailboxAddress,
1389
+ personalEmail: action.desired.email,
1390
+ },
1391
+ label: `zitadel:owner for ${org.slug}`,
1392
+ onSecret: (value) => this.handledSecrets.add(value),
1393
+ ...(invitedBefore ? { invitedUserId: invitedBefore } : {}),
1394
+ });
1339
1395
  // An owner invited after chat was built never reaches `coolifyChat`
1340
1396
  // again, so the role is granted here once the messaging project exists.
1341
1397
  const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
@@ -1868,7 +1924,19 @@ export class FoundryStepExecutor {
1868
1924
  const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
1869
1925
  if (action.desired.enabled === false) {
1870
1926
  if (!recordedAppId) {
1871
- throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
1927
+ throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to ${action.desired.delete === true ? 'delete' : 'park'}`);
1928
+ }
1929
+ // `planApp` sets `delete` only on the retired default app. A 404 counts
1930
+ // as deleted, so a retry after a crash past the Coolify delete succeeds.
1931
+ if (action.desired.delete === true) {
1932
+ try {
1933
+ await this.ports.coolify.deleteApplication(recordedAppId);
1934
+ }
1935
+ catch (error) {
1936
+ if (!isNotFoundError(error))
1937
+ throw error;
1938
+ }
1939
+ return {};
1872
1940
  }
1873
1941
  await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
1874
1942
  return { resourceId: recordedAppId };
@@ -1876,10 +1944,12 @@ export class FoundryStepExecutor {
1876
1944
  const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
1877
1945
  if (!entry)
1878
1946
  throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
1879
- // A level-1 org with no bespoke app of its own gets the shared standard
1880
- // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
1881
- // with no `repo`.
1882
- const appRepo = entry.repo ?? DEFAULT_APP_REPO;
1947
+ // `planApp` blocks a `repo: null` entry at plan time; reaching here with
1948
+ // one means a stale or hand-built plan.
1949
+ if (entry.repo === null) {
1950
+ throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
1951
+ }
1952
+ const appRepo = entry.repo;
1883
1953
  const projectUuid = await this.ensureCoolifyProject(org, context);
1884
1954
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1885
1955
  // The recorded id first; the name when nothing is recorded, or when the
@@ -1953,9 +2023,6 @@ export class FoundryStepExecutor {
1953
2023
  // copies `base_directory`, and reads `publish_directory` only for a
1954
2024
  // built app.
1955
2025
  base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
1956
- // Only the shared standard app builds from this repo; a bespoke
1957
- // app's Dockerfile is at its own repo's root.
1958
- dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
1959
2026
  });
1960
2027
  appId = created.uuid;
1961
2028
  // Create sent every setting, so nothing is left to patch.
@@ -2500,6 +2567,23 @@ export class FoundryStepExecutor {
2500
2567
  // Last: a run that failed above plans the step again.
2501
2568
  context.recordScopeId(brokerEntryAppliedScopeKey(entryKey), JSON.stringify(want));
2502
2569
  }
2570
+ /**
2571
+ * `null` once the app already has `AUTHZ_PEER_PASSWORD_OPERATOR`: minted only
2572
+ * the first time and never rotated, like {@link ensureAuthCookieSecret}.
2573
+ */
2574
+ async ensurePeerPassword(appId) {
2575
+ return this.mintIfAbsent(appId, BOULE_PEER_PASSWORD_ENV, 24);
2576
+ }
2577
+ /** Same rule for the secret Archon's invite form sends to Boule. */
2578
+ async ensureBridgeSecret(appId) {
2579
+ return this.mintIfAbsent(appId, BOULE_BRIDGE_SECRET_ENV, 32);
2580
+ }
2581
+ async mintIfAbsent(appId, key, bytes) {
2582
+ const envs = await this.ports.coolify.listEnvs(appId);
2583
+ if (envs.some((e) => e.key === key && !e.is_preview))
2584
+ return null;
2585
+ return randomBytes(bytes).toString('base64url');
2586
+ }
2503
2587
  /**
2504
2588
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
2505
2589
  * only the first time (task brief), never regenerated or overwritten, so a
@@ -4714,7 +4798,29 @@ export class FoundryStepExecutor {
4714
4798
  const appKey = await this.ensureAuthAppKey(app.appId);
4715
4799
  if (appKey)
4716
4800
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
4717
- await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
4801
+ // Archon's way into this Boule's people store: only the platform's own
4802
+ // org's Boule gets it, minted once and never rotated (the role Boule's boot
4803
+ // creates from it would otherwise keep the old password).
4804
+ const peerPassword = action.desired.platformOwner === true ? await this.ensurePeerPassword(app.appId) : null;
4805
+ if (peerPassword) {
4806
+ this.handledSecrets.add(peerPassword);
4807
+ envs.push({ key: BOULE_PEER_PASSWORD_ENV, value: peerPassword });
4808
+ }
4809
+ // Archon's invite form calls Boule with this secret; same rule as the password.
4810
+ const bridgeSecret = action.desired.platformOwner === true ? await this.ensureBridgeSecret(app.appId) : null;
4811
+ if (bridgeSecret) {
4812
+ this.handledSecrets.add(bridgeSecret);
4813
+ envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
4814
+ }
4815
+ try {
4816
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
4817
+ }
4818
+ catch (error) {
4819
+ const minted = [peerPassword, bridgeSecret].filter((v) => v !== null);
4820
+ if (minted.length === 0)
4821
+ throw error;
4822
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
4823
+ }
4718
4824
  const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
4719
4825
  if (!dbPush.pushed) {
4720
4826
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
@@ -4730,6 +4836,8 @@ export class FoundryStepExecutor {
4730
4836
  // that a previous apply created but crashed before deploying) sat built
4731
4837
  // but never started. Deployed once this executor has confirmed, never
4732
4838
  // again -- a live app is only ever restarted from here on.
4839
+ const secretsNotRestarted = action.desired.platformOwner === true &&
4840
+ !context.recordedIds()[BOULE_SECRETS_RESTARTED_SCOPE_KEY];
4733
4841
  if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
4734
4842
  // #543: every deploy waits for health -- Boule's own public hostname.
4735
4843
  const runtime = this.ports.runtime ?? {};
@@ -4738,10 +4846,21 @@ export class FoundryStepExecutor {
4738
4846
  }
4739
4847
  else if (app.existed &&
4740
4848
  !mail.restarted &&
4741
- (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
4849
+ (envChanged ||
4850
+ cookieSecret !== null ||
4851
+ appKey !== null ||
4852
+ peerPassword !== null ||
4853
+ bridgeSecret !== null ||
4854
+ secretsNotRestarted ||
4855
+ dbPush.changed ||
4856
+ mail.changed)) {
4742
4857
  // A restart inside the mail rule already came after every env write above.
4743
4858
  await this.ports.coolify.restart(app.appId);
4744
4859
  }
4860
+ // Last, after the restart or deploy: a run that failed before here repeats it, minting nothing.
4861
+ if (action.desired.platformOwner === true) {
4862
+ context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
4863
+ }
4745
4864
  return { resourceId: app.appId };
4746
4865
  }
4747
4866
  /**
@@ -28,7 +28,7 @@ const CALLS_HEALTH_PATH = '/healthz';
28
28
  function alwaysOnTargets(org, platform) {
29
29
  // The platform's own org signs in on the shared instance host, not its own
30
30
  // `loginHostnameFor` (#488; `login-host.ts`, `diff.ts`'s `planLoginHost`).
31
- const loginHost = platform && isPlatformOwnOrg(org, platform.apex)
31
+ const loginHost = platform && isPlatformOwnOrg(org, platform.ownerOrgId)
32
32
  ? platform.auth.hostname
33
33
  : loginHostnameFor(org);
34
34
  return [
@@ -12,8 +12,9 @@ export type { PlatformOrgClientFactories } from './platform-org-ports.js';
12
12
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
13
13
  export type { AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot } from './adopt.js';
14
14
  export { AdoptRefusedError } from './adopt.js';
15
- export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
15
+ export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
16
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
+ export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
17
18
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
19
  export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
19
20
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
@@ -40,6 +41,8 @@ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
40
41
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
41
42
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
42
43
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
44
+ export type { EnsureSignInOrgOptions } from './sign-in-org.js';
45
+ export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './sign-in-org.js';
43
46
  export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './health-targets.js';
44
47
  export { healthTargets, platformHealthTargets } from './health-targets.js';
45
48
  export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
@@ -5,7 +5,7 @@ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDUL
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
8
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
8
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
9
9
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
10
10
  // #171: how a host signs files management requests with the delivered key.
11
11
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
@@ -21,6 +21,7 @@ export { loginHostnameFor } from './login-host.js';
21
21
  export { checkMailIdentityAudience } from './mail-identity.js';
22
22
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
23
23
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
24
+ export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './sign-in-org.js';
24
25
  export { healthTargets, platformHealthTargets } from './health-targets.js';
25
26
  export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
26
27
  export { createEdgeAccess } from './edge-access.js';
@@ -7,6 +7,7 @@ import type { AppliedState } from '../state.js';
7
7
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
8
8
  import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
9
9
  import { type AgoraHostPort } from './agora.js';
10
+ import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
10
11
  import { type StepAction } from './diff.js';
11
12
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
12
13
  import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
@@ -171,6 +172,12 @@ export interface StepExecutor {
171
172
  * an executor without it supplies none.
172
173
  */
173
174
  ownEdgePorts?(): Partial<Record<EdgePortName, boolean>>;
175
+ /**
176
+ * Every secret this executor minted or read so far (peer password, bridge
177
+ * secret, invite code and link). The host's run adds them to its redactor
178
+ * after each step, so an error or progress line that quotes one is masked.
179
+ */
180
+ secretsHandled?(): readonly string[];
174
181
  }
175
182
  /**
176
183
  * #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
@@ -313,6 +320,15 @@ export declare class StalePlanError extends Error {
313
320
  export declare class ConfirmationRequiredError extends Error {
314
321
  constructor(orgId: OrgId);
315
322
  }
323
+ /**
324
+ * A plan holds a version change that needs confirmation
325
+ * (`VersionChangeItem.needsConfirmation`) and `confirmUpgrade` does not name
326
+ * its service exactly. `upgrades` lists every such service with its versions.
327
+ */
328
+ export declare class UpgradeConfirmationRequiredError extends Error {
329
+ readonly upgrades: readonly UnconfirmedUpgrade[];
330
+ constructor(upgrades: readonly UnconfirmedUpgrade[]);
331
+ }
316
332
  export declare class NotAuthorizedError extends Error {
317
333
  constructor(actor: AuditActor, orgId: OrgId);
318
334
  }
@@ -336,6 +352,11 @@ export declare class PlatformStalePlanError extends Error {
336
352
  export declare class PlatformConfirmationRequiredError extends Error {
337
353
  constructor();
338
354
  }
355
+ /** The third argument of `apply` and `applyPlatform`. */
356
+ export interface FoundryApplyOptions {
357
+ /** Service names (`VersionChangeItem.service`), matched exactly, whose version change the operator confirmed. */
358
+ confirmUpgrade?: readonly string[];
359
+ }
339
360
  export interface FoundryPlanApplyOptions {
340
361
  store: DeclaredOrgStore;
341
362
  readers: LiveReaders;
@@ -380,6 +401,12 @@ export interface FoundryPlanApplyOptions {
380
401
  * reported blocked instead of planned.
381
402
  */
382
403
  edgeWorkers?: boolean;
404
+ /**
405
+ * True when the host's executor was built with `ownerInviteMail`. Optional,
406
+ * and false without it: each owner's invite is then reported blocked
407
+ * instead of planned.
408
+ */
409
+ ownerInviteMail?: boolean;
383
410
  /**
384
411
  * #177: true when the host's executor was built with its own `filesOrganisation`
385
412
  * / `aiOrganisation` port. Without one, the org step needs the platform's
@@ -393,6 +420,15 @@ export interface FoundryPlanApplyOptions {
393
420
  * reported blocked instead of planned to fail.
394
421
  */
395
422
  edge?: boolean;
423
+ /**
424
+ * Narrows every org plan to the steps `step` accepts, and its blocked items
425
+ * to the keys `blocked` accepts, so a plan, its stale check and its apply
426
+ * all see the same slice. Absent: the whole plan.
427
+ */
428
+ onlySteps?: {
429
+ step(action: StepAction): boolean;
430
+ blocked(key: string): boolean;
431
+ };
396
432
  /** Overridable for tests; defaults to `() => new Date()`. */
397
433
  now?: () => Date;
398
434
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -471,9 +507,11 @@ export declare class FoundryPlanApply implements PlanApply {
471
507
  private readonly zitadelIdTokenUserinfo;
472
508
  private readonly broker;
473
509
  private readonly edgeWorkers;
510
+ private readonly ownerInviteMail;
474
511
  private readonly hostFiles;
475
512
  private readonly hostAi;
476
513
  private readonly hostEdge;
514
+ private readonly onlySteps;
477
515
  private readonly now;
478
516
  private readonly leaseTtlMs;
479
517
  private readonly appliedVersions;
@@ -518,7 +556,7 @@ export declare class FoundryPlanApply implements PlanApply {
518
556
  */
519
557
  release(id: OrgId, entryKey: string): Promise<AppliedState>;
520
558
  private computeAdopt;
521
- apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
559
+ apply(plan: Plan, confirmedOrgName: string, options?: FoundryApplyOptions): Promise<AppliedState>;
522
560
  /**
523
561
  * After an org apply: gives the platform files runner a changed share-domain
524
562
  * set (its Coolify `domains` and `FILES_PUBLIC_HOSTS`, derived from the
@@ -543,7 +581,7 @@ export declare class FoundryPlanApply implements PlanApply {
543
581
  * shape of their own. `confirmedName` must be the literal word `platform`
544
582
  * rather than a declared name -- `DeclaredPlatform` has none.
545
583
  */
546
- applyPlatform(plan: Plan, confirmedName: string): Promise<{
584
+ applyPlatform(plan: Plan, confirmedName: string, options?: FoundryApplyOptions): Promise<{
547
585
  serviceIds: Record<string, string>;
548
586
  }>;
549
587
  /**
@@ -6,6 +6,7 @@ import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
6
6
  import { StaleLeaseError, } from '../store.js';
7
7
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
8
8
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
9
+ import { unconfirmedUpgrades } from './confirm-upgrade.js';
9
10
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
10
11
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
11
12
  import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
@@ -53,6 +54,21 @@ export class ConfirmationRequiredError extends Error {
53
54
  this.name = 'ConfirmationRequiredError';
54
55
  }
55
56
  }
57
+ /**
58
+ * A plan holds a version change that needs confirmation
59
+ * (`VersionChangeItem.needsConfirmation`) and `confirmUpgrade` does not name
60
+ * its service exactly. `upgrades` lists every such service with its versions.
61
+ */
62
+ export class UpgradeConfirmationRequiredError extends Error {
63
+ upgrades;
64
+ constructor(upgrades) {
65
+ super(`the plan changes versions that must be confirmed by service name: ${upgrades
66
+ .map((u) => `${u.service} ${u.from ?? 'unknown'} -> ${u.to ?? 'unknown'}`)
67
+ .join(', ')}; pass confirmUpgrade naming each one`);
68
+ this.upgrades = upgrades;
69
+ this.name = 'UpgradeConfirmationRequiredError';
70
+ }
71
+ }
56
72
  export class NotAuthorizedError extends Error {
57
73
  constructor(actor, orgId) {
58
74
  super(`${actor.display} does not hold the permission to apply org ${orgId} (#9, #19)`);
@@ -124,9 +140,11 @@ export class FoundryPlanApply {
124
140
  zitadelIdTokenUserinfo;
125
141
  broker;
126
142
  edgeWorkers;
143
+ ownerInviteMail;
127
144
  hostFiles;
128
145
  hostAi;
129
146
  hostEdge;
147
+ onlySteps;
130
148
  now;
131
149
  leaseTtlMs;
132
150
  appliedVersions;
@@ -146,9 +164,11 @@ export class FoundryPlanApply {
146
164
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
147
165
  this.broker = options.broker ?? false;
148
166
  this.edgeWorkers = options.edgeWorkers ?? false;
167
+ this.ownerInviteMail = options.ownerInviteMail ?? false;
149
168
  this.hostFiles = options.filesOrganisation ?? false;
150
169
  this.hostAi = options.aiOrganisation ?? false;
151
170
  this.hostEdge = options.edge ?? false;
171
+ this.onlySteps = options.onlySteps;
152
172
  this.now = options.now ?? (() => new Date());
153
173
  this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
154
174
  this.appliedVersions = options.appliedVersions;
@@ -341,7 +361,7 @@ export class FoundryPlanApply {
341
361
  previous,
342
362
  };
343
363
  }
344
- async apply(plan, confirmedOrgName) {
364
+ async apply(plan, confirmedOrgName, options = {}) {
345
365
  const orgId = plan.orgId;
346
366
  const authorized = await this.authz.canApply(this.actor, orgId);
347
367
  if (!authorized) {
@@ -354,6 +374,11 @@ export class FoundryPlanApply {
354
374
  await this.refuse(plan, 'stale: live state has moved since this plan was computed');
355
375
  throw new StalePlanError(orgId);
356
376
  }
377
+ const unconfirmed = unconfirmedUpgrades(freshPlan.versionChanges, options.confirmUpgrade);
378
+ if (unconfirmed.length > 0) {
379
+ await this.refuse(plan, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
380
+ throw new UpgradeConfirmationRequiredError(unconfirmed);
381
+ }
357
382
  if (planNeedsConfirmation(plan.steps) &&
358
383
  confirmedOrgName.trim() !== org.displayName.trim()) {
359
384
  await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
@@ -426,7 +451,7 @@ export class FoundryPlanApply {
426
451
  * shape of their own. `confirmedName` must be the literal word `platform`
427
452
  * rather than a declared name -- `DeclaredPlatform` has none.
428
453
  */
429
- async applyPlatform(plan, confirmedName) {
454
+ async applyPlatform(plan, confirmedName, options = {}) {
430
455
  const { store, executor } = this.requirePlatformDeps();
431
456
  const declared = await store.get();
432
457
  if (!declared)
@@ -449,6 +474,11 @@ export class FoundryPlanApply {
449
474
  await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
450
475
  throw new PlatformStalePlanError();
451
476
  }
477
+ const unconfirmed = unconfirmedUpgrades(freshPlan.versionChanges, options.confirmUpgrade);
478
+ if (unconfirmed.length > 0) {
479
+ await this.refusePlatform(plan, platform.ownerOrgId, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
480
+ throw new UpgradeConfirmationRequiredError(unconfirmed);
481
+ }
452
482
  if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
453
483
  await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
454
484
  throw new PlatformConfirmationRequiredError();
@@ -803,6 +833,10 @@ export class FoundryPlanApply {
803
833
  const { system, kind, key } = planned.action;
804
834
  serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
805
835
  }
836
+ else if (planned.action.desired.delete === true) {
837
+ const { system, kind, key } = planned.action;
838
+ delete serviceIds[`${system}:${kind}:${key}`];
839
+ }
806
840
  checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
807
841
  }
808
842
  }
@@ -881,6 +915,7 @@ export class FoundryPlanApply {
881
915
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
882
916
  broker: this.broker,
883
917
  edgeWorkers: this.edgeWorkers,
918
+ ownerInviteMail: this.ownerInviteMail,
884
919
  otherSlugs,
885
920
  now: () => this.now().getTime(),
886
921
  archonUrl,
@@ -896,6 +931,11 @@ export class FoundryPlanApply {
896
931
  key: 'hosting:invalid',
897
932
  });
898
933
  }
934
+ if (this.onlySteps) {
935
+ const { step, blocked } = this.onlySteps;
936
+ detail.plannedSteps = detail.plannedSteps.filter(({ action }) => step(action));
937
+ detail.unmanaged = detail.unmanaged.filter((item) => item.key === undefined || blocked(item.key));
938
+ }
899
939
  return { org, plan: toPlan(detail), detail };
900
940
  }
901
941
  /**
@@ -1459,6 +1499,7 @@ function canonical(plan) {
1459
1499
  // version-change line must still be caught as stale, the same as any
1460
1500
  // other field here.
1461
1501
  versionChanges: [...(plan.versionChanges ?? [])].sort((x, y) => x.description.localeCompare(y.description)),
1502
+ notes: [...(plan.notes ?? [])].sort((x, y) => x.key.localeCompare(y.key)),
1462
1503
  };
1463
1504
  }
1464
1505
  /**