@wtfalch/foundry 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +18 -1
  2. package/dist/bootstrap/bootstrap.js +167 -28
  3. package/dist/bootstrap/declare.d.ts +5 -1
  4. package/dist/bootstrap/declare.js +3 -1
  5. package/dist/bootstrap/index.d.ts +1 -1
  6. package/dist/bootstrap/versions.d.ts +4 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +41 -0
  9. package/dist/index.d.ts +18 -8
  10. package/dist/index.js +8 -3
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/org.d.ts +24 -2
  14. package/dist/org.js +42 -0
  15. package/dist/plan/boule.d.ts +24 -1
  16. package/dist/plan/boule.js +29 -1
  17. package/dist/plan/calls-matrix.d.ts +9 -0
  18. package/dist/plan/calls-matrix.js +17 -0
  19. package/dist/plan/cloudflared-connector.d.ts +7 -1
  20. package/dist/plan/cloudflared-connector.js +14 -1
  21. package/dist/plan/diff.d.ts +32 -1
  22. package/dist/plan/diff.js +383 -26
  23. package/dist/plan/executor.d.ts +177 -7
  24. package/dist/plan/executor.js +738 -51
  25. package/dist/plan/health-check-settings.d.ts +28 -0
  26. package/dist/plan/health-check-settings.js +36 -0
  27. package/dist/plan/health-gaps.d.ts +33 -0
  28. package/dist/plan/health-gaps.js +89 -0
  29. package/dist/plan/health-targets.js +1 -1
  30. package/dist/plan/held-key-refs.d.ts +10 -0
  31. package/dist/plan/held-key-refs.js +10 -0
  32. package/dist/plan/index.d.ts +16 -6
  33. package/dist/plan/index.js +8 -3
  34. package/dist/plan/live.d.ts +5 -2
  35. package/dist/plan/live.js +15 -2
  36. package/dist/plan/locked-hostnames.d.ts +5 -2
  37. package/dist/plan/locked-hostnames.js +9 -1
  38. package/dist/plan/login-host.d.ts +62 -2
  39. package/dist/plan/login-host.js +104 -1
  40. package/dist/plan/mail-relay.d.ts +120 -0
  41. package/dist/plan/mail-relay.js +323 -0
  42. package/dist/plan/org-names.d.ts +24 -0
  43. package/dist/plan/org-names.js +52 -0
  44. package/dist/plan/own-diff.d.ts +169 -0
  45. package/dist/plan/own-diff.js +432 -0
  46. package/dist/plan/own-dns.d.ts +6 -0
  47. package/dist/plan/own-dns.js +15 -0
  48. package/dist/plan/own-limits.d.ts +22 -0
  49. package/dist/plan/own-limits.js +101 -0
  50. package/dist/plan/own-services.d.ts +193 -0
  51. package/dist/plan/own-services.js +167 -0
  52. package/dist/plan/own-sfu.d.ts +69 -0
  53. package/dist/plan/own-sfu.js +255 -0
  54. package/dist/plan/plan-apply.d.ts +101 -7
  55. package/dist/plan/plan-apply.js +216 -12
  56. package/dist/plan/platform-diff.d.ts +61 -12
  57. package/dist/plan/platform-diff.js +140 -34
  58. package/dist/plan/platform-org.d.ts +36 -0
  59. package/dist/plan/platform-org.js +96 -2
  60. package/dist/plan/platform-providers.d.ts +407 -12
  61. package/dist/plan/platform-providers.js +947 -422
  62. package/dist/plan/sign-in-org.d.ts +99 -0
  63. package/dist/plan/sign-in-org.js +151 -0
  64. package/dist/plan/zitadel-login-policy.js +1 -1
  65. package/dist/plan/zitadel-system-api.d.ts +22 -1
  66. package/dist/plan/zitadel-system-api.js +49 -8
  67. package/dist/plan.d.ts +18 -0
  68. package/dist/platform.d.ts +8 -0
  69. package/dist/slug.js +1 -0
  70. package/dist/store/postgres.js +51 -2
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +5 -0
  73. package/package.json +1 -1
  74. package/versions.json +3 -1
