@wtfalch/foundry 0.32.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.
@@ -172,6 +172,12 @@ export interface StepExecutor {
172
172
  * an executor without it supplies none.
173
173
  */
174
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[];
175
181
  }
176
182
  /**
177
183
  * #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
@@ -395,6 +401,12 @@ export interface FoundryPlanApplyOptions {
395
401
  * reported blocked instead of planned.
396
402
  */
397
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;
398
410
  /**
399
411
  * #177: true when the host's executor was built with its own `filesOrganisation`
400
412
  * / `aiOrganisation` port. Without one, the org step needs the platform's
@@ -408,6 +420,15 @@ export interface FoundryPlanApplyOptions {
408
420
  * reported blocked instead of planned to fail.
409
421
  */
410
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
+ };
411
432
  /** Overridable for tests; defaults to `() => new Date()`. */
412
433
  now?: () => Date;
413
434
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -486,9 +507,11 @@ export declare class FoundryPlanApply implements PlanApply {
486
507
  private readonly zitadelIdTokenUserinfo;
487
508
  private readonly broker;
488
509
  private readonly edgeWorkers;
510
+ private readonly ownerInviteMail;
489
511
  private readonly hostFiles;
490
512
  private readonly hostAi;
491
513
  private readonly hostEdge;
514
+ private readonly onlySteps;
492
515
  private readonly now;
493
516
  private readonly leaseTtlMs;
494
517
  private readonly appliedVersions;
@@ -140,9 +140,11 @@ export class FoundryPlanApply {
140
140
  zitadelIdTokenUserinfo;
141
141
  broker;
142
142
  edgeWorkers;
143
+ ownerInviteMail;
143
144
  hostFiles;
144
145
  hostAi;
145
146
  hostEdge;
147
+ onlySteps;
146
148
  now;
147
149
  leaseTtlMs;
148
150
  appliedVersions;
@@ -162,9 +164,11 @@ export class FoundryPlanApply {
162
164
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
163
165
  this.broker = options.broker ?? false;
164
166
  this.edgeWorkers = options.edgeWorkers ?? false;
167
+ this.ownerInviteMail = options.ownerInviteMail ?? false;
165
168
  this.hostFiles = options.filesOrganisation ?? false;
166
169
  this.hostAi = options.aiOrganisation ?? false;
167
170
  this.hostEdge = options.edge ?? false;
171
+ this.onlySteps = options.onlySteps;
168
172
  this.now = options.now ?? (() => new Date());
169
173
  this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
170
174
  this.appliedVersions = options.appliedVersions;
@@ -911,6 +915,7 @@ export class FoundryPlanApply {
911
915
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
912
916
  broker: this.broker,
913
917
  edgeWorkers: this.edgeWorkers,
918
+ ownerInviteMail: this.ownerInviteMail,
914
919
  otherSlugs,
915
920
  now: () => this.now().getTime(),
916
921
  archonUrl,
@@ -926,6 +931,11 @@ export class FoundryPlanApply {
926
931
  key: 'hosting:invalid',
927
932
  });
928
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
+ }
929
939
  return { org, plan: toPlan(detail), detail };
930
940
  }
931
941
  /**
@@ -1489,6 +1499,7 @@ function canonical(plan) {
1489
1499
  // version-change line must still be caught as stale, the same as any
1490
1500
  // other field here.
1491
1501
  versionChanges: [...(plan.versionChanges ?? [])].sort((x, y) => x.description.localeCompare(y.description)),
1502
+ notes: [...(plan.notes ?? [])].sort((x, y) => x.key.localeCompare(y.key)),
1492
1503
  };
1493
1504
  }
1494
1505
  /**
@@ -1,5 +1,5 @@
1
1
  import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
2
- import { type Plan, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
2
+ import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { PlannedStep } from './diff.js';
5
5
  import type { LiveResource } from './live.js';
@@ -16,6 +16,8 @@ export interface PlatformPlanDetail {
16
16
  unmanaged: UnmanagedItem[];
17
17
  /** #251: see {@link VersionChangeItem}. Never folded into `plannedSteps`. */
18
18
  versionChanges: VersionChangeItem[];
19
+ /** Advisory lines that block no step; see {@link PlanNote}. */
20
+ notes: PlanNote[];
19
21
  }
20
22
  /**
21
23
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -161,12 +163,11 @@ export declare const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = "archon_deployment";
161
163
  */
162
164
  export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_broker_app";
