@wtfalch/foundry 0.8.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.
@@ -1,5 +1,7 @@
1
1
  import { VERSIONS } from '../bootstrap/versions.js';
2
+ import { APP_ENTRY_KEY } from '../org.js';
2
3
  import { VERSION_CHANGE_KIND, } from '../plan.js';
4
+ import { legacyAppHostname } from './hostnames.js';
3
5
  /**
4
6
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
5
7
  * mirroring `diff.ts`'s `toPlan`. There is no separate platform-shaped `Plan`
@@ -136,6 +138,10 @@ export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_a
136
138
  export const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = 'archon_gate_closed';
137
139
  export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
138
140
  export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
141
+ /** #283: the created instance's id, recorded once its owner token is delivered. */
142
+ export const PLATFORM_AUTH_INSTANCE_SCOPE_KEY = 'zitadel_platform_instance_id';
143
+ /** #283: an instance that may exist without a delivered owner token; blocks creating another. */
144
+ export const PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_instance_undelivered';
139
145
  /**
140
146
  * #216 (building on #209/#215): the `IAM_LOGIN_CLIENT` machine user minted
141
147
  * for the login app and the broker, mirroring
@@ -343,24 +349,78 @@ export function platformMailMxHost(apex) {
343
349
  export const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = 'mail_applied_apex';
344
350
  export const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = 'mail_applied_host';
345
351
  /**
346
- * #243: the org's `hosting.appEnv` values the last `coolify:app` apply pushed,
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.
355
+ */
356
+ export function appStepKey(org, entryKey = APP_ENTRY_KEY) {
357
+ return `${org.id}:${entryKey}`;
358
+ }
359
+ /**
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.
363
+ */
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,
347
369
  * recorded as one JSON object (env name to value; ids and a domain, never a
348
370
  * secret). `planApp` plans the step again when {@link resolveAppEnv} differs
349
- * from it, so a changed map reaches an app that is already live.
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.
350
374
  */
351
- export const APP_ENV_APPLIED_SCOPE_KEY = 'app_env_applied';
375
+ export function appEnvAppliedScopeKey(entryKey) {
376
+ return `app_env_applied.${entryKey}`;
377
+ }
352
378
  /**
353
379
  * #239: set to '1' once an app has been restarted after its database URLs
354
380
  * were pushed. Not a URL. Without it a crash between the push and the restart
355
381
  * would leave a stale container that no plan notices.
356
382
  */
357
- export const DB_ENV_APPLIED_SCOPE_KEY = 'db_env_applied';
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
+ }
358
418
  /**
359
- * The `hosting.appEnv` entries whose source has a recorded value, in env-name
360
- * order, and the env names whose source has none yet. Shared by `planApp` and
361
- * the executor's `pushAppEnv` so both read the same recorded ids.
419
+ * The entry's `env` values that have a value, in env-name order, and the env
420
+ * names whose source has none yet. Shared by `planApp` and the executor's
421
+ * `pushAppEnv` so both read the same recorded ids.
362
422
  */