@@ -5,7 +5,9 @@ import { VERSION_CHANGE_KIND, } from '../plan.js';
5
5
  import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
6
6
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
7
7
  import { legacyAppHostname } from './hostnames.js';
8
+ import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
8
9
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
10
+ import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, signInDomainBlockedText } from './sign-in-org.js';
9
11
  import { withStepSystem } from './step-system.js';
10
12
  /**
11
13
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -22,6 +24,7 @@ export function toPlatformPlan(detail, ownerOrgId) {
22
24
  steps: detail.plannedSteps.map((p) => p.step),
23
25
  unmanaged: detail.unmanaged,
24
26
  versionChanges: detail.versionChanges,
27
+ notes: detail.notes,
25
28
  };
26
29
  }
27
30
  function resourceKey(system, kind, key) {
@@ -195,12 +198,11 @@ export const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = 'archon_deployment';
195
198
  */
196
199
  export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_app';
197
200
  /**
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.
201
+ * The sign-in org id the last Archon apply set as `AUTH_ORGANIZATION_ID` and
202
+ * registered its broker entry under. `planArchon` plans the step again when
203
+ * {@link archonAuthOrganizationId} differs from it.
202
204
  */
203
- export const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = 'archon_applied_bootstrap_operator';
205
+ export const PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY = 'archon_applied_auth_org';
204
206
  /**
205
207
  * #475: whether the last successful Archon apply patched the app with
206
208
  * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
@@ -217,13 +219,15 @@ export const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = 'archon_applied_bootst
217
219
  */
218
220
  export const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = 'archon_applied_rolling_update';
219
221
  /**
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).
222
+ * The sign-in org Archon accepts: the platform owner company's own
223
+ * ({@link PLATFORM_OWNER_ORG_SCOPE_KEY}), else the platform's `foundry` org for
224
+ * a platform with no owner company declared. Undefined when neither is recorded.
223
225
  */
224
- export function archonBootstrapOperator(recorded) {
225
- const id = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
226
- return id && id !== 'none' ? id : undefined;
226
+ export function archonAuthOrganizationId(recorded) {
227
+ const owner = recorded[PLATFORM_OWNER_ORG_SCOPE_KEY];
228
+ if (owner && owner !== 'none')
229
+ return owner;
230
+ return recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] || undefined;
227
231
  }
228
232
  /**
229
233
  * #175: set to '1' before the step points the tunnel at `http_status:404`
@@ -244,6 +248,26 @@ export const PLATFORM_AUTH_SMTP_SCOPE_KEY = 'zitadel_platform_smtp_provider_id';
244
248
  * requested, so the invite goes out once. `none` when no owner is declared.
245
249
  */
246
250
  export const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_owner_invite_user_id';
251
+ /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
252
+ export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
253
+ /**
254
+ * The id of the platform owner company's own sign-in organisation, made by
255
+ * `platform:auth` with the same name and primary domain a company apply uses,
256
+ * so that apply finds this org and makes no second one. `none` when the owner
257
+ * company is not declared. Archon signs people in from this org; the
258
+ * `foundry` org ({@link PLATFORM_ZITADEL_ORG_SCOPE_KEY}) holds no people.
259
+ */
260
+ export const PLATFORM_OWNER_ORG_SCOPE_KEY = 'zitadel_platform_owner_org';
261
+ /** The primary domain `platform:auth` registered on the owner company's sign-in org. */
262
+ export const PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY = 'zitadel_platform_owner_org_domain';
263
+ /** The owner org's sign-in domain the sign-in server refused to make primary; set instead of failing, reported as blocked. */
264
+ export const PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY = 'zitadel_platform_owner_org_domain_blocked';
265
+ /**
266
+ * The sign-in org the recorded owner invite ({@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY})
267
+ * was made in. An invite recorded against another org (the `foundry` org, on a
268
+ * platform applied before the owner moved) plans the auth step again.
269
+ */
270
+ export const PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY = 'zitadel_platform_owner_invite_org';
247
271
  /**
248
272
  * #394: the id of the role-less ZITADEL machine user that reserves the
249
273
  * username `admin` in the platform's own org, so nobody can sign up under a
@@ -253,8 +277,6 @@ export const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_owner_invi
253
277
  export const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = 'zitadel_platform_reserved_admin_username';
254
278
  /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
255
279
  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
280
  export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
259
281
  export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
260
282
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -461,6 +483,26 @@ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
461
483
  * no key and creates nothing in the sign-in server.
462
484
  */
