@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.
@@ -0,0 +1,24 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ /**
3
+ * The full names an entry is served at. With `hosting.usesDomain` off, or no
4
+ * domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
5
+ * and every other entry is internal. `'*'` names nothing here; the wildcard is
6
+ * acted on elsewhere.
7
+ */
8
+ export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
9
+ /**
10
+ * The one primary name of the org's app: its first, or the name the app was
11
+ * served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
12
+ * and the jobs registration read it.
13
+ */
14
+ export declare function appHostname(org: DeclaredOrg): string;
15
+ /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
16
+ export declare function legacyAppHostname(org: DeclaredOrg): string;
17
+ /**
18
+ * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
19
+ * #164): a site's two names, every entry's names, an own-domain calls name and
20
+ * Agora's. An org that does not host keeps the single name it always had.
21
+ */
22
+ export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
23
+ /** The ingress names that get a DNS record: those under the org's own domain. */
24
+ export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string): string[];
@@ -0,0 +1,56 @@
1
+ import { APP_ENTRY_KEY } from '../org.js';
2
+ import { agoraHostnameFor } from './agora.js';
3
+ /**
4
+ * The full names an entry is served at. With `hosting.usesDomain` off, or no
5
+ * domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
6
+ * and every other entry is internal. `'*'` names nothing here; the wildcard is
7
+ * acted on elsewhere.
8
+ */
9
+ export function entryHostnames(org, entry) {
10
+ const { domain } = org;
11
+ if (!org.hosting.usesDomain || !domain) {
12
+ return entry.key === APP_ENTRY_KEY ? [`${org.slug}.oikos.host`] : [];
13
+ }
14
+ return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
15
+ }
16
+ /**
17
+ * The one primary name of the org's app: its first, or the name the app was
18
+ * served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
19
+ * and the jobs registration read it.
20
+ */
21
+ export function appHostname(org) {
22
+ const entry = org.hosting.entries.find((e) => e.key === APP_ENTRY_KEY);
23
+ const first = entry ? entryHostnames(org, entry)[0] : undefined;
24
+ return first ?? legacyAppHostname(org);
25
+ }
26
+ /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
27
+ export function legacyAppHostname(org) {
28
+ return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
29
+ }
30
+ /**
31
+ * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
32
+ * #164): a site's two names, every entry's names, an own-domain calls name and
33
+ * Agora's. An org that does not host keeps the single name it always had.
34
+ */
35
+ export function tunnelIngressHostnames(org) {
36
+ const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
37
+ if (org.hosting.enabled) {
38
+ for (const entry of org.hosting.entries)
39
+ hostnames.push(...entryHostnames(org, entry));
40
+ }
41
+ else if (hostnames.length === 0) {
42
+ hostnames.push(legacyAppHostname(org));
43
+ }
44
+ if (org.calls.enabled && org.calls.usesDomain && org.domain) {
45
+ hostnames.push(`calls.${org.domain}`);
46
+ }
47
+ // #164: Agora always moves onto `agora.<domain>` once `org.domain` is set.
48
+ if (org.domain) {
49
+ hostnames.push(agoraHostnameFor(org));
50
+ }
51
+ return [...new Set(hostnames)];
52
+ }
53
+ /** The ingress names that get a DNS record: those under the org's own domain. */
54
+ export function tunnelDnsHostnames(org, domain) {
55
+ return tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
56
+ }
@@ -13,7 +13,7 @@ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, Confirmatio
13
13
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
14
14
  export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
15
15
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
16
- export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
16
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
17
17
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
18
18
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
19
19
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
@@ -24,6 +24,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
24
24
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
25
25
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
26
26
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
27
+ export type { BrokerPort } from './broker-entries.js';
28
+ export { PlatformBrokerPort } from './broker-entries.js';
27
29
  export type { LoginHostBinding, LoginHostBindingsPort } from './login-host.js';