163
165
  /**
164
- * The `AUTHZ_BOOTSTRAP_OPERATOR` the last Archon apply set (`none` when it set
165
- * none). `planArchon` plans the step again when the owner's id recorded by
166
- * `platform:auth` differs from it, so Archon picks the owner up once auth
167
- * has recorded it.
166
+ * The sign-in org id the last Archon apply set as `AUTH_ORGANIZATION_ID` and
167
+ * registered its broker entry under. `planArchon` plans the step again when
168
+ * {@link archonAuthOrganizationId} differs from it.
168
169
  */
169
- export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applied_bootstrap_operator";
170
+ export declare const PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY = "archon_applied_auth_org";
170
171
  /**
171
172
  * #475: whether the last successful Archon apply patched the app with
172
173
  * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
@@ -183,11 +184,11 @@ export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applie
183
184
  */
184
185
  export declare const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = "archon_applied_rolling_update";
185
186
  /**
186
- * The owner's ZITADEL user id Archon takes as its first operator, from
187
- * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
188
- * recorded or the recorded value is `none` (no owner declared).
187
+ * The sign-in org Archon accepts: the platform owner company's own
188
+ * ({@link PLATFORM_OWNER_ORG_SCOPE_KEY}), else the platform's `foundry` org for
189
+ * a platform with no owner company declared. Undefined when neither is recorded.
189
190
  */
190
- export declare function archonBootstrapOperator(recorded: Readonly<Record<string, string>>): string | undefined;
191
+ export declare function archonAuthOrganizationId(recorded: Readonly<Record<string, string>>): string | undefined;
191
192
  /**
192
193
  * #175: set to '1' before the step points the tunnel at `http_status:404`
193
194
  * (Access app rebuild), cleared to '' once the real ingress is written back.
@@ -207,6 +208,26 @@ export declare const PLATFORM_AUTH_SMTP_SCOPE_KEY = "zitadel_platform_smtp_provi
207
208
  * requested, so the invite goes out once. `none` when no owner is declared.
208
209
  */
209
210
  export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_owner_invite_user_id";
211
+ /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
212
+ export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
213
+ /**
214
+ * The id of the platform owner company's own sign-in organisation, made by
215
+ * `platform:auth` with the same name and primary domain a company apply uses,
216
+ * so that apply finds this org and makes no second one. `none` when the owner
217
+ * company is not declared. Archon signs people in from this org; the
218
+ * `foundry` org ({@link PLATFORM_ZITADEL_ORG_SCOPE_KEY}) holds no people.
219
+ */
220
+ export declare const PLATFORM_OWNER_ORG_SCOPE_KEY = "zitadel_platform_owner_org";
221
+ /** The primary domain `platform:auth` registered on the owner company's sign-in org. */
222
+ export declare const PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY = "zitadel_platform_owner_org_domain";
223
+ /** The owner org's sign-in domain the sign-in server refused to make primary; set instead of failing, reported as blocked. */
224
+ export declare const PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY = "zitadel_platform_owner_org_domain_blocked";
225
+ /**
226
+ * The sign-in org the recorded owner invite ({@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY})
227
+ * was made in. An invite recorded against another org (the `foundry` org, on a
228
+ * platform applied before the owner moved) plans the auth step again.
229
+ */
230
+ export declare const PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY = "zitadel_platform_owner_invite_org";
210
231
  /**
211
232
  * #394: the id of the role-less ZITADEL machine user that reserves the
212
233
  * username `admin` in the platform's own org, so nobody can sign up under a
@@ -216,8 +237,6 @@ export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_ow
216
237
  export declare const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = "zitadel_platform_reserved_admin_username";
217
238
  /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
218
239
  export declare const PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_platform_registration_closed";
219
- /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
220
- export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
221
240
  export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
222
241
  export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
223
242
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -974,6 +993,8 @@ export interface PlanPlatformOptions {
974
993
  * these. Default `[]`.
975
994
  */
976
995
  authOtherInputsMissing?: string[];
996
+ /** C3: why the owner's invite cannot be made (no work address, no mail port). The rest of the step still plans. */
997
+ authOwnerInvite?: string;
977
998
  files?: string;
978
999
  integrations?: string;
979
1000
  ai?: string;
@@ -6,6 +6,7 @@ import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
6
6
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
7
7
  import { legacyAppHostname } from './hostnames.js';