463
485
  export const PLATFORM_MAIL_ROUTE_SCOPE_KEY = 'mail_route_applied';
486
+ /**
487
+ * The host ports the platform Stalwart publishes, as the mapping string the
488
+ * last apply set on its app. Without the shared relay it binds SMTP port 25
489
+ * itself; with `mail.relay` it keeps only submission, because the relay owns
490
+ * port 25. A missing record means the mapping it was created with, `25:25,...`.
491
+ */
492
+ export const PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY = 'mail_host_ports_applied';
493
+ export const PLATFORM_MAIL_HOST_PORTS_DIRECT = '25:25,465:465,587:587';
494
+ export const PLATFORM_MAIL_HOST_PORTS_RELAY = '465:465,587:587';
495
+ export function platformMailHostPorts(platform) {
496
+ return platform.mail.relay === true
497
+ ? PLATFORM_MAIL_HOST_PORTS_RELAY
498
+ : PLATFORM_MAIL_HOST_PORTS_DIRECT;
499
+ }
500
+ function mailPortsApplied(platform, recordedIds) {
501
+ return ((recordedIds[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ?? PLATFORM_MAIL_HOST_PORTS_DIRECT) ===
502
+ platformMailHostPorts(platform));
503
+ }
504
+ export { PLATFORM_MAIL_RELAY_KEY };
505
+ export { PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, } from './mail-relay.js';
464
506
  export const PLATFORM_MAIL_ROUTE_WANTED = 'local-first';
465
507
  /**
466
508
  * #177: where the mail server answers on the Coolify network
@@ -1092,7 +1134,7 @@ export function uptimeRecorded(recordedIds) {
1092
1134
  * unchanged).
1093
1135
  */
1094
1136
  export function planPlatform(platform, live, options = {}) {
1095
- const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, recordedIds = {}, blocked = {}, drifted = {}, } = options;
1137
+ const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailPlatformDomains = [], mailRoutesBlocked = [], recordedIds = {}, blocked = {}, drifted = {}, } = options;
1096
1138
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
1097
1139
  const claimed = new Set();
1098
1140
  const plannedSteps = [];
@@ -1138,7 +1180,7 @@ export function planPlatform(platform, live, options = {}) {
1138
1180
  // reconcile below can be planned in the *same* round hc first comes up,
1139
1181
  // the same way files/mail/archon are planned in auth's own first round.
1140
1182
  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 ?? []);
1183
+ const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite);
1142
1184
  if (auth.step)
1143
1185
  plannedSteps.push(auth.step);
1144
1186
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -1157,6 +1199,18 @@ export function planPlatform(platform, live, options = {}) {
1157
1199
  const mail = planMail(platform, recordedIds, blocked.mail ?? dnsBlocked?.('mail server'), authReady);
1158
1200
  if (mail.step)
1159
1201
  plannedSteps.push(mail.step);
1202
+ // The shared relay owns host port 25 once `mail.relay` is on, so it runs after the
1203
+ // mail step that gives the port up.
1204
+ const relay = planMailRelay(platform, recordedIds, {
1205
+ routes: mailRoutes,
1206
+ platformDomains: mailPlatformDomains,
1207
+ executorBlocked: blocked.relay,
1208
+ mailReady: mail.step !== null ||
1209
+ (Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_MAIL_KEY)]) &&
1210
+ mailPortsApplied(platform, recordedIds)),
1211
+ });
1212
+ if (relay.step)
1213
+ plannedSteps.push(relay.step);
1160
1214
  // #172: a throw halts every step after it, so this one goes late.
1161
1215
  const ai = planAi(platform, recordedIds, blocked.ai);
1162
1216
  if (ai.step)