28
30
  export { loginHostnameFor } from './login-host.js';
29
31
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
@@ -6,12 +6,13 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.
6
6
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
7
7
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
8
8
  // #171: how a host signs files management requests with the delivered key.
9
- export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
9
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
10
10
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
11
11
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
12
12
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
13
13
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
14
14
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
15
+ export { PlatformBrokerPort } from './broker-entries.js';
15
16
  export { loginHostnameFor } from './login-host.js';
16
17
  export { checkMailIdentityAudience } from './mail-identity.js';
17
18
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
@@ -0,0 +1,8 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ /**
3
+ * The names under the org's domain that foundry owns, so no hosting entry may
4
+ * take one: Agora, the cms studio when the site is on, calls when it uses the
5
+ * domain, the sign-in host when the issuer does, and every platform service
6
+ * hostname that falls under the domain. Empty for an org with no domain.
7
+ */
8
+ export declare function lockedHostnames(org: DeclaredOrg, platformHostnames: readonly string[]): Set<string>;
@@ -0,0 +1,26 @@
1
+ import { agoraHostnameFor } from './agora.js';
2
+ import { loginHostnameFor } from './login-host.js';
3
+ /**
4
+ * The names under the org's domain that foundry owns, so no hosting entry may
5
+ * take one: Agora, the cms studio when the site is on, calls when it uses the
6
+ * domain, the sign-in host when the issuer does, and every platform service
7
+ * hostname that falls under the domain. Empty for an org with no domain.
8
+ */
9
+ export function lockedHostnames(org, platformHostnames) {
10
+ const { domain } = org;
11
+ const locked = new Set();
12
+ if (!domain)
13
+ return locked;
14
+ locked.add(agoraHostnameFor(org));
15
+ if (org.site.enabled)
16
+ locked.add(`cms.${domain}`);
17
+ if (org.calls.enabled && org.calls.usesDomain)
18
+ locked.add(`calls.${domain}`);
19
+ if (org.issuer.usesDomain)
20
+ locked.add(loginHostnameFor(org));
21
+ for (const hostname of platformHostnames) {
22
+ if (hostname.endsWith(`.${domain}`))
23
+ locked.add(hostname);
24
+ }
25
+ return locked;
26
+ }
@@ -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',
@@ -633,13 +673,12 @@ function stepField(action) {
633
673
  *
634
674
  * Three sources, in this order:
635
675
  * - fields no planner step exists for yet, so no apply can make them true:
636
- * SSO, guests and solo when turned on, an adopted app in the `repo` form
637
- * (#7's `#15` comment: "declared but refused by `apply`"), and calls or
638
- * hosting on a domain the org has not recorded (#145: calls on the org's
639
- * own domain now has a planner step, the same way hosting's own domain
640
- * does, so it only fails for that one missing-domain reason, not
641
- * unconditionally). `bespokeAppRepo` is not one of these: `coolify:app`
642
- * reads it (#240), so it converges with that step like any other field;
676
+ * SSO, guests and solo when turned on, and calls or hosting on a domain
677
+ * the org has not recorded (#145: calls on the org's own domain now has a
678
+ * planner step, the same way hosting's own domain does, so it only fails
679
+ * for that one missing-domain reason, not unconditionally). An app repo is
680
+ * not one of these: `coolify:app` reads the `app` entry's `repo`, so it
681
+ * converges with that step like any other field;
643
682
  * - chat's reverse re-key, which the planner surfaces but which is not built
644
683
  * (#7 amendment 3), so it counts as failed even when no step threw;
645
684
  * - on a failed apply, the field of every planned step from the one that
@@ -663,8 +702,6 @@ function failedFields(org, detail, completed, serviceIds) {
663
702
  failed.add('guests.enabled');
664
703
  if (org.solo.enabled)
665
704
  failed.add('solo.enabled');
666
- if (org.adoptedApp?.form === 'repo')
667
- failed.add('adoptedApp');
668
705
  // `planCallsService`/`planTunnel`/`planZone` all share this same domain
669
706
  // requirement (#145): a declared `usesDomain: true` with no recorded
670
707
  // domain plans no step for any of them either.
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, OrgId } from '../org.js';
1
+ import type { DeclaredOrg, HostingEntry, OrgId } from '../org.js';
2
2
  import { type Plan, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { PlannedStep } from './diff.js';
@@ -118,6 +118,10 @@ export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_
118
118
  export declare const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = "archon_gate_closed";
119
119
  export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
120
120
  export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
121
+ /** #283: the created instance's id, recorded once its owner token is delivered. */
122
+ export declare const PLATFORM_AUTH_INSTANCE_SCOPE_KEY = "zitadel_platform_instance_id";
123
+ /** #283: an instance that may exist without a delivered owner token; blocks creating another. */
124
+ export declare const PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY = "zitadel_platform_instance_undelivered";
121
125
  /**
122
126
  * #216 (building on #209/#215): the `IAM_LOGIN_CLIENT` machine user minted
123
127
  * for the login app and the broker, mirroring
@@ -312,24 +316,67 @@ export declare function platformMailMxHost(apex: string): string;
312
316
  export declare const ORG_MAIL_APPLIED_APEX_SCOPE_KEY = "mail_applied_apex";
313
317
  export declare const ORG_MAIL_APPLIED_HOST_SCOPE_KEY = "mail_applied_host";
314
318
  /**
315
- * #243: the org's `hosting.appEnv` values the last `coolify:app` apply pushed,
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
+ */
323
+ export declare function appStepKey(org: DeclaredOrg, entryKey?: string): string;
324
+ /**
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.
328
+ */
329
+ export declare function appNameFor(org: DeclaredOrg, entry: HostingEntry): string;
330
+ /**
331
+ * #243: the entry's `env` values the last `coolify:app` apply pushed,
316
332
  * recorded as one JSON object (env name to value; ids and a domain, never a
317
333
  * secret). `planApp` plans the step again when {@link resolveAppEnv} differs
318
- * from it, so a changed map reaches an app that is already live.
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.
319
337
  */
320
- export declare const APP_ENV_APPLIED_SCOPE_KEY = "app_env_applied";
338
+ export declare function appEnvAppliedScopeKey(entryKey: string): string;
321
339
  /**
322
340
  * #239: set to '1' once an app has been restarted after its database URLs
323
341
  * were pushed. Not a URL. Without it a crash between the push and the restart
324
342
  * would leave a stale container that no plan notices.
325
343
  */
326
- export declare const DB_ENV_APPLIED_SCOPE_KEY = "db_env_applied";
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[];
327
374
  /**
328
- * The `hosting.appEnv` entries whose source has a recorded value, in env-name
329
- * order, and the env names whose source has none yet. Shared by `planApp` and
330
- * the executor's `pushAppEnv` so both read the same recorded ids.
375
+ * The entry's `env` values that have a value, in env-name order, and the env
376
+ * names whose source has none yet. Shared by `planApp` and the executor's
377
+ * `pushAppEnv` so both read the same recorded ids.
331
378
  */
332
- export declare function resolveAppEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
379
+ export declare function resolveAppEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
333
380
  values: Record<string, string>;
334
381
  unresolved: Array<[string, string]>;
335
382
  };
@@ -498,11 +545,23 @@ export interface PlanPlatformOptions {
498
545
  * include list is not the declared one. `auth` (#216 round 4): the
499
546
  * login-client PAT recorded under
500
547
  * {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY} is no longer the sole
501
- * 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.
502
559
  */
503
560
  drifted?: {
504
561
  archon?: boolean;
505
562
  auth?: boolean;
563
+ backupStorage?: boolean;
564
+ backupStorageBlocked?: string;
506
565
  };
507
566
  }
508
567
  /**