8
8
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
9
+ import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, signInDomainBlockedText } from './sign-in-org.js';
9
10
  import { withStepSystem } from './step-system.js';
10
11
  /**
11
12
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -22,6 +23,7 @@ export function toPlatformPlan(detail, ownerOrgId) {
22
23
  steps: detail.plannedSteps.map((p) => p.step),
23
24
  unmanaged: detail.unmanaged,
24
25
  versionChanges: detail.versionChanges,
26
+ notes: detail.notes,
25
27
  };
26
28
  }
27
29
  function resourceKey(system, kind, key) {
@@ -195,12 +197,11 @@ export const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = 'archon_deployment';
195
197
  */
196
198
  export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_app';
197
199
  /**
198
- * The `AUTHZ_BOOTSTRAP_OPERATOR` the last Archon apply set (`none` when it set
199
- * none). `planArchon` plans the step again when the owner's id recorded by
200
- * `platform:auth` differs from it, so Archon picks the owner up once auth
201
- * has recorded it.
200
+ * The sign-in org id the last Archon apply set as `AUTH_ORGANIZATION_ID` and
201
+ * registered its broker entry under. `planArchon` plans the step again when
202
+ * {@link archonAuthOrganizationId} differs from it.
202
203
  */
203
- export const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = 'archon_applied_bootstrap_operator';
204
+ export const PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY = 'archon_applied_auth_org';
204
205
  /**
205
206
  * #475: whether the last successful Archon apply patched the app with
206
207
  * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
@@ -217,13 +218,15 @@ export const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = 'archon_applied_bootst
217
218
  */
218
219
  export const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = 'archon_applied_rolling_update';
219
220
  /**
220
- * The owner's ZITADEL user id Archon takes as its first operator, from
221
- * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
222
- * recorded or the recorded value is `none` (no owner declared).
221
+ * The sign-in org Archon accepts: the platform owner company's own
222
+ * ({@link PLATFORM_OWNER_ORG_SCOPE_KEY}), else the platform's `foundry` org for
223
+ * a platform with no owner company declared. Undefined when neither is recorded.
223
224
  */
224
- export function archonBootstrapOperator(recorded) {
225
- const id = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
226
- return id && id !== 'none' ? id : undefined;
225
+ export function archonAuthOrganizationId(recorded) {
226
+ const owner = recorded[PLATFORM_OWNER_ORG_SCOPE_KEY];
227
+ if (owner && owner !== 'none')
228
+ return owner;
229
+ return recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] || undefined;
227
230
  }
228
231
  /**
229
232
  * #175: set to '1' before the step points the tunnel at `http_status:404`
@@ -244,6 +247,26 @@ export const PLATFORM_AUTH_SMTP_SCOPE_KEY = 'zitadel_platform_smtp_provider_id';
244
247
  * requested, so the invite goes out once. `none` when no owner is declared.
245
248
  */
246
249
  export const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_owner_invite_user_id';