@@ -1243,6 +1297,12 @@ export function planPlatform(platform, live, options = {}) {
1243
1297
  // A blocked jobs scheduler is reported here, not planned: a step that
1244
1298
  // throws every apply, or that can never be seen live and so re-plans
1245
1299
  // forever, is worse than none.
1300
+ if (!platform.ownerOrgId) {
1301
+ unmanaged.push({
1302
+ 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",
1303
+ key: 'owner-org',
1304
+ });
1305
+ }
1246
1306
  if (jobs.blocked)
1247
1307
  unmanaged.push({ description: jobs.blocked, key: 'jobs' });
1248
1308
  if (platform.sfu.enabled && sfuBlocked) {
@@ -1253,6 +1313,13 @@ export function planPlatform(platform, live, options = {}) {
1253
1313
  unmanaged.push({ description: auth.blocked, key: 'auth' });
1254
1314
  if (mail.blocked)
1255
1315
  unmanaged.push({ description: mail.blocked, key: 'mail' });
1316
+ if (relay.blocked)
1317
+ unmanaged.push({ description: relay.blocked, key: 'mail-relay' });
1318
+ if (platform.mail.relay === true) {
1319
+ for (const description of mailRoutesBlocked) {
1320
+ unmanaged.push({ description, key: 'mail-relay:route' });
1321
+ }
1322
+ }
1256
1323
  if (integrations.blocked)
1257
1324
  unmanaged.push({ description: integrations.blocked, key: 'integrations' });
1258
1325
  if (files.blocked)
@@ -1284,7 +1351,12 @@ export function planPlatform(platform, live, options = {}) {
1284
1351
  unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
1285
1352
  for (const description of uptime.blocked)
1286
1353
  unmanaged.push({ description, key: 'uptime' });
1287
- return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
1354
+ return {
1355
+ plannedSteps: plannedSteps.map(withStepSystem),
1356
+ unmanaged,
1357
+ versionChanges,
1358
+ notes: uptime.notes,
1359
+ };
1288
1360
  }
1289
1361
  /**
1290
1362
  * The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
@@ -1703,9 +1775,12 @@ function planSfu(platform, callsOrgSlugs, recordedIds) {
1703
1775
  * promising "this apply re-mints" while one of those is still missing would
1704
1776
  * be wrong the moment it is read.
1705
1777
  */
1706
- function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = []) {
1778
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked) {
1707
1779
  if (!platform.auth.enabled)
1708
1780
  return { step: null, blocked: null };
1781
+ // C3/S2: standing notes. The invite and the domain wait on a person; they hold nothing else back.
1782
+ const standing = [ownerInviteBlocked, ownerDomainBlockedText(recordedIds)].filter((t) => Boolean(t));
1783
+ const standingNote = standing.length > 0 ? standing.join('; ') : null;
1709
1784
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
1710
1785
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
1711
1786
  // #211: a recorded app and connector with no recorded broker id is a
@@ -1739,11 +1814,18 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1739
1814
  // Mail and the owner's invite: a platform applied before they existed
1740
1815
  // plans the step again, which only adds them.
1741
1816
  Boolean(recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY]) &&
1742
- Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) &&
1817
+ (Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) || Boolean(ownerInviteBlocked)) &&
1818
+ // The owner company's own sign-in org, and an invite made in it: a
1819
+ // platform whose owner was invited into the `foundry` org plans the step again.
1820
+ Boolean(recordedIds[PLATFORM_OWNER_ORG_SCOPE_KEY]) &&
1821
+ (Boolean(ownerInviteBlocked) ||
1822
+ recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY] === 'none' ||
1823
+ recordedIds[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY] ===
1824
+ recordedIds[PLATFORM_OWNER_ORG_SCOPE_KEY]) &&
1743
1825
  // The ZITADEL run options reach the container only through the auth
1744
1826
  // step's redeploy, so a platform recorded before them still plans it.
1745
1827
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
1746
- return { step: null, blocked: null };
1828
+ return { step: null, blocked: standingNote };
1747
1829
  }
