@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,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
@@ -972,6 +993,8 @@ export interface PlanPlatformOptions {
972
993
  * these. Default `[]`.
973
994
  */
974
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;
975
998
  files?: string;
976
999
  integrations?: string;
977
1000
  ai?: string;
@@ -1038,6 +1061,12 @@ export interface PlanPlatformOptions {
1038
1061
  * repair step is not planned.
1039
1062
  */
1040
1063
  archonIdTokenUserinfo?: boolean;
1064
+ /**
1065
+ * The executor has the platform's sign-in dependencies and a ZITADEL
1066
+ * client for its instance. Not a block: without it the sign-in policy
1067
+ * step is not planned.
1068
+ */
1069
+ authSignInPolicy?: boolean;
1041
1070
  };
1042
1071
  /**
1043
1072
  * Steps whose live state differs from the declared one in a way the recorded
@@ -2,9 +2,11 @@ import { createHash } from 'node:crypto';
2
2
  import { VERSIONS } from '../bootstrap/versions.js';
3
3
  import { APP_ENTRY_KEY } from '../org.js';
4
4
  import { VERSION_CHANGE_KIND, } from '../plan.js';
5
+ import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
5
6
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
6
7
  import { legacyAppHostname } from './hostnames.js';
7
8
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
9
+ import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, signInDomainBlockedText } from './sign-in-org.js';
8
10
  import { withStepSystem } from './step-system.js';
9
11
  /**
10
12
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -21,6 +23,7 @@ export function toPlatformPlan(detail, ownerOrgId) {
21
23
  steps: detail.plannedSteps.map((p) => p.step),
22
24
  unmanaged: detail.unmanaged,
23
25
  versionChanges: detail.versionChanges,
26
+ notes: detail.notes,
24
27
  };
25
28
  }
26
29
  function resourceKey(system, kind, key) {
@@ -78,6 +81,9 @@ export function versionChangeLine(service, from, to, note) {
78
81
  kind: VERSION_CHANGE_KIND,
79
82
  service,
80
83
  description: `${service}: ${from} -> ${to} (${note})`,
84
+ from,
85
+ to,
86
+ needsConfirmation: versionChangeNeedsConfirmation(service, from, to),
81
87
  };
82
88
  }
83
89
  /**
@@ -99,6 +105,8 @@ export function versionNotAppliedLine(service, appLive, applied, pinned) {
99
105
  kind: VERSION_CHANGE_KIND,
100
106
  service,
101
107
  description: `${service}: pinned ${pinned}, running tag unknown`,
108
+ to: pinned,
109
+ needsConfirmation: false,
102
110
  };
103
111
  }
104
112
  if (applied === pinned)
@@ -107,6 +115,10 @@ export function versionNotAppliedLine(service, appLive, applied, pinned) {
107
115
  kind: VERSION_CHANGE_KIND,
108
116
  service,
109
117
  description: `${service}: pinned ${pinned}, running ${applied} (not applied by foundry)`,
118
+ from: applied,
119
+ to: pinned,
120
+ // #254: no apply acts on this line, so there is no upgrade to confirm.
121
+ needsConfirmation: false,
110
122
  };
111
123
  }
112
124
  /**
@@ -185,12 +197,11 @@ export const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = 'archon_deployment';
185
197
  */
186
198
  export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_app';
187
199
  /**
188
- * The `AUTHZ_BOOTSTRAP_OPERATOR` the last Archon apply set (`none` when it set
189
- * none). `planArchon` plans the step again when the owner's id recorded by
190
- * `platform:auth` differs from it, so Archon picks the owner up once auth
191
- * 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.
192
203
  */
193
- 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';
194
205
  /**
195
206
  * #475: whether the last successful Archon apply patched the app with
196
207
  * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
@@ -207,13 +218,15 @@ export const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = 'archon_applied_bootst
207
218
  */
208
219
  export const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = 'archon_applied_rolling_update';
209
220
  /**
210
- * The owner's ZITADEL user id Archon takes as its first operator, from
211
- * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
212
- * 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.
213
224
  */
214
- export function archonBootstrapOperator(recorded) {
215
- const id = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
216
- 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;
217
230
  }
218
231
  /**
219
232
  * #175: set to '1' before the step points the tunnel at `http_status:404`
@@ -234,6 +247,26 @@ export const PLATFORM_AUTH_SMTP_SCOPE_KEY = 'zitadel_platform_smtp_provider_id';
234
247
  * requested, so the invite goes out once. `none` when no owner is declared.
235
248
  */
236
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';
237
270
  /**
238
271
  * #394: the id of the role-less ZITADEL machine user that reserves the
239
272
  * username `admin` in the platform's own org, so nobody can sign up under a
@@ -1126,7 +1159,7 @@ export function planPlatform(platform, live, options = {}) {
1126
1159
  // reconcile below can be planned in the *same* round hc first comes up,
1127
1160
  // the same way files/mail/archon are planned in auth's own first round.
1128
1161
  const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
1129
- 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);
1130
1163
  if (auth.step)
1131
1164
  plannedSteps.push(auth.step);
1132
1165
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -1183,6 +1216,10 @@ export function planPlatform(platform, live, options = {}) {
1183
1216
  const idTokenUserinfo = planArchonIdTokenUserinfo(platform, recordedIds, blocked.archonIdTokenUserinfo ?? false);
1184
1217
  if (idTokenUserinfo)
1185
1218
  plannedSteps.push(idTokenUserinfo);
1219
+ // #669: after the ID token repair too, which is proven; a policy call that fails every time holds up nothing.
1220
+ const signInPolicy = planAuthSignInPolicy(platform, recordedIds, blocked.authSignInPolicy ?? false);
1221
+ if (signInPolicy)
1222
+ plannedSteps.push(signInPolicy);
1186
1223
  // #251: one line per service whose recorded applied ref no longer matches
1187
1224
  // what the next apply would use (or, for zitadel, what is actually live).
1188
1225
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -1227,6 +1264,12 @@ export function planPlatform(platform, live, options = {}) {
1227
1264
  // A blocked jobs scheduler is reported here, not planned: a step that
1228
1265
  // throws every apply, or that can never be seen live and so re-plans
1229
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
+ }
1230
1273
  if (jobs.blocked)
1231
1274
  unmanaged.push({ description: jobs.blocked, key: 'jobs' });
1232
1275
  if (platform.sfu.enabled && sfuBlocked) {
@@ -1268,7 +1311,12 @@ export function planPlatform(platform, live, options = {}) {
1268
1311
  unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
1269
1312
  for (const description of uptime.blocked)
1270
1313
  unmanaged.push({ description, key: 'uptime' });
1271
- return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
1314
+ return {
1315
+ plannedSteps: plannedSteps.map(withStepSystem),
1316
+ unmanaged,
1317
+ versionChanges,
1318
+ notes: uptime.notes,
1319
+ };
1272
1320
  }
1273
1321
  /**
1274
1322
  * The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
@@ -1687,9 +1735,12 @@ function planSfu(platform, callsOrgSlugs, recordedIds) {
1687
1735
  * promising "this apply re-mints" while one of those is still missing would
1688
1736
  * be wrong the moment it is read.
1689
1737
  */
1690
- function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = []) {
1738
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked) {
1691
1739
  if (!platform.auth.enabled)
1692
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;
1693
1744
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
1694
1745
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
1695
1746
  // #211: a recorded app and connector with no recorded broker id is a
@@ -1723,14 +1774,18 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1723
1774
  // Mail and the owner's invite: a platform applied before they existed
1724
1775
  // plans the step again, which only adds them.
1725
1776
  Boolean(recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY]) &&