250
+ /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
251
+ export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
252
+ /**
253
+ * The id of the platform owner company's own sign-in organisation, made by
254
+ * `platform:auth` with the same name and primary domain a company apply uses,
255
+ * so that apply finds this org and makes no second one. `none` when the owner
256
+ * company is not declared. Archon signs people in from this org; the
257
+ * `foundry` org ({@link PLATFORM_ZITADEL_ORG_SCOPE_KEY}) holds no people.
258
+ */
259
+ export const PLATFORM_OWNER_ORG_SCOPE_KEY = 'zitadel_platform_owner_org';
260
+ /** The primary domain `platform:auth` registered on the owner company's sign-in org. */
261
+ export const PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY = 'zitadel_platform_owner_org_domain';
262
+ /** The owner org's sign-in domain the sign-in server refused to make primary; set instead of failing, reported as blocked. */
263
+ export const PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY = 'zitadel_platform_owner_org_domain_blocked';
264
+ /**
265
+ * The sign-in org the recorded owner invite ({@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY})
266
+ * was made in. An invite recorded against another org (the `foundry` org, on a
267
+ * platform applied before the owner moved) plans the auth step again.
268
+ */
269
+ export const PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY = 'zitadel_platform_owner_invite_org';
247
270
  /**
248
271
  * #394: the id of the role-less ZITADEL machine user that reserves the
249
272
  * username `admin` in the platform's own org, so nobody can sign up under a
@@ -253,8 +276,6 @@ export const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_owner_invi
253
276
  export const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = 'zitadel_platform_reserved_admin_username';
254
277
  /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
255
278
  export const PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_platform_registration_closed';
256
- /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
257
- export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
258
279
  export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
259
280
  export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
260
281
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -1138,7 +1159,7 @@ export function planPlatform(platform, live, options = {}) {
1138
1159
  // reconcile below can be planned in the *same* round hc first comes up,
1139
1160
  // the same way files/mail/archon are planned in auth's own first round.
1140
1161
  const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
1141
- const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
1162
+ const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite);
1142
1163
  if (auth.step)
1143
1164
  plannedSteps.push(auth.step);
1144
1165
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -1243,6 +1264,12 @@ export function planPlatform(platform, live, options = {}) {
1243
1264
  // A blocked jobs scheduler is reported here, not planned: a step that
1244
1265
  // throws every apply, or that can never be seen live and so re-plans
1245
1266
  // forever, is worse than none.
1267
+ if (!platform.ownerOrgId) {
1268
+ unmanaged.push({
1269
+ description: "platform.ownerOrgId is not declared, so no org is the platform's own: no peer password or bridge secret is minted for any Boule and Archon reads no people",
1270
+ key: 'owner-org',
1271
+ });
1272
+ }
1246
1273
  if (jobs.blocked)
1247
1274
  unmanaged.push({ description: jobs.blocked, key: 'jobs' });
1248
1275
  if (platform.sfu.enabled && sfuBlocked) {
@@ -1284,7 +1311,12 @@ export function planPlatform(platform, live, options = {}) {
1284
1311
  unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
1285
1312
  for (const description of uptime.blocked)
1286
1313
  unmanaged.push({ description, key: 'uptime' });
1287
- return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
1314
+ return {
1315
+ plannedSteps: plannedSteps.map(withStepSystem),
1316
+ unmanaged,
1317
+ versionChanges,
1318
+ notes: uptime.notes,
1319
+ };
1288
1320
  }
1289
1321
  /**
1290
1322
  * The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
@@ -1703,9 +1735,12 @@ function planSfu(platform, callsOrgSlugs, recordedIds) {
1703
1735
  * promising "this apply re-mints" while one of those is still missing would
1704
1736
  * be wrong the moment it is read.
1705
1737
  */
1706
- function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = []) {
1738
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked) {
1707
1739
  if (!platform.auth.enabled)
1708
1740
  return { step: null, blocked: null };
1741
+ // C3/S2: standing notes. The invite and the domain wait on a person; they hold nothing else back.
1742
+ const standing = [ownerInviteBlocked, ownerDomainBlockedText(recordedIds)].filter((t) => Boolean(t));
1743
+ const standingNote = standing.length > 0 ? standing.join('; ') : null;
1709
1744
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
1710
1745
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
1711
1746
  // #211: a recorded app and connector with no recorded broker id is a
@@ -1739,11 +1774,18 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1739
1774
  // Mail and the owner's invite: a platform applied before they existed
1740
1775
  // plans the step again, which only adds them.
1741
1776
  Boolean(recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY]) &&
1742
- Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) &&
1777
+ (Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) || Boolean(ownerInviteBlocked)) &&
1778
+ // The owner company's own sign-in org, and an invite made in it: a
1779
+ // platform whose owner was invited into the `foundry` org plans the step again.
1780
+ Boolean(recordedIds[PLATFORM_OWNER_ORG_SCOPE_KEY]) &&
1781
+ (Boolean(ownerInviteBlocked) ||
1782
+ recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY] === 'none' ||
1783
+ recordedIds[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY] ===
1784
+ recordedIds[PLATFORM_OWNER_ORG_SCOPE_KEY]) &&
1743
1785
  // The ZITADEL run options reach the container only through the auth
1744
1786
  // step's redeploy, so a platform recorded before them still plans it.
1745
1787
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
1746
- return { step: null, blocked: null };
1788
+ return { step: null, blocked: standingNote };
1747
1789
  }
1748
1790
  // The admin key was minted but never reached the host. ZITADEL shows a
1749
1791
  // token once, so it is lost: minting another would hide that, and skipping
@@ -1814,6 +1856,7 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1814
1856
  // 5: and only claim "this apply re-mints" when `otherInputsMissing` is
1815
1857
  // actually empty -- see this function's own doc comment.
1816
1858
  blocked: executorBlocked ??
1859
+ standingNote ??
1817
1860
  loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing),