1748
1830
  // The admin key was minted but never reached the host. ZITADEL shows a
1749
1831
  // token once, so it is lost: minting another would hide that, and skipping
@@ -1814,6 +1896,7 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1814
1896
  // 5: and only claim "this apply re-mints" when `otherInputsMissing` is
1815
1897
  // actually empty -- see this function's own doc comment.
1816
1898
  blocked: executorBlocked ??
1899
+ standingNote ??
1817
1900
  loginClientNeedsMintNote(loginClientUndelivered, loginClientApplied, authDrifted, loginClientRecord, otherInputsMissing),
1818
1901
  };
1819
1902
  }
@@ -1879,7 +1962,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1879
1962
  // #177: a mail step applied before this key existed never recorded it, and
1880
1963
  // org steps read it for Agora's mail upstream. Missing means unapplied.
1881
1964
  recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
1882
- recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED) {
1965
+ recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED &&
1966
+ mailPortsApplied(platform, recordedIds)) {
1883
1967
  return { step: null, blocked: null };
1884
1968
  }
1885
1969
  // Only the route is missing: say so, the app is not redeployed.
@@ -1887,7 +1971,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1887
1971
  recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
1888
1972
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
1889
1973
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1890
- recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY];
1974
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
1975
+ mailPortsApplied(platform, recordedIds);
1891
1976
  // Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
1892
1977
  // reached the host: report it, plan nothing, the same rule {@link planAuth}
1893
1978
  // follows for ZITADEL's own admin key.
@@ -2487,9 +2572,9 @@ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyM
2487
2572
  * destructive step removes whatever of it is recorded (edge removes its KV
2488
2573
  * namespace with the script).
2489
2574
  *
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.
2575
+ * The returned `blocked` lines are reported in `unmanaged`. A secret whose
2576
+ * source does not exist yet blocks nothing: the step still delivers
2577
+ * everything that exists, so it comes back in `notes`.
2493
2578
  */
2494
2579
  function planUptime(platform, recordedIds, executorBlocked, heldOptional, archonApplied, uptimeHeld = {
2495
2580
  token: true,
@@ -2507,9 +2592,9 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2507
2592
  });
2508
2593
  if (!uptime.enabled) {
2509
2594
  if (!uptimeRecorded(recordedIds))
2510
- return { step: null, blocked: [] };
2595
+ return { step: null, blocked: [], notes: [] };
2511
2596
  if (executorBlocked)
2512
- return { step: null, blocked: [executorBlocked] };
2597
+ return { step: null, blocked: [executorBlocked], notes: [] };
2513
2598
  return {
2514
2599
  step: {
2515
2600
  action: action({ remove: true }),
@@ -2520,6 +2605,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2520
2605
  },
2521
2606
  },
2522
2607
  blocked: [],
2608
+ notes: [],
2523
2609
  };
2524
2610
  }
2525
2611
  if (!uptime.tag || !uptime.sha256) {
@@ -2528,6 +2614,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2528
2614
  blocked: [
2529
2615
  '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
2616
  ],
2617
+ notes: [],
2531
2618
  };
2532
2619
  }
2533
2620
  if (!archonApplied) {
@@ -2536,16 +2623,25 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2536
2623
  blocked: [
2537
2624
  'the uptime Worker is blocked, not planned: platform:archon has not been applied yet',
2538
2625
  ],
2626
+ notes: [],
2539
2627
  };
2540
2628
  }
2541
2629
  if (executorBlocked)
2542
- return { step: null, blocked: [executorBlocked] };
2543
- const missing = [];
2630
+ return { step: null, blocked: [executorBlocked], notes: [] };
2631
+ const notes = [];
2544
2632
  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`);
2633
+ notes.push({
2634
+ key: 'uptime:CHAT_ACCESS_TOKEN',
2635
+ service: 'uptime',
2636
+ 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`,
2637
+ });
2546
2638
  }
2547
2639
  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`);
2640
+ notes.push({
2641
+ key: 'uptime:MAIL_API_KEY',
2642
+ service: 'uptime',
2643
+ 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`,
2644
+ });
2549
2645
  }
