@wtfalch/foundry 0.9.0 → 0.10.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.
@@ -66,7 +66,7 @@ export interface LoginHostBindingsPort {
66
66
  * on the next run.
67
67
  */
68
68
  export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
69
- /** The org's recorded app client ids (hosted app, cms, agora), sorted. */
69
+ /** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
70
70
  export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
71
71
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
72
72
  export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
@@ -1,4 +1,4 @@
1
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, } from './agora.js';
1
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
2
2
  /**
3
3
  * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
4
  * need to agree on for an org's own company login host -- the hostname and
@@ -36,12 +36,15 @@ export function loginHostnameFor(org) {
36
36
  * on the next run.
37
37
  */
38
38
  export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
39
- /** The org's recorded app client ids (hosted app, cms, agora), sorted. */
39
+ /** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
40
40
  export function loginHostClientIds(recorded) {
41
41
  return [
42
42
  recorded[APP_CLIENT_ID_SCOPE_KEY],
43
43
  recorded[CMS_CLIENT_ID_SCOPE_KEY],
44
44
  recorded[AGORA_CLIENT_ID_SCOPE_KEY],
45
+ ...Object.entries(recorded)
46
+ .filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
47
+ .map(([, id]) => id),
45
48
  ]
46
49
  .filter((id) => Boolean(id))
47
50
  .sort();
@@ -202,6 +202,9 @@ export interface PlatformStepExecutor {
202
202
  driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
203
203
  archon?: boolean;
204
204
  auth?: boolean;
205
+ backupStorage?: boolean;
206
+ /** #257 fix round 4: see `PlanPlatformOptions.drifted.backupStorageBlocked` (`platform-diff.ts`). */
207
+ backupStorageBlocked?: string;
205
208
  }>;
206
209
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
207
210
  resourceId?: string;