363
- export function resolveAppEnv(org, recorded) {
423
+ export function resolveAppEnv(org, entry, recorded) {
364
424
  const sources = {
365
425
  filesOrganisationId: recorded[`files:organisation:${org.id}`],
366
426
  aiOrganisationId: recorded[`ai:organisation:${org.id}`],
@@ -368,12 +428,12 @@ export function resolveAppEnv(org, recorded) {
368
428
  };
369
429
  const values = {};
370
430
  const unresolved = [];
371
- for (const [name, source] of Object.entries(org.hosting.appEnv).sort(([a], [b]) => a < b ? -1 : 1)) {
372
- const value = sources[source];
431
+ for (const [name, declared] of Object.entries(entry.env).sort(([a], [b]) => (a < b ? -1 : 1))) {
432
+ const value = typeof declared === 'string' ? declared : sources[declared.from];
373
433
  if (value)
374
434
  values[name] = value;
375
- else
376
- unresolved.push([name, source]);
435
+ else if (typeof declared !== 'string')
436
+ unresolved.push([name, declared.from]);
377
437
  }
378
438
  return { values, unresolved };
379
439
  }
@@ -561,7 +621,7 @@ export function planPlatform(platform, live, options = {}) {
561
621
  plannedSteps.push(archon.step);
562
622
  // #257: after every service above, so a database any of them just
563
623
  // recorded is already visible to `recordedIds` on this same plan.
564
- const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false);
624
+ const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false, drifted.backupStorage ?? false, drifted.backupStorageBlocked);
565
625
  plannedSteps.push(...backups.steps);
566
626
  // #251: one line per service whose recorded applied ref no longer matches
567
627
  // what the next apply would use (or, for zitadel, what is actually live).
@@ -763,15 +823,36 @@ export const PLATFORM_BACKUP_SERVICE_KEYS = [
763
823
  * Once a step's own `coolify:backup:<service>` resource is live, it is
764
824
  * claimed and never re-planned: like every other provision-only step here,
765
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.
766
845
  */
767
- function planBackups(byKey, claimed, recordedIds, executorMissing) {
846
+ function planBackups(byKey, claimed, recordedIds, executorMissing, storageDrifted, storageBlocked) {
768
847
  const steps = [];
769
848
  const blocked = [];
849
+ let anyDatabase = false;
770
850
  for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
771
851
  const key = resourceKey('coolify', 'backup', service);
772
852
  const live = byKey.get(key);
773
853
  if (live) {
774
854
  claimed.add(key);
855
+ anyDatabase = true;
775
856
  continue;
776
857
  }
777
858
  // No database yet is not reported: unlike a Coolify reader or a backup
@@ -783,6 +864,7 @@ function planBackups(byKey, claimed, recordedIds, executorMissing) {
783
864
  const databaseId = recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
784
865
  if (!databaseId)
785
866
  continue;
867
+ anyDatabase = true;
786
868
  if (executorMissing) {
787
869
  blocked.push({
788
870
  description: `platform:backup:${service} is blocked, not planned: the host built the platform executor without its backup dependencies`,
@@ -804,6 +886,33 @@ function planBackups(byKey, claimed, recordedIds, executorMissing) {
804
886
  },
805
887
  });
806
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
+ }
807
916
  return { steps, blocked };
808
917
  }
809
918
  /**
@@ -999,6 +1108,16 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
999
1108
  blocked: `the platform's sign-in is blocked, not planned: the admin key of ZITADEL machine user ${undelivered} was minted but never delivered and cannot be shown again; delete that user in ZITADEL and remove the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`,
1000
1109
  };
1001
1110
  }
1111
+ // #283: an instance may exist whose owner token never reached the host.
1112
+ const instanceLost = recordedIds[PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY];
1113
+ if (instanceLost &&
1114
+ !recordedIds[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
1115
+ !recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
1116
+ return {
1117
+ step: null,
1118
+ blocked: `the platform's sign-in is blocked, not planned: a ZITADEL instance (${instanceLost}) may exist without a delivered owner token; delete it with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, then remove the recorded key ${PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY}`,
1119
+ };
1120
+ }
1002
1121
  // #211 round 6: the executor itself is unusable on this host -- nothing to
1003
1122
  // plan, whatever the broker's recorded state says.
1004
1123
  if (executorMissing)
@@ -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;
@@ -223,6 +226,13 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
223
226
  private executeSfu;
224
227
  private coolifyJobs;
225
228
  }
229
+ /**
230
+ * #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
231
+ * delivered under, once. A host reads it back to build `zitadelForInstance`
232
+ * (see {@link provisionPlatformAuth}'s doc comment). Distinct from the admin
233
+ * key delivered under `zitadel:platform:admin`.
234
+ */
235
+ export declare const PLATFORM_AUTH_INSTANCE_BEARER_REF = "zitadel:platform:instanceBearer";
226
236
  /**
227
237
  * The fixed description Stalwart's admin API key is minted under
228
238
  * (`StalwartClient.createApiKey`'s own signature returns only the secret,
@@ -361,6 +371,40 @@ export declare const PLATFORM_ARCHON_ACCESS_APP_NAME = "foundry:platform:archon"
361
371
  export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
362
372
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
363
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;
364
408
  /**
365
409
  * #172/#201: mints Archon's own AI management key -- the plain bearer key
366
410
  * (`wtfk_...`) `AiOrganisationPort` (`executor.ts`) authenticates with,
@@ -493,6 +537,51 @@ export declare function createAiManagementKeyMinter(options: {
493
537
  fetchImpl?: typeof fetch;
494
538
  now?: () => number;
495
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;
496
585
  /**
497
586
  * #171: everything {@link provisionPlatformFiles} needs beyond
498
587
  * {@link PlatformStepExecutionContext}, bundled for the same reason as
@@ -600,10 +689,21 @@ export declare const INTEGRATIONS_MANAGEMENT_KEY_ID = "foundry-1";
600
689
  * platform database's dump lands in. A person creates or confirms the
601
690
  * bucket and these credentials by hand (the issue's own "Human steps"); this
602
691
  * package only ever registers the bucket with Coolify and schedules the
603
- * 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.
604
704
  */
605
705
  export interface PlatformBackupDeps {
606
- coolify: Pick<CoolifyPort, 'listS3Storages' | 'createS3Storage' | 'listDatabaseBackups' | 'createDatabaseBackup'>;
706
+ coolify: Pick<CoolifyPort, 'listS3Storages' | 'createS3Storage' | 'listDatabaseBackups' | 'createDatabaseBackup' | 'raw'>;
607
707
  r2: {
608
708
  endpoint: string;
609
709
  bucket: string;