2550
2646
  const hostname = platform.archon.hostname;
2551
2647
  // Applied only once a run got to its end and recorded these: a Worker that
@@ -2561,7 +2657,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2561
2657
  (recordedIds[PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY] ?? '') === uptimeHeld.vars &&
2562
2658
  recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional);
2563
2659
  if (applied)
2564
- return { step: null, blocked: missing };
2660
+ return { step: null, blocked: [], notes };
2565
2661
  return {
2566
2662
  step: {
2567
2663
  action: action({ hostname, gitRef: uptime.tag, sha256: uptime.sha256 }),
@@ -2571,7 +2667,8 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2571
2667
  migrates: false,
2572
2668
  },
2573
2669
  },
2574
- blocked: missing,
2670
+ blocked: [],
2671
+ notes,
2575
2672
  };
2576
2673
  }
2577
2674
  /**
@@ -2709,8 +2806,8 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2709
2806
  Boolean(recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY]) &&
2710
2807
  recordedIds[PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY] ===
2711
2808
  recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
2712
- (recordedIds[PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY] ?? 'none') ===
2713
- (archonBootstrapOperator(recordedIds) ?? 'none') &&
2809
+ recordedIds[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY] ===
2810
+ archonAuthOrganizationId(recordedIds) &&
2714
2811
  // #475: a platform applied before the rolling-update fix keeps planning
2715
2812
  // this step until a confirmed redeploy has cleared it. #480: this marker
2716
2813
  // alone does not catch the live app's settings changing back afterward
@@ -2775,3 +2872,12 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted,
2775
2872
  blocked: null,
2776
2873
  };
2777
2874
  }
2875
+ /** The blocked text for a refused owner-org sign-in domain, while it is recorded and not yet primary. */
2876
+ function ownerDomainBlockedText(recordedIds) {
2877
+ const blockedDomain = recordedIds[PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY];
2878
+ if (!blockedDomain)
2879
+ return null;
2880
+ if (recordedIds[PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY] === blockedDomain)
2881
+ return null;
2882
+ return signInDomainBlockedText('the platform owner', blockedDomain).replace(ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY);
2883
+ }
@@ -1,3 +1,4 @@
1
+ import { type DeclaredOrg } from '../org.js';
1
2
  import type { DeclaredPlatform } from '../platform.js';
2
3
  import type { AgoraHostValues } from './agora.js';
3
4
  /**
@@ -61,6 +62,8 @@ export interface OrgPlatformReadiness {
61
62
  * while `MAIL_DOMAIN_SCOPE_KEY` was still recorded as the apex.
62
63
  */
63
64
  platformApex: string | null;
65
+ /** `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. */
66
+ platformOwnerOrgId: string | null;
64
67
  /** `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
68
  platformLettermintProjectId: string | null;
66
69
  /** 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. */
@@ -149,6 +152,19 @@ export interface OrgPlatformReadiness {
149
152
  * A missing key is then reported as "no reader given" instead of "not held".
150
153
  */
151
154
  noKeyReader?: boolean;
155
+ /** The platform's declared git ref for each service, which an own org's services deploy. */
156
+ serviceGitRefs?: {
157
+ auth: string;
158
+ mail: string;
159
+ files: string;
160
+ jobs: string;
161
+ };
162
+ /**
163
+ * The platform's recorded owner invite (`PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY`): the user id the
164
+ * platform's files host takes as `AUTHZ_BOOTSTRAP_OPERATOR`, or `'none'` when the platform has no
165
+ * owner to invite. `null` or absent until recorded. An own org's files host gets the same value.
166
+ */
167
+ platformOwnerUserId?: string | null;
152
168
  }
153
169
  /**
154
170
  * What an org's plan reads when no platform is declared: nothing is ready, so
@@ -205,6 +221,26 @@ heldRefs?: ReadonlySet<string>,
205
221
  noKeyReader?: boolean,
206
222
  /** #317: true when the host's executor was built with an `EdgePort` ({@link OrgPlatformReadiness.edgeReady}). */
207
223
  edgePortPresent?: boolean): OrgPlatformReadiness;