1818
1861
  };
1819
1862
  }
@@ -2487,9 +2530,9 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2487
2530
  * destructive step removes whatever of it is recorded (edge removes its KV
2488
2531
  * namespace with the script).
2489
2532
  *
2490
- * The returned `blocked` lines are reported in `unmanaged`; some accompany a
2491
- * planned step (a secret whose source does not exist yet), because the step
2492
- * still delivers everything that exists.
2533
+ * The returned `blocked` lines are reported in `unmanaged`. A secret whose
2534
+ * source does not exist yet blocks nothing: the step still delivers
2535
+ * everything that exists, so it comes back in `notes`.
2493
2536
  */
2494
2537
  function planUptime(platform, recordedIds, executorBlocked, heldOptional, archonApplied, uptimeHeld = {
2495
2538
  token: true,
@@ -2507,9 +2550,9 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2507
2550
  });
2508
2551
  if (!uptime.enabled) {
2509
2552
  if (!uptimeRecorded(recordedIds))
2510
- return { step: null, blocked: [] };
2553
+ return { step: null, blocked: [], notes: [] };
2511
2554
  if (executorBlocked)
2512
- return { step: null, blocked: [executorBlocked] };
2555
+ return { step: null, blocked: [executorBlocked], notes: [] };
2513
2556
  return {
2514
2557
  step: {
2515
2558
  action: action({ remove: true }),
@@ -2520,6 +2563,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2520
2563
  },
2521
2564
  },
2522
2565
  blocked: [],
2566
+ notes: [],
2523
2567
  };
2524
2568
  }
2525
2569
  if (!uptime.tag || !uptime.sha256) {
@@ -2528,6 +2572,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2528
2572
  blocked: [
2529
2573
  'the uptime Worker is blocked, not planned: no artifact is pinned; declare uptime.tag (an Archon release tag) and uptime.sha256 (the digest of archon-uptime-worker.js at that tag)',
2530
2574
  ],
2575
+ notes: [],
2531
2576
  };
2532
2577
  }
2533
2578
  if (!archonApplied) {
@@ -2536,16 +2581,25 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2536
2581
  blocked: [
2537
2582
  'the uptime Worker is blocked, not planned: platform:archon has not been applied yet',
2538
2583
  ],
2584
+ notes: [],
2539
2585
  };
2540
2586
  }
2541
2587
  if (executorBlocked)
2542
- return { step: null, blocked: [executorBlocked] };
2543
- const missing = [];
2588
+ return { step: null, blocked: [executorBlocked], notes: [] };
2589
+ const notes = [];
2544
2590
  if (!heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2545
- missing.push(`the uptime Worker has no chat credential (CHAT_ACCESS_TOKEN): hold one under ${PLATFORM_UPTIME_CHAT_TOKEN_REF} and it is set on the next apply; until then it sends no chat alerts`);
2591
+ notes.push({
2592
+ key: 'uptime:CHAT_ACCESS_TOKEN',
2593
+ service: 'uptime',
2594
+ description: `the uptime Worker has no chat credential (CHAT_ACCESS_TOKEN): hold one under ${PLATFORM_UPTIME_CHAT_TOKEN_REF} and it is set on the next apply; until then it sends no chat alerts`,
2595
+ });
2546
2596
  }
2547
2597
  if (!heldOptional.includes('MAIL_API_KEY')) {
2548
- missing.push(`the uptime Worker has no mail API key (MAIL_API_KEY): hold one under ${PLATFORM_UPTIME_MAIL_KEY_REF} and it is set on the next apply; until then it sends no alert email`);
2598
+ notes.push({
2599
+ key: 'uptime:MAIL_API_KEY',
2600
+ service: 'uptime',
2601
+ description: `the uptime Worker has no mail API key (MAIL_API_KEY): hold one under ${PLATFORM_UPTIME_MAIL_KEY_REF} and it is set on the next apply; until then it sends no alert email`,
2602
+ });
2549
2603
  }
2550
2604
  const hostname = platform.archon.hostname;
2551
2605
  // Applied only once a run got to its end and recorded these: a Worker that
@@ -2561,7 +2615,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2561
2615
  (recordedIds[PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY] ?? '') === uptimeHeld.vars &&
2562
2616
  recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional);
2563
2617
  if (applied)