@@ -295,6 +298,12 @@ export interface FoundryPlanApplyOptions {
295
298
  * before zitadel-login's binding can be written.
296
299
  */
297
300
  loginHostBindings?: boolean;
301
+ /**
302
+ * F5: true when the host's executor was built with a `broker` port.
303
+ * Optional, and false without it: each entry's broker entry is then
304
+ * reported blocked instead of planned.
305
+ */
306
+ broker?: boolean;
298
307
  /**
299
308
  * #177: true when the host's executor was built with its own `filesOrganisation`
300
309
  * / `aiOrganisation` port. Without one, the org step needs the platform's
@@ -349,6 +358,7 @@ export declare class FoundryPlanApply implements PlanApply {
349
358
  private readonly agora;
350
359
  private readonly jobsScheduler;
351
360
  private readonly loginHostBindings;
361
+ private readonly broker;
352
362
  private readonly hostFiles;
353
363
  private readonly hostAi;
354
364
  private readonly now;
@@ -388,6 +398,12 @@ export declare class FoundryPlanApply implements PlanApply {
388
398
  private refuseDestroy;
389
399
  private runSteps;
390
400
  private computeFreshPlan;
401
+ /**
402
+ * An entry may not take a name foundry owns (`locked-hostnames.ts`). The
403
+ * store checks the names it knows; this adds the platform's own hostnames,
404
+ * which only the plan can read.
405
+ */
406
+ private invalidHosting;
391
407
  /**
392
408
  * #177: an org's plan reads the shared platform's own addresses and
393
409
  * readiness through the very `platform` deps `planPlatform`/`applyPlatform`
@@ -1,8 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
+ import { validateHosting } from '../org.js';
4
+ import { PLATFORM_SERVICE_KEYS } from '../platform.js';
3
5
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
4
6
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
5
7
  import { readLivePlatformResources, readLiveResources, } from './live.js';
8
+ import { lockedHostnames } from './locked-hostnames.js';
6
9
  import { planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
7
10
  import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
8
11
  export class DeclaredOrgNotFoundError extends Error {
@@ -63,6 +66,15 @@ export class PlatformConfirmationRequiredError extends Error {
63
66
  this.name = 'PlatformConfirmationRequiredError';
64
67
  }
65
68
  }
69
+ /** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
70
+ const HOSTNAME_STEPS = new Set([
71
+ 'coolify:app',
72
+ 'coolify:broker-entry',
73
+ 'coolify:database',
74
+ 'cloudflare:tunnel',
75
+ 'cloudflare:access',
76
+ 'jobs:registration',
77
+ ]);
66
78
  /**
67
79
  * The #19 implementation of {@link PlanApply}: computes a plan against live
68
80
  * state, and refuses to apply one that has gone stale, one that touches a
@@ -80,6 +92,7 @@ export class FoundryPlanApply {
80
92
  agora;
81
93
  jobsScheduler;
82
94
  loginHostBindings;
95
+ broker;
83
96
  hostFiles;
84
97
  hostAi;
85
98
  now;
@@ -95,6 +108,7 @@ export class FoundryPlanApply {
95
108
  this.agora = options.agora;
96
109
  this.jobsScheduler = options.jobsScheduler ?? false;
97
110
  this.loginHostBindings = options.loginHostBindings ?? false;
111
+ this.broker = options.broker ?? false;
98
112
  this.hostFiles = options.filesOrganisation ?? false;
99
113
  this.hostAi = options.aiOrganisation ?? false;
100
114
  this.now = options.now ?? (() => new Date());
@@ -354,9 +368,34 @@ export class FoundryPlanApply {
354
368
  this.orgPlatformReadiness(),
355
369
  this.orgAgoraHost(id),
356
370
  ]);
357
- const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, { loginHostBindings: this.loginHostBindings });
371
+ const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, { loginHostBindings: this.loginHostBindings, broker: this.broker });
372
+ const invalid = await this.invalidHosting(org);
373
+ if (invalid) {
374
+ // Rule 2: reported in `unmanaged`, not thrown. Steps that read the
375
+ // hostnames are dropped, so `apply` does not act on them.
376
+ detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !HOSTNAME_STEPS.has(`${action.system}:${action.kind}`));
377
+ detail.unmanaged.push({
378
+ description: `${org.slug}'s hosting is blocked, not planned: ${invalid}`,
379
+ key: 'hosting:invalid',
380
+ });
381
+ }
358
382
  return { org, plan: toPlan(detail), detail };
359
383
  }
384
+ /**
385
+ * An entry may not take a name foundry owns (`locked-hostnames.ts`). The
386
+ * store checks the names it knows; this adds the platform's own hostnames,
387
+ * which only the plan can read.
388
+ */
389
+ async invalidHosting(org) {
390
+ const platform = await this.platformDeps?.store.get();
391
+ const platformHostnames = platform
392
+ ? PLATFORM_SERVICE_KEYS.map((k) => platform[k].hostname)
393
+ : [];
394
+ return validateHosting(org.hosting, {
395
+ domain: org.domain,
396
+ locked: lockedHostnames(org, platformHostnames),
397
+ });
398
+ }
360
399
  /**
361
400
  * #177: an org's plan reads the shared platform's own addresses and
362
401
  * readiness through the very `platform` deps `planPlatform`/`applyPlatform`
@@ -604,7 +643,8 @@ function destroyServiceIdKey(target) {
604
643
  const STEP_FIELDS = {
605
644
  'zitadel:issuer': 'issuer.usesDomain',
606
645
  'zitadel:owner': 'owner',
607
- 'zitadel:customers': 'hosting.issuerApps',
646
+ 'zitadel:customers': 'hosting.entries.uses.auth',
647
+ 'coolify:broker-entry': 'hosting.entries.uses.auth',
608
648
  'coolify:mail': 'mail',
609
649
  'coolify:chat': 'chat.usesDomain',
610
650
  'coolify:calls': 'calls.enabled',
@@ -316,26 +316,61 @@ export declare function platformMailMxHost(apex: string): string;
316
316
  export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
317
317
  export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
318
318
  /**
319
- * The key of an org's `app` hosting entry in a `coolify:app` or
320
- * `cloudflare:access` step and in the ids recorded for it:
321
- * `<orgId>:<entry key>`, so a second entry cannot collide with the first.
319
+ * The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
320
+ * and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
321
+ * cannot collide with the first.
322
322
  */
323
- export declare function appStepKey(org: DeclaredOrg): string;
323
+ export declare function appStepKey(org: DeclaredOrg, entryKey?: string): string;
324
324
  /**
325
- * #243: the org's `app` entry's `env` values the last `coolify:app` apply
326
- * pushed, recorded as one JSON object (env name to value; ids and a domain,
327
- * never a secret). `planApp` plans the step again when {@link resolveAppEnv}
328
- * differs from it, so a changed map reaches an app that is already live. The
329
- * `.app` suffix is the entry key; a scope key holds no `:`, so
330
- * `storeRecordedIds` still filters it out.
325
+ * The Coolify name of a hosting entry's app: `<slug>` for the `app` entry, so
326
+ * the move to a list finds the app that runs today, `<slug>-<key>` for any
327
+ * other.
331
328
  */
332
- export declare const APP_ENV_APPLIED_SCOPE_KEY = "app_env_applied.app";
329
+ export declare function appNameFor(org: DeclaredOrg, entry: HostingEntry): string;
330
+ /**
331
+ * #243: the entry's `env` values the last `coolify:app` apply pushed,
332
+ * recorded as one JSON object (env name to value; ids and a domain, never a
333
+ * secret). `planApp` plans the step again when {@link resolveAppEnv} differs
334
+ * from it, so a changed map reaches an app that is already live. The `.<key>`
335
+ * suffix is the entry key; a scope key holds no `:`, so `storeRecordedIds`
336
+ * still filters it out.
337
+ */
338
+ export declare function appEnvAppliedScopeKey(entryKey: string): string;
333
339
  /**
334
340
  * #239: set to '1' once an app has been restarted after its database URLs
335
341
  * were pushed. Not a URL. Without it a crash between the push and the restart
336
342
  * would leave a stale container that no plan notices.
337
343
  */
338
- export declare const DB_ENV_APPLIED_SCOPE_KEY = "db_env_applied.app";
344
+ export declare function dbEnvAppliedScopeKey(entryKey: string): string;
345
+ /**
346
+ * The settings `coolifyApp` owns on an entry's app (`folder`, `port`,
347
+ * `autoDeploy`) as the JSON the marker below holds. `coolifyApp` records it
348
+ * on create and after it patches the app, and `planApp` plans the step again
349
+ * when the declared settings differ from it.
350
+ */
351
+ export interface AppSettings {
352
+ folder: string | null;
353
+ port: number | null;
354
+ autoDeploy: boolean;
355
+ }
356
+ export declare function appSettingsOf(entry: HostingEntry): AppSettings;
357
+ /** What an app that predates the marker has: Coolify's own defaults, so it plans no change. */
358
+ export declare const DEFAULT_APP_SETTINGS: AppSettings;
359
+ export declare function appSettingsAppliedScopeKey(entryKey: string): string;
360
+ /** The settings the app has: the marker, or the defaults when there is none. */
361
+ export declare function appliedAppSettings(recorded: Readonly<Record<string, string>>, entryKey: string): AppSettings;
362
+ /**
363
+ * The names `coolifyApp` last put in the entry's Coolify `domains`, as a JSON
364
+ * array (`[]` for an internal entry). `planApp` plans the step again, with a
365
+ * redeploy, when {@link entryHostnames} differs from it.
366
+ */
367
+ export declare function appDomainsAppliedScopeKey(entryKey: string): string;
368
+ /**
369
+ * The names the app has: the marker, or for an app that predates it the one
370
+ * name it was created with (the `app` entry's address, nothing for any other
371
+ * entry), so an app that runs today plans no change.
372
+ */
373
+ export declare function appliedAppDomains(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, entryKey: string): string[];
339
374
  /**
340
375
  * The entry's `env` values that have a value, in env-name order, and the env
341
376
  * names whose source has none yet. Shared by `planApp` and the executor's
@@ -510,11 +545,23 @@ export interface PlanPlatformOptions {
510
545
  * include list is not the declared one. `auth` (#216 round 4): the
511
546
  * login-client PAT recorded under
512
547
  * {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} is no longer the sole
513
- * live PAT on that ZITADEL machine user.
548
+ * live PAT on that ZITADEL machine user. `backupStorage` (#257 fix round
549
+ * 2): the registered R2 storage's live fingerprint no longer matches the
550
+ * host's configured bucket/endpoint/key/secret, or the recorded storage
551
+ * uuid is no longer in Coolify's live list -- see `planBackups`'s own doc
552
+ * comment for why this is the one drift flag that can fire even once
553
+ * every per-service backup step is claimed. `backupStorageBlocked` (#257
554
+ * fix round 4, point 4): set instead of `backupStorage` when the liveness
555
+ * check itself failed (Coolify unreachable, credentials revoked) -- the
556
+ * plan is never failed over it, but it is reported in `unmanaged` rather
557
+ * than only logged, so a persistent failure is visible instead of silently
558
+ * retried forever.
514
559
  */
515
560
  drifted?: {
516
561
  archon?: boolean;
517
562
  auth?: boolean;
563
+ backupStorage?: boolean;
564
+ backupStorageBlocked?: string;
518
565
  };
519
566
  }