224
+ /** The git refs the platform declares for the services an own org deploys. */
225
+ export declare function serviceGitRefsOf(platform: DeclaredPlatform): {
226
+ auth: string;
227
+ mail: string;
228
+ files: string;
229
+ jobs: string;
230
+ };
231
+ /** The runner app both the platform's and an own org's files step record. */
232
+ export declare const OWN_FILES_RUNNER_SCOPE_KEY = "coolify_platform_files_runner_app";
233
+ /**
234
+ * An own org (`services.own`) plans against its own services, so its readiness
235
+ * reads only its own recorded ids and its own held keys; nothing the shared
236
+ * services recorded reaches it. AI, edge and Access stay shared and come from
237
+ * `base`. Any other org gets `base` back unchanged.
238
+ */
239
+ export declare function orgPlatformReadinessFor(org: DeclaredOrg, base: OrgPlatformReadiness, orgRecordedIds: Readonly<Record<string, string>>, heldRefs: ReadonlySet<string>): OrgPlatformReadiness;
240
+ /** The own org's held-key refs `heldKeys` resolves right now. A resolver that throws counts as not held, and its error goes nowhere. */
241
+ export declare function heldOwnKeyRefs(heldKeys: HeldKeyResolver, org: Pick<DeclaredOrg, 'slug'>): Promise<Set<string>>;
242
+ /** {@link agoraHostValuesFromPlatform}'s counterpart for an own org: every value is the org's own, null until recorded. */
243
+ export declare function agoraHostValuesForOwn(org: DeclaredOrg, orgRecordedIds: Readonly<Record<string, string>>): AgoraHostValues;
208
244
  /**
209
245
  * #177: {@link AgoraHostValues} derived from the same platform state, rather
210
246
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the
@@ -1,6 +1,8 @@
1
+ import { ownsServices } from '../org.js';
1
2
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
2
- import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
3
- import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
3
+ import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
4
+ import { OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget } from './own-services.js';
5
+ import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
4
6
  /**
5
7
  * #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
6
8
  * fixed suffix `agora.ts`'s `agoraHostnameFor`, `boule.ts`'s
@@ -27,6 +29,7 @@ export const NO_DOMAIN_MAIL_APEX = 'oikos.host';
27
29
  export const DEFAULT_ORG_PLATFORM_READINESS = {
28
30
  mailHost: null,
29
31
  platformApex: null,
32
+ platformOwnerOrgId: null,
30
33
  platformLettermintProjectId: null,
31
34
  filesReady: false,
32
35
  aiReady: false,
@@ -124,6 +127,8 @@ edgePortPresent = false) {
124
127
  // mail.lettermintProjectId are facts regardless of whether mail itself is
125
128
  // enabled or applied.
126
129
  platformApex: platform.apex,
130
+ platformOwnerOrgId: platform.ownerOrgId || null,
131
+ platformOwnerUserId: platformRecordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY] || null,
127
132
  platformLettermintProjectId: platform.mail.lettermintProjectId ?? null,
128
133
  filesReady: filesApplied && missingKeys.files === null,
129
134
  filesUrl: filesApplied
@@ -145,6 +150,95 @@ edgePortPresent = false) {
145
150
  ...(noKeyReader ? { noKeyReader } : {}),
146
151
  };
147
152
  }
153
+ /** The git refs the platform declares for the services an own org deploys. */
154
+ export function serviceGitRefsOf(platform) {
155
+ return {
156
+ auth: platform.auth.gitRef,
157
+ mail: platform.mail.gitRef,
158
+ files: platform.files.gitRef,
159
+ jobs: platform.jobs.gitRef,
160
+ };
161
+ }
162
+ /** The runner app both the platform's and an own org's files step record. */
163
+ export const OWN_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
164
+ const OWN_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
165
+ /** Resource id of an own-service step, in `AppliedState.serviceIds`' `system:kind:key` form. */
166
+ function ownStepRecorded(ids, org, kind) {
167
+ return Boolean(ids[`coolify:${kind}:${org.id}`]);
168
+ }
169
+ /**
170
+ * The own org's sign-in is up: applied, so its ZITADEL answers. The broker and the login client
171
+ * come after the org's first broker entry, as the platform's do after its first registration, so
172
+ * waiting for them here would wait for the very steps this gate lets run.
173
+ */
174
+ function ownAuthReady(ids) {
175
+ return Boolean(ids[OWN_SERVICES_APPLIED_SCOPE_KEY]);
176
+ }
177
+ /**
178
+ * An own org (`services.own`) plans against its own services, so its readiness
179
+ * reads only its own recorded ids and its own held keys; nothing the shared
180
+ * services recorded reaches it. AI, edge and Access stay shared and come from
181
+ * `base`. Any other org gets `base` back unchanged.
182
+ */
183
+ export function orgPlatformReadinessFor(org, base, orgRecordedIds, heldRefs) {
184
+ if (!ownsServices(org))
185
+ return base;
186
+ const refs = orgTarget(org).refs;
187
+ const authReady = ownAuthReady(orgRecordedIds);
188
+ const filesApplied = ownStepRecorded(orgRecordedIds, org, 'own-files') &&
189
+ Boolean(orgRecordedIds[OWN_FILES_RUNNER_SCOPE_KEY]);
190
+ const jobsApplied = ownStepRecorded(orgRecordedIds, org, 'own-jobs');
191
+ const mailApplied = ownStepRecorded(orgRecordedIds, org, 'own-mail') &&
192
+ Boolean(orgRecordedIds[OWN_MAIL_APP_SCOPE_KEY]);
193
+ const filesUrl = orgRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY];
194
+ const jobsUrl = orgRecordedIds[JOBS_INTERNAL_URL_SCOPE_KEY];
195
+ const missing = (applied, ref) => applied && !heldRefs.has(ref) ? ref : null;
196
+ const missingKeys = {
197
+ files: missing(filesApplied, refs.filesManagement),
198
+ ai: base.missingKeys.ai,
199
+ jobs: missing(jobsApplied, refs.jobsAdminToken),
200
+ };
201
+ const { jobsUrlMissing: _shared, ...rest } = base;
202
+ return {
203
+ ...rest,
204
+ authReady,
205
+ authIssuer: authReady && org.domain ? `https://auth.${org.domain}` : null,
206
+ filesReady: filesApplied && filesUrl !== undefined && missingKeys.files === null,
207
+ filesUrl: filesApplied ? (filesUrl ?? null) : null,
208
+ jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
209
+ ...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
210
+ callsReady: ownStepRecorded(orgRecordedIds, org, 'own-sfu'),
211
+ mailHost: mailApplied && org.domain && base.mailHost
212
+ ? { apex: org.domain, mailHost: base.mailHost.mailHost }
213
+ : null,
214
+ missingKeys,
215
+ };
216
+ }
217
+ /** The own org's held-key refs `heldKeys` resolves right now. A resolver that throws counts as not held, and its error goes nowhere. */
218
+ export async function heldOwnKeyRefs(heldKeys, org) {
219
+ const held = new Set();
220
+ for (const ref of Object.values(orgTarget(org).refs)) {
221
+ try {
222
+ if (await heldKeys.resolve(ref))
223
+ held.add(ref);
224
+ }
225
+ catch {
226
+ // not held
227
+ }
228
+ }
229
+ return held;
230
+ }
231
+ /** {@link agoraHostValuesFromPlatform}'s counterpart for an own org: every value is the org's own, null until recorded. */
232
+ export function agoraHostValuesForOwn(org, orgRecordedIds) {
233
+ const authReady = ownAuthReady(orgRecordedIds);
234
+ return {
235
+ issuer: authReady && org.domain ? `https://auth.${org.domain}` : null,
236
+ mailProjectId: orgRecordedIds[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY] ?? null,
237
+ messagingProjectId: null,
238
+ mailUpstream: orgRecordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] ?? null,
239
+ filesUpstream: orgRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null,
240
+ };
241
+ }
148
242
  /**
149
243
  * #177: {@link AgoraHostValues} derived from the same platform state, rather
150
244
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the