1726
- 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]) &&
1727
1785
  // The ZITADEL run options reach the container only through the auth
1728
1786
  // step's redeploy, so a platform recorded before them still plans it.
1729
- recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
1730
- // #394: a platform applied before these existed plans the step again.
1731
- Boolean(recordedIds[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) &&
1732
- Boolean(recordedIds[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY])) {
1733
- return { step: null, blocked: null };
1787
+ recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
1788
+ return { step: null, blocked: standingNote };
1734
1789
  }
1735
1790
  // The admin key was minted but never reached the host. ZITADEL shows a
1736
1791
  // token once, so it is lost: minting another would hide that, and skipping
@@ -1801,6 +1856,7 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1801
1856
  // 5: and only claim "this apply re-mints" when `otherInputsMissing` is
1802
1857
  // actually empty -- see this function's own doc comment.
1803
1858
  blocked: executorBlocked ??
1859
+ standingNote ??
1804
1860
  loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing),
1805
1861
  };
1806
1862
  }
@@ -2249,6 +2305,31 @@ function planArchonIdTokenUserinfo(platform, recordedIds, wired) {
2249
2305
  },
2250
2306
  };
2251
2307
  }
2308
+ /**
2309
+ * #394, #669: the platform's sign-in org reserves the username `admin` and
2310
+ * switches self-registration off. One `zitadel:sign-in-policy` step keyed
2311
+ * {@link PLATFORM_AUTH_KEY}, planned while either recorded key is missing and
2312
+ * the sign-in's ZITADEL org is recorded. Not planned before that, and not
2313
+ * blocked: nothing is owed until the sign-in exists.
2314
+ */
2315
+ function planAuthSignInPolicy(platform, recordedIds, wired) {
2316
+ if (!wired || !platform.auth.enabled)
2317
+ return null;
2318
+ if (!recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY])
2319
+ return null;
2320
+ if (recordedIds[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY] &&
2321
+ recordedIds[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
2322
+ return null;
2323
+ }
2324
+ return {
2325
+ action: { system: 'zitadel', kind: 'sign-in-policy', key: PLATFORM_AUTH_KEY, desired: {} },
2326
+ step: {
2327
+ description: "reserve the username 'admin' and switch self-registration off on the platform's sign-in org",
2328
+ destroys: false,
2329
+ migrates: false,
2330
+ },
2331
+ };
2332
+ }
2252
2333
  /**
2253
2334
  * #604: the platform's ZITADEL as a sign-in provider in Cloudflare Access: a
2254
2335
  * ZITADEL client for Cloudflare, then edge's `ensureProvider`. One
@@ -2449,9 +2530,9 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2449
2530
  * destructive step removes whatever of it is recorded (edge removes its KV
2450
2531
  * namespace with the script).
2451
2532
  *
2452
- * The returned `blocked` lines are reported in `unmanaged`; some accompany a
2453
- * planned step (a secret whose source does not exist yet), because the step
2454
- * 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`.
2455
2536
  */