2564
- return { step: null, blocked: missing };
2618
+ return { step: null, blocked: [], notes };
2565
2619
  return {
2566
2620
  step: {
2567
2621
  action: action({ hostname, gitRef: uptime.tag, sha256: uptime.sha256 }),
@@ -2571,7 +2625,8 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2571
2625
  migrates: false,
2572
2626
  },
2573
2627
  },
2574
- blocked: missing,
2628
+ blocked: [],
2629
+ notes,
2575
2630
  };
2576
2631
  }
2577
2632
  /**
@@ -2709,8 +2764,8 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2709
2764
  Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
2710
2765
  recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
2711
2766
  recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
2712
- (recordedIds[PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY] ?? 'none') ===
2713
- (archonBootstrapOperator(recordedIds) ?? 'none') &&
2767
+ recordedIds[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY] ===
2768
+ archonAuthOrganizationId(recordedIds) &&
2714
2769
  // #475: a platform applied before the rolling-update fix keeps planning
2715
2770
  // this step until a confirmed redeploy has cleared it. #480: this marker
2716
2771
  // alone does not catch the live app's settings changing back afterward
@@ -2775,3 +2830,12 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2775
2830
  blocked: null,
2776
2831
  };
2777
2832
  }
2833
+ /** The blocked text for a refused owner-org sign-in domain, while it is recorded and not yet primary. */
2834
+ function ownerDomainBlockedText(recordedIds) {
2835
+ const blockedDomain = recordedIds[PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY];
2836
+ if (!blockedDomain)
2837
+ return null;
2838
+ if (recordedIds[PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY] === blockedDomain)
2839
+ return null;
2840
+ return signInDomainBlockedText('the platform owner', blockedDomain).replace(ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY);
2841
+ }
@@ -61,6 +61,8 @@ export interface OrgPlatformReadiness {
61
61
  * while `MAIL_DOMAIN_SCOPE_KEY` was still recorded as the apex.
62
62
  */
63
63
  platformApex: string | null;
64
+ /** `DeclaredPlatform.ownerOrgId`, or `null` when no platform is declared or it is empty. The one fact "this is the platform's own org" rests on. */
65
+ platformOwnerOrgId: string | null;
64
66
  /** `DeclaredPlatform.mail.lettermintProjectId`, `null` when undeclared or when no platform is declared. See {@link platformApex}'s own doc comment for why this is unconditional too. */
65
67
  platformLettermintProjectId: string | null;
66
68
  /** The files host (#171). Files is always on for every org (#80), so `false` always blocks rather than only mattering when a flag is set. */
@@ -27,6 +27,7 @@ export const NO_DOMAIN_MAIL_APEX = 'oikos.host';
27
27
  export const DEFAULT_ORG_PLATFORM_READINESS = {
28
28
  mailHost: null,
29
29
  platformApex: null,
30
+ platformOwnerOrgId: null,
30
31
  platformLettermintProjectId: null,
31
32
  filesReady: false,
32
33
  aiReady: false,
@@ -124,6 +125,7 @@ edgePortPresent = false) {
124
125
  // mail.lettermintProjectId are facts regardless of whether mail itself is
125
126
  // enabled or applied.
126
127
  platformApex: platform.apex,
128
+ platformOwnerOrgId: platform.ownerOrgId || null,
127
129
  platformLettermintProjectId: platform.mail.lettermintProjectId ?? null,
128
130
  filesReady: filesApplied && missingKeys.files === null,
129
131
  filesUrl: filesApplied
@@ -1,5 +1,5 @@
1
1
  import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
- import type { DeclaredOrg } from '../org.js';
2
+ import { type DeclaredOrg } from '../org.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { DeclaredOrgStore, PlatformStore } from '../store.js';
5
5
  import { type DeployHealthRuntime } from './deploy-health.js';
@@ -11,6 +11,7 @@ import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort
11
11
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
12
12
  import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
13
13
  import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platform-diff.js';
14
+ export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
14
15
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
15
16
  import { type PlatformUptimeDeps } from './uptime-worker.js';
16
17
  /**
@@ -90,7 +91,14 @@ export interface PlatformAuthDeps {
90
91
  tunnels: PlatformTunnelsFactory;
91
92
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
92
93
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
93
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'inviteOrgOwner'> & PlatformZitadelSmtp;
94
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'inviteOrgOwner' | 'addOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp;
95
+ /**
96
+ * Mails the owner's first-sign-in link to their personal address (the same
97
+ * port as `FoundryStepExecutorPorts.ownerInviteMail`). The link carries the
98
+ * invite code, so `send` must not log it or put it in an error. Without it
99
+ * the owner invite cannot run and the step stops there with a fixed message.
100
+ */
101
+ ownerInviteMail?: FoundryStepExecutorPorts['ownerInviteMail'];
94
102
  /**
95
103
  * Builds the ZITADEL port for the new instance from its issuer URL
96
104
  * (`https://<auth.hostname>`). When set it is used instead of
@@ -315,6 +323,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
315
323
  auth?: string;
316
324
  authExecutorMissing?: boolean;
317
325
  authOtherInputsMissing?: string[];
326
+ authOwnerInvite?: string;
318
327
  files?: string;
319
328
  integrations?: string;
320
329
  ai?: string;
@@ -520,12 +529,51 @@ export interface PlatformMailRuntime {
520
529
  /** Tries at handing the admin key to the host (default 3). */
521
530
  deliverAttempts?: number;
522
531
  }
532
+ /**
533
+ * #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
534
+ * `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
535
+ * skips deploying the broker while any is missing
536
+ * ({@link missingBrokerInputs}); this is the same check surfaced through
537
+ * `blockedSteps`, so a plan says why, by name, rather than the broker simply
538
+ * sitting undeployed with no explanation. `null` while there is no broker
539
+ * app id recorded yet (nothing to check) or every input is present.
540
+ *
541
+ * #211 round 3: wraps the live read the way {@link mailBlockedReason} wraps
542
+ * its own Coolify call -- a transient `listEnvs` failure must report a
543
+ * blocked reason naming the broker app id, never throw out of
544
+ * `blockedSteps` and take the rest of the plan down with it.
545
+ *
546
+ * #211 round 5 addendum: when `AUTH_APPS` is the reason, names which problem
547
+ * it actually is ({@link authAppsProblem}) instead of always saying "it has
548
+ * no ... AUTH_APPS yet" -- that wording is only true when nothing has been
549
+ * set; a present-but-invalid value is a different failure (a person needs to
550
+ * go fix an entry, not merely wait for one of #209/#210/Archon's own
551
+ * registration to supply one), and reporting it identically hid which one
552
+ * was true.
553
+ *
554
+ * #210: `LETTERMINT_API_TOKEN` joins `ZITADEL_SERVICE_USER_TOKEN` in being
555
+ * excluded from `otherInputsMissing` -- but only while `auth.heldKeys`
556
+ * actually resolves {@link PLATFORM_AUTH_BROKER_MAIL_KEY_REF} right now.
557
+ * `planAuth`'s own "loop fix" stops planning this step the moment every
558
+ * `otherInputsMissing` entry looks like something only another run can
559
+ * supply; excluding `LETTERMINT_API_TOKEN` unconditionally (the way
560
+ * `ZITADEL_SERVICE_USER_TOKEN` is, since foundry can always eventually mint
561
+ * that one) would keep the step planned, and `provisionPlatformAuth`
562
+ * running its full body, forever on a host that never wires `heldKeys` or
563
+ * whose operator has not put a Lettermint key there yet -- exactly the
564
+ * wasteful-replan bug #211 round 5 fixed for the broker deploy itself. Once
565
+ * the key *is* held, excluding it lets this run's `provisionPlatformAuth`
566
+ * push it (see that function's own `heldMailKey` read) and clears the gap
567
+ * for good.
568
+ */
569
+ /** Why the platform owner cannot be invited yet, or `null`. Fixed text: names no address. */
570
+ export declare function ownerInviteBlockedReason(ownerCompany: DeclaredOrg | null, hasInviteMail: boolean): string | null;
523
571
  /**
524
572
  * #175: everything {@link provisionPlatformArchon} needs beyond
525
573
  * {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
526
574
  */
527
575
  export interface PlatformArchonDeps {
528
- coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment'>;
576
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'listEnvironments' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'deleteEnv' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'getDeployment'>;
529
577
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
530
578
  dns: PlatformDnsFactory;
531
579
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -604,7 +652,6 @@ export interface PlatformArchonRuntime {
604
652
  * org (`foundry:<orgId>`), with the platform's step key in the org id's place.
605
653
  */
606
654
  export declare const PLATFORM_ARCHON_ACCESS_APP_NAME = "foundry:platform:archon";
607
- export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
608
655
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
609
656
  export declare const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = "zitadel_platform_archon_client_id";
610
657
  /**