520
567
  /**
@@ -1,6 +1,7 @@
1
1
  import { VERSIONS } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY } from '../org.js';
3
3
  import { VERSION_CHANGE_KIND, } from '../plan.js';
4
+ import { legacyAppHostname } from './hostnames.js';
4
5
  /**
5
6
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
6
7
  * mirroring `diff.ts`'s `toPlan`. There is no separate platform-shaped `Plan`
@@ -348,28 +349,72 @@ export function platformMailMxHost(apex) {
348
349
  export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
349
350
  export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
350
351
  /**
351
- * The key of an org's `app` hosting entry in a `coolify:app` or
352
- * `cloudflare:access` step and in the ids recorded for it:
353
- * `<orgId>:<entry key>`, so a second entry cannot collide with the first.
352
+ * The key of a hosting entry in a `coolify:app` or `cloudflare:access` step
353
+ * and in the ids recorded for it: `<orgId>:<entry key>`, so a second entry
354
+ * cannot collide with the first.
354
355
  */
355
- export function appStepKey(org) {
356
- return `${org.id}:${APP_ENTRY_KEY}`;
356
+ export function appStepKey(org, entryKey = APP_ENTRY_KEY) {
357
+ return `${org.id}:${entryKey}`;
357
358
  }
358
359
  /**
359
- * #243: the org's `app` entry's `env` values the last `coolify:app` apply
360
- * pushed, recorded as one JSON object (env name to value; ids and a domain,
361
- * never a secret). `planApp` plans the step again when {@link resolveAppEnv}
362
- * differs from it, so a changed map reaches an app that is already live. The
363
- * `.app` suffix is the entry key; a scope key holds no `:`, so
364
- * `storeRecordedIds` still filters it out.
360
+ * The Coolify name of a hosting entry's app: `<slug>` for the `app` entry, so
361
+ * the move to a list finds the app that runs today, `<slug>-<key>` for any
362
+ * other.
365
363
  */
366
- export const APP_ENV_APPLIED_SCOPE_KEY = `app_env_applied.${APP_ENTRY_KEY}`;
364
+ export function appNameFor(org, entry) {
365
+ return entry.key === APP_ENTRY_KEY ? org.slug : `${org.slug}-${entry.key}`;
366
+ }
367
+ /**
368
+ * #243: the entry's `env` values the last `coolify:app` apply pushed,
369
+ * recorded as one JSON object (env name to value; ids and a domain, never a
370
+ * secret). `planApp` plans the step again when {@link resolveAppEnv} differs
371
+ * from it, so a changed map reaches an app that is already live. The `.<key>`
372
+ * suffix is the entry key; a scope key holds no `:`, so `storeRecordedIds`
373
+ * still filters it out.
374
+ */
375
+ export function appEnvAppliedScopeKey(entryKey) {
376
+ return `app_env_applied.${entryKey}`;
377
+ }
367
378
  /**
368
379
  * #239: set to '1' once an app has been restarted after its database URLs
369
380
  * were pushed. Not a URL. Without it a crash between the push and the restart
370
381
  * would leave a stale container that no plan notices.
371
382
  */
372
- export const DB_ENV_APPLIED_SCOPE_KEY = `db_env_applied.${APP_ENTRY_KEY}`;
383
+ export function dbEnvAppliedScopeKey(entryKey) {
384
+ return `db_env_applied.${entryKey}`;
385
+ }
386
+ export function appSettingsOf(entry) {
387
+ return { folder: entry.folder, port: entry.port, autoDeploy: entry.autoDeploy };
388
+ }
389
+ /** What an app that predates the marker has: Coolify's own defaults, so it plans no change. */
390
+ export const DEFAULT_APP_SETTINGS = { folder: null, port: null, autoDeploy: true };
391
+ export function appSettingsAppliedScopeKey(entryKey) {
392
+ return `app_settings_applied.${entryKey}`;
393
+ }
394
+ /** The settings the app has: the marker, or the defaults when there is none. */
395
+ export function appliedAppSettings(recorded, entryKey) {
396
+ const marker = recorded[appSettingsAppliedScopeKey(entryKey)];
397
+ return marker ? JSON.parse(marker) : DEFAULT_APP_SETTINGS;
398
+ }
399
+ /**
400
+ * The names `coolifyApp` last put in the entry's Coolify `domains`, as a JSON
401
+ * array (`[]` for an internal entry). `planApp` plans the step again, with a
402
+ * redeploy, when {@link entryHostnames} differs from it.
403
+ */
404
+ export function appDomainsAppliedScopeKey(entryKey) {
405
+ return `app_domains_applied.${entryKey}`;
406
+ }
407
+ /**
408
+ * The names the app has: the marker, or for an app that predates it the one
409
+ * name it was created with (the `app` entry's address, nothing for any other
410
+ * entry), so an app that runs today plans no change.
411
+ */
412
+ export function appliedAppDomains(org, recorded, entryKey) {
413
+ const marker = recorded[appDomainsAppliedScopeKey(entryKey)];
414
+ if (marker)
415
+ return JSON.parse(marker);
416
+ return entryKey === APP_ENTRY_KEY ? [legacyAppHostname(org)] : [];
417
+ }
373
418
  /**
374
419
  * The entry's `env` values that have a value, in env-name order, and the env
375
420
  * names whose source has none yet. Shared by `planApp` and the executor's
@@ -576,7 +621,7 @@ export function planPlatform(platform, live, options = {}) {
576
621
  plannedSteps.push(archon.step);
577
622
  // #257: after every service above, so a database any of them just
578
623
  // recorded is already visible to `recordedIds` on this same plan.
579
- const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false);
624
+ const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false, drifted.backupStorage ?? false, drifted.backupStorageBlocked);
580
625
  plannedSteps.push(...backups.steps);
581
626
  // #251: one line per service whose recorded applied ref no longer matches
582
627
  // what the next apply would use (or, for zitadel, what is actually live).
@@ -778,15 +823,36 @@ export const PLATFORM_BACKUP_SERVICE_KEYS = [
778
823
  * Once a step's own `coolify:backup:<service>` resource is live, it is
779
824
  * claimed and never re-planned: like every other provision-only step here,
780
825
  * there is no park branch for a backup already scheduled.
826
+ *
827
+ * #257 fix round 2: that "claimed, never re-planned" rule is exactly why a
828
+ * rotated R2 credential would otherwise never reach Coolify again once every
829
+ * service's own backup is live -- nothing left in this loop ever calls
830
+ * `ensurePlatformBackupStorage` (`platform-providers.ts`) again. `storageDrifted`
831
+ * (the host's own `driftedSteps`, read fresh each plan) is the signal that
832
+ * self-heal is needed; when it is true, this plans exactly one extra
833
+ * `coolify:backup-storage:platform` step, independent of every per-service
834
+ * step's own claimed status, so the self-heal runs again whether or not any
835
+ * service step is also being (re)planned this round. It is never itself
836
+ * "claimed" against a live resource -- there is nothing to read live for a
837
+ * storage push, only to react to -- so it simply stops being planned the
838
+ * moment `storageDrifted` goes false again.
839
+ *
840
+ * #257 fix round 4, point 4: `storageBlocked`, when set, means the host's own
841
+ * `driftedSteps` could not even tell whether the storage is drifted (a
842
+ * Coolify read failed) -- reported in `blocked` instead of planning the
843
+ * self-heal step, so a persistent failure is visible in the plan rather than
844
+ * only logged and retried silently forever.
781
845
  */
782
- function planBackups(byKey, claimed, recordedIds, executorMissing) {
846
+ function planBackups(byKey, claimed, recordedIds, executorMissing, storageDrifted, storageBlocked) {
783
847
  const steps = [];
784
848
  const blocked = [];
849
+ let anyDatabase = false;
785
850
  for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
786
851
  const key = resourceKey('coolify', 'backup', service);
787
852
  const live = byKey.get(key);
788
853
  if (live) {
789
854
  claimed.add(key);
855
+ anyDatabase = true;
790
856
  continue;
791
857
  }
792
858
  // No database yet is not reported: unlike a Coolify reader or a backup
@@ -798,6 +864,7 @@ function planBackups(byKey, claimed, recordedIds, executorMissing) {
798
864
  const databaseId = recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
799
865
  if (!databaseId)
800
866
  continue;
867
+ anyDatabase = true;
801
868
  if (executorMissing) {
802
869
  blocked.push({
803
870
  description: `platform:backup:${service} is blocked, not planned: the host built the platform executor without its backup dependencies`,
@@ -819,6 +886,33 @@ function planBackups(byKey, claimed, recordedIds, executorMissing) {
819
886
  },
820
887
  });
821
888
  }
889
+ // #257 fix round 4, point 4: a blocked liveness check is reported instead
890
+ // of planning the self-heal step -- there is nothing to push until the
891
+ // read itself works again.
892
+ if (!executorMissing && storageBlocked && anyDatabase) {
893
+ blocked.push({ description: storageBlocked, key: 'backup-storage' });
894
+ }
895
+ else if (
896
+ // #257 fix round 2: same "nothing to report yet" gate every other branch
897
+ // above follows -- a platform with no database yet has no storage to
898
+ // self-heal either, whatever a stale `storageDrifted` reading says.
899
+ !executorMissing &&
900
+ storageDrifted &&
901
+ anyDatabase) {
902
+ steps.push({
903
+ action: {
904
+ system: 'coolify',
905
+ kind: 'backup-storage',
906
+ key: 'platform',
907
+ desired: {},
908
+ },
909
+ step: {
910
+ description: "self-heal the platform's R2 backup storage registered with Coolify (a rotated credential or a changed bucket/endpoint)",
911
+ destroys: false,
912
+ migrates: false,
913
+ },
914
+ });
915
+ }
822
916
  return { steps, blocked };
823
917
  }
824
918
  /**
@@ -5,6 +5,7 @@ import type { StepAction } from './diff.js';
5
5
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
6
6
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
7
7
  import type { PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
8
+ import { type BrokerDeployRecord } from './platform-diff.js';
8
9
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
9
10
  /**
10
11
  * #168: everything {@link provisionPlatformAuth} needs beyond
@@ -206,6 +207,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
206
207
  driftedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
207
208
  archon?: boolean;
208
209
  auth?: boolean;
210
+ backupStorage?: boolean;
211
+ backupStorageBlocked?: string;
209
212
  }>;
210
213
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
211
214
  resourceId?: string;
@@ -368,6 +371,40 @@ export declare const PLATFORM_ARCHON_ACCESS_APP_NAME = "foundry:platform:archon"
368
371
  export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
369
372
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
370
373
  export declare const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = "zitadel_platform_archon_client_id";
374
+ /**
375
+ * One `AUTH_APPS` entry, read back. Only the fields this step itself reads or
376
+ * writes are named; every other field `wtfalch/auth/broker/src/apps.ts`'s
377
+ * `App` knows about (`from`, `productName`, `locale`, `registration`,
378
+ * `emailLink`) rides along unnamed through the index signature, so merging an
379
+ * entry (see `provisionPlatformArchon`) never has to know the broker's full
380
+ * shape to avoid dropping a field it does not itself set.
381
+ */
382
+ type BrokerAppEntry = Record<string, unknown> & {
383
+ organizationId: string;
384
+ key: string;
385
+ name: string;
386
+ clientIds: string[];
387
+ origins: string[];
388
+ };
389
+ /**
390
+ * One `AUTH_APPS[index]` entry, checked the way
391
+ * `wtfalch/auth/broker/src/apps.ts`'s own `parseApps`/`parseAppFields`
392
+ * (https://github.com/wtfalch/auth/blob/main/broker/src/apps.ts) check it at
393
+ * boot, in the same order: a `key` of at least 32 characters and a non-empty
394
+ * `organizationId`; a `name` that passes the broker's own `isSafeText` rule
395
+ * (#218); a `productName`, when present, that is a string and also passes it
396
+ * (#218); a `from` that passes {@link isValidSenderFrom}; a `locale` of
397
+ * `'en'` or `'nb'` when present; non-blank `clientIds`; and `origins` each in
398
+ * {@link isCanonicalOrigin} form. #211 round 2: on a bad
399
+ * entry this throws naming the broker app id and the entry's index, never
400
+ * the value -- `key` may hold another organisation's secret, and even
401
+ * `organizationId`/`name` are not this step's to print in an error only an
402
+ * operator reads. `entry` may be anything at all (`null`, a string, an
403
+ * array): every check below is a `typeof`/`Array.isArray` guard first, so a
404
+ * malformed entry never reaches a property read on a non-object and throws a
405
+ * `TypeError` instead of this named error.
406
+ */
407
+ export declare function validateBrokerAppEntry(entry: unknown, brokerAppId: string, index: number): BrokerAppEntry;
371
408
  /**
372
409
  * #172/#201: mints Archon's own AI management key -- the plain bearer key
373
410
  * (`wtfk_...`) `AiOrganisationPort` (`executor.ts`) authenticates with,
@@ -500,6 +537,51 @@ export declare function createAiManagementKeyMinter(options: {
500
537
  fetchImpl?: typeof fetch;
501
538
  now?: () => number;
502
539
  }): AiManagementKeyMinter;
540
+ /**
541
+ * #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
542
+ * `broker/server.ts`) refuses to start without, that `provisionPlatformAuth`
543
+ * itself has no way to supply yet -- `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`
544
+ * (#209 mints and pushes this once it lands) and `RESEND_API_TOKEN` (#210,
545
+ * blocked on `wtfalch/auth#138`). Read from the broker's own live env, not a
546
+ * recorded scope id: whichever of #209/#210 lands first (or a human, setting
547
+ * either directly on the app) is enough to clear that one input, in either
548
+ * order, with no coupling to this step's own execution.
549
+ *
550
+ * #211 round 3: `AUTH_APPS` is a third required input -- the broker's own
551
+ * `parseApps` refuses to start with no `AUTH_APPS` set or an empty array
552
+ * (`wtfalch/auth/broker/src/apps.ts`), and this check used to name only the
553
+ * other two, so a deploy could go ahead with a broker that still would not
554
+ * come up. Also treat `null`/`undefined` as missing, not only `''`: the
555
+ * `CoolifyPort.listEnvs` doc comment already warns a live value does not
556
+ * always match its declared `string` type.
557
+ */
558
+ export declare function missingBrokerInputs(coolify: Pick<CoolifyPort, 'listEnvs'>, brokerAppId: string): Promise<string[]>;
559
+ /**
560
+ * #211 round 5: the one rule `provisionPlatformAuth` and
561
+ * `provisionPlatformArchon` both call to decide whether the broker needs a
562
+ * (re)deploy -- replacing the two ad hoc "was it ever ostensibly deployed"
563
+ * booleans each function used to keep, which is what let a recreated broker,
564
+ * and a crash between writing `AUTH_APPS` and deploying, both go unnoticed
565
+ * (the round 5 issue text's own diagnosis: "they share one cause"). `false`
566
+ * while any required input is still missing -- deploying a broker that
567
+ * cannot start is pointless regardless of what changed. Otherwise `true`
568
+ * when nothing has ever been recorded, when the broker that was deployed is
569
+ * a different app (recreated under a new id), or when the `AUTH_APPS` that
570
+ * was deployed is not the `AUTH_APPS` that is live/about-to-be-live now.
571
+ * `authAppsRaw` is that exact string -- read live by `provisionPlatformAuth`,
572
+ * which never writes `AUTH_APPS` itself, or supplied by
573
+ * `provisionPlatformArchon` as the string it already knows it just wrote (or
574
+ * left unchanged), so neither caller needs a second read to agree with
575
+ * itself about what is now true of the app.
576
+ */
577
+ export declare function brokerDeployNeeded(missingInputs: readonly string[], brokerAppId: string, authAppsRaw: string | undefined, recordedDeploy: BrokerDeployRecord | null): boolean;
578
+ /**
579
+ * #211 round 5: the {@link BrokerDeployRecord} JSON a successful deploy
580
+ * writes under {@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY} (via each
581
+ * caller's own `record()`), replacing the plain broker-id string round 2-4
582
+ * wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
583
+ */
584
+ export declare function brokerDeployRecordValue(brokerAppId: string, authAppsRaw: string | undefined): string;
503
585
  /**
504
586
  * #171: everything {@link provisionPlatformFiles} needs beyond
505
587
  * {@link PlatformStepExecutionContext}, bundled for the same reason as
@@ -607,10 +689,21 @@ export declare const INTEGRATIONS_MANAGEMENT_KEY_ID = "foundry-1";
607
689
  * platform database's dump lands in. A person creates or confirms the
608
690
  * bucket and these credentials by hand (the issue's own "Human steps"); this
609
691
  * package only ever registers the bucket with Coolify and schedules the
610
- * dump, never creates or reads the bucket itself.
692
+ * dump, never creates or reads the bucket itself. `raw` carries the S3
693
+ * storage update call, `PATCH /s3-storages/{uuid}` -- checked against the
694
+ * same live Coolify 4.3.8 instance `@wtfalch/providers`' own CLAUDE.md
695
+ * records as the last version this estate verified its `{uuid}` routes
696
+ * against (that package's own "Known traps": the bundled `openapi.yaml`
697
+ * omits every one of them, so `routes/api.php`-level comments are what to
698
+ * trust). `raw` also carries the schedule-repoint call (#257 fix round 3),
699
+ * `PATCH /databases/{uuid}/backups/{backup_uuid}`, documented in Coolify's
700
+ * public API reference ("Update database backup") as taking an
701
+ * `s3_storage_uuid` field. `CoolifyPort` has not wrapped either route with a
702
+ * typed method, so both go through the same `raw` escape hatch `coolifyApp`'s
703
+ * own stop/start calls use for a REST route the port has not wrapped yet.
611
704
  */
612
705
  export interface PlatformBackupDeps {
613
- coolify: Pick<CoolifyPort, 'listS3Storages' | 'createS3Storage' | 'listDatabaseBackups' | 'createDatabaseBackup'>;
706
+ coolify: Pick<CoolifyPort, 'listS3Storages' | 'createS3Storage' | 'listDatabaseBackups' | 'createDatabaseBackup' | 'raw'>;
614
707
  r2: {
615
708
  endpoint: string;
616
709
  bucket: string;