2456
2537
  function planUptime(platform, recordedIds, executorBlocked, heldOptional, archonApplied, uptimeHeld = {
2457
2538
  token: true,
@@ -2469,9 +2550,9 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2469
2550
  });
2470
2551
  if (!uptime.enabled) {
2471
2552
  if (!uptimeRecorded(recordedIds))
2472
- return { step: null, blocked: [] };
2553
+ return { step: null, blocked: [], notes: [] };
2473
2554
  if (executorBlocked)
2474
- return { step: null, blocked: [executorBlocked] };
2555
+ return { step: null, blocked: [executorBlocked], notes: [] };
2475
2556
  return {
2476
2557
  step: {
2477
2558
  action: action({ remove: true }),
@@ -2482,6 +2563,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2482
2563
  },
2483
2564
  },
2484
2565
  blocked: [],
2566
+ notes: [],
2485
2567
  };
2486
2568
  }
2487
2569
  if (!uptime.tag || !uptime.sha256) {
@@ -2490,6 +2572,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2490
2572
  blocked: [
2491
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)',
2492
2574
  ],
2575
+ notes: [],
2493
2576
  };
2494
2577
  }
2495
2578
  if (!archonApplied) {
@@ -2498,16 +2581,25 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2498
2581
  blocked: [
2499
2582
  'the uptime Worker is blocked, not planned: platform:archon has not been applied yet',
2500
2583
  ],
2584
+ notes: [],
2501
2585
  };
2502
2586
  }
2503
2587
  if (executorBlocked)
2504
- return { step: null, blocked: [executorBlocked] };
2505
- const missing = [];
2588
+ return { step: null, blocked: [executorBlocked], notes: [] };
2589
+ const notes = [];
2506
2590
  if (!heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2507
- 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
+ });
2508
2596
  }
2509
2597
  if (!heldOptional.includes('MAIL_API_KEY')) {
2510
- 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
+ });
2511
2603
  }
2512
2604
  const hostname = platform.archon.hostname;
2513
2605
  // Applied only once a run got to its end and recorded these: a Worker that
@@ -2523,7 +2615,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2523
2615
  (recordedIds[PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY] ?? '') === uptimeHeld.vars &&
2524
2616
  recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional);
2525
2617
  if (applied)
2526
- return { step: null, blocked: missing };
2618
+ return { step: null, blocked: [], notes };
2527
2619
  return {
2528
2620
  step: {
2529
2621
  action: action({ hostname, gitRef: uptime.tag, sha256: uptime.sha256 }),
@@ -2533,7 +2625,8 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2533
2625
  migrates: false,
2534
2626
  },
2535
2627
  },
2536
- blocked: missing,
2628
+ blocked: [],
2629
+ notes,
2537
2630
  };
2538
2631
  }
2539
2632
  /**
@@ -2671,8 +2764,8 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2671
2764
  Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
2672
2765
  recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
2673
2766
  recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
2674
- (recordedIds[PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY] ?? 'none') ===
2675
- (archonBootstrapOperator(recordedIds) ?? 'none') &&
2767
+ recordedIds[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY] ===
2768
+ archonAuthOrganizationId(recordedIds) &&
2676
2769
  // #475: a platform applied before the rolling-update fix keeps planning
2677
2770
  // this step until a confirmed redeploy has cleared it. #480: this marker
2678
2771
  // alone does not catch the live app's settings changing back afterward
@@ -2737,3 +2830,12 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2737
2830
  blocked: null,
2738
2831
  };
2739
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';
@@ -10,7 +10,8 @@ import type { TunnelPort } from './edge-tunnels.js';
10
10
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
11
11
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
12
12
  import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
13
- import { type BrokerDeployRecord } from './platform-diff.js';
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;
@@ -336,6 +345,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
336
345
  archonRunsInsideApp?: boolean;
337
346
  archonDeployRunning?: string;
338
347
  archonIdTokenUserinfo?: boolean;
348
+ authSignInPolicy?: boolean;
339
349
  }>;
340
350
  /**
341
351
  * foundry#542: why the uptime Worker cannot run yet, which optional secrets
@@ -519,12 +529,51 @@ export interface PlatformMailRuntime {
519
529
  /** Tries at handing the admin key to the host (default 3). */
520
530
  deliverAttempts?: number;
521
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;
522
571
  /**
523
572
  * #175: everything {@link provisionPlatformArchon} needs beyond
524
573
  * {@link PlatformStepExecutionContext}, bundled like {@link PlatformAuthDeps}.
525
574
  */
526
575
  export interface PlatformArchonDeps {
527
- 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'>;
528
577
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
529
578
  dns: PlatformDnsFactory;
530
579
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -603,8 +652,6 @@ export interface PlatformArchonRuntime {
603
652
  * org (`foundry:<orgId>`), with the platform's step key in the org id's place.
604
653
  */
605
654
  export declare const PLATFORM_ARCHON_ACCESS_APP_NAME = "foundry:platform:archon";
606
- /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
607
- export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
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
  /**
@@ -1246,4 +1293,3 @@ export declare function filesPublicHostsValue(publicHosts: string | undefined, s
1246
1293
  * from the declared orgs, for its apply and its drift check alike.
1247
1294
  */
1248
1295
  export declare function filesShareDomains(orgs: readonly DeclaredOrg[]): string[];
1249
- export {};