@wtfalch/foundry 0.30.0 → 0.32.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.
package/dist/apply.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OrgId } from './org.js';
2
2
  import type { Plan } from './plan.js';
3
+ import type { FoundryApplyOptions } from './plan/plan-apply.js';
3
4
  import type { AppliedState } from './state.js';
4
5
  /** One destroy step starting, finishing or failing; `index` counts from 1 up to `total`. */
5
6
  export interface DestroyProgress {
@@ -30,8 +31,12 @@ export interface PlanApply {
30
31
  * required whenever `plan` contains a step with `destroys` or `migrates`
31
32
  * true (#9). Implementations must refuse to apply when the live state a
32
33
  * fresh plan would compute no longer matches this `plan` (#9, #10).
34
+ * @param options `confirmUpgrade` names the services whose version change
35
+ * is confirmed. Implementations must refuse, before any step runs, a plan
36
+ * whose `versionChanges` holds a `needsConfirmation` line for a service
37
+ * `confirmUpgrade` does not name exactly.
33
38
  */
34
- apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
39
+ apply(plan: Plan, confirmedOrgName: string, options?: FoundryApplyOptions): Promise<AppliedState>;
35
40
  /**
36
41
  * Deletes every resource foundry created and recorded for `id`, then the
37
42
  * declared org itself.
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requ
10
10
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
11
11
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
12
12
  export type { Checkpoint, LastApplied, AppliedState, DestroyRecord, DestroyStepRecord, } from './state.js';
13
- export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
13
+ export type { PlanStep, UnmanagedItem, Plan, VersionChangeItem } from './plan.js';
14
14
  export type { AppliedVersion, AppliedVersionsStore, DeclaredOrgStore, HeldLease, MutationLease, PlatformStore, RequestedChangeStore, StaleUpdate, UpdateOptions, } from './store.js';
15
15
  export { StaleLeaseError } from './store.js';
16
16
  export type { PlanApply, DestroyProgress } from './apply.js';
@@ -18,11 +18,11 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
18
18
  export { createPostgresAuditLog } from './store/audit.js';
19
19
  export type { FoundryStoreDb } from './store/postgres.js';
20
20
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
21
- export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
21
+ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, OrgPostgresPlan, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, UnconfirmedUpgrade, } from './plan/index.js';
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
23
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
24
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
25
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
25
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
26
26
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
27
27
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
28
28
  export type { PlatformOrgClientFactories } from './plan/index.js';
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
15
  export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
16
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
17
17
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
18
18
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
19
19
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
package/dist/org.d.ts CHANGED
@@ -288,7 +288,12 @@ export declare const APP_ENTRY_KEY = "app";
288
288
  export interface HostingEntry {
289
289
  /** A DNS label, unique per org. It goes into step ids and scope keys, so it never holds `:` or `.`. */
290
290
  key: string;
291
- /** The repo under `wtfalch` the app builds from. `null` is the shared standard app (`executor.ts`'s `DEFAULT_APP_REPO`). */
291
+ /**
292
+ * The repo under `wtfalch` the app builds from. `null` is the shape
293
+ * migration 0029 wrote for every org with no `bespokeAppRepo`; there is no
294
+ * shared default app any more (#447), so `plan/diff.ts`'s `planApp` blocks
295
+ * it at plan time instead of provisioning it.
296
+ */
292
297
  repo: string | null;
293
298
  /** The app's folder in `repo`. `null` is the repo root. */
294
299
  folder: string | null;
package/dist/org.js CHANGED
@@ -502,8 +502,8 @@ const WORKER_SHA256 = /^[0-9a-f]{64}$/;
502
502
  const WORKER_NAME_FIXED_LENGTH = 8 + 1 + '-edge'.length;
503
503
  const WORKER_NAME_MAX_LENGTH = 63;
504
504
  /**
505
- * `worker`: the entry's own repo (never the shared standard app -- entries
506
- * with `repo: null` have no Worker source of their own), an `https://`
505
+ * `worker`: the entry's own repo (entries with `repo: null` have no Worker
506
+ * source of their own), an `https://`
507
507
  * artifact URL with a lowercase-hex sha256 (never a bare path or relative
508
508
  * file -- estate DECISIONS.md, 2026-10-04, rules out any checkout or
509
509
  * bundling inside foundry), a route whose zone is the org's own domain
@@ -519,7 +519,7 @@ function validateEntryWorker(entry, hosting, domain) {
519
519
  if (!hosting.enabled)
520
520
  return `${at} requires hosting.enabled`;
521
521
  if (entry.repo === null) {
522
- return `${at} needs its own repo (repo: null is the shared standard app, not a Worker source)`;
522
+ return `${at} needs its own repo (repo: null is not a Worker source)`;
523
523
  }
524
524
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
525
525
  return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
@@ -0,0 +1,17 @@
1
+ import type { VersionChangeItem } from '../plan.js';
2
+ export interface UnconfirmedUpgrade {
3
+ service: string;
4
+ from?: string;
5
+ to?: string;
6
+ }
7
+ /**
8
+ * Whether a change of `service` from `from` to `to` needs a confirmation:
9
+ * any change on a stateful service, otherwise a major or minor difference.
10
+ * A git ref or branch name has no major or minor, so it never counts as a jump.
11
+ */
12
+ export declare function versionChangeNeedsConfirmation(service: string, from: string, to: string): boolean;
13
+ /**
14
+ * The lines that need a confirmation `confirmUpgrade` does not give. A name
15
+ * must match `VersionChangeItem.service` exactly; no value confirms all.
16
+ */
17
+ export declare function unconfirmedUpgrades(versionChanges: readonly VersionChangeItem[] | undefined, confirmUpgrade: readonly string[] | undefined): UnconfirmedUpgrade[];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The `VersionChangeItem.service` names whose data the service migrates on
3
+ * first boot with a new image, so going back to the old image may not undo
4
+ * it. Stalwart is reported as `mail`, the name of its plan step. Postgres
5
+ * has no version-change line yet; its name is here for when it gets one.
6
+ */
7
+ const STATEFUL_VERSION_CHANGE_SERVICES = new Set([
8
+ 'zitadel',
9
+ 'mail',
10
+ 'synapse',
11
+ 'mas',
12
+ 'postgres',
13
+ ]);
14
+ function majorMinor(version) {
15
+ const match = /^v?(\d+)\.(\d+)\.\d+/.exec(version.trim());
16
+ return match ? `${match[1]}.${match[2]}` : null;
17
+ }
18
+ /**
19
+ * Whether a change of `service` from `from` to `to` needs a confirmation:
20
+ * any change on a stateful service, otherwise a major or minor difference.
21
+ * A git ref or branch name has no major or minor, so it never counts as a jump.
22
+ */
23
+ export function versionChangeNeedsConfirmation(service, from, to) {
24
+ if (from === to)
25
+ return false;
26
+ if (STATEFUL_VERSION_CHANGE_SERVICES.has(service))
27
+ return true;
28
+ const a = majorMinor(from);
29
+ const b = majorMinor(to);
30
+ return a !== null && b !== null && a !== b;
31
+ }
32
+ /**
33
+ * The lines that need a confirmation `confirmUpgrade` does not give. A name
34
+ * must match `VersionChangeItem.service` exactly; no value confirms all.
35
+ */
36
+ export function unconfirmedUpgrades(versionChanges, confirmUpgrade) {
37
+ const confirmed = new Set(confirmUpgrade ?? []);
38
+ const seen = new Set();
39
+ const result = [];
40
+ for (const line of versionChanges ?? []) {
41
+ if (!line.needsConfirmation || confirmed.has(line.service) || seen.has(line.service))
42
+ continue;
43
+ seen.add(line.service);
44
+ result.push({ service: line.service, from: line.from, to: line.to });
45
+ }
46
+ return result;
47
+ }
package/dist/plan/diff.js CHANGED
@@ -2323,10 +2323,17 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
2323
2323
  step: { description: describeProvision(), destroys: false, migrates: false },
2324
2324
  };
2325
2325
  }
2326
+ /**
2327
+ * The git source of the retired shared default app (`apps/hestia`, deleted in
2328
+ * #447). {@link planApp} uses it only to recognise a live app still built
2329
+ * from it, which it deletes instead of parking: nothing can redeploy it.
2330
+ */
2331
+ const RETIRED_DEFAULT_APP_REPO = 'wtfalch/foundry';
2326
2332
  /**
2327
2333
  * The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
2328
2334
  * per-org singleton resource modeled on {@link planSite}: park, not delete,
2329
- * on the way off (#7 corrections 1).
2335
+ * on the way off (#7 corrections 1) -- except a live app built from
2336
+ * {@link RETIRED_DEFAULT_APP_REPO}, which is deleted (#447).
2330
2337
  *
2331
2338
  * #75: every app foundry deploys also carries a durable log directory (a
2332
2339
  * persistent volume mounted at `/app/logs`, `LOG_DIR` in its env) so a
@@ -2411,10 +2418,49 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
2411
2418
  });
2412
2419
  }
2413
2420
  }
2414
- const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
2421
+ // #447: no shared default app any more. Migration 0029 wrote `repo: null`
2422
+ // for every org without a bespoke app and nothing rewrites that data, so
2423
+ // the entry stays declared but is blocked here and never provisioned.
2424
+ const noRepo = entry !== undefined && entry.repo === null;
2425
+ if (noRepo) {
2426
+ blocked.push({
2427
+ description: `${org.slug}'s ${label} repo is blocked, not planned: there is no shared default app any more (#447)`,
2428
+ key: blockedKey('repo'),
2429
+ });
2430
+ }
2431
+ const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !noRepo, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
2432
+ // #447: only the live app's own `git_repository` decides, never the
2433
+ // declared entry (a `repo: null` entry can sit over a bespoke app, which is
2434
+ // parked as before). Only the `app` key ever held the default app, and an
2435
+ // entry that declares a repo is never a candidate. The reader does not
2436
+ // surface the Dockerfile location, so the repo is the whole match. Checked
2437
+ // whether or not the app is already parked, so a parked leftover is not
2438
+ // stranded.
2439
+ if (entryKey === APP_ENTRY_KEY && (entry === undefined || noRepo)) {
2440
+ const liveApp = byKey.get(resourceKey('coolify', 'app', stepKey));
2441
+ if (liveApp?.config.git_repository === RETIRED_DEFAULT_APP_REPO) {
2442
+ claimed.add(resourceKey('coolify', 'app', stepKey));
2443
+ return {
2444
+ step: {
2445
+ action: {
2446
+ system: 'coolify',
2447
+ kind: 'app',
2448
+ key: stepKey,
2449
+ desired: { enabled: false, delete: true },
2450
+ },
2451
+ step: {
2452
+ description: `delete ${org.slug}'s ${label}: it still builds from the retired shared default app, which no longer exists to redeploy from (#447)`,
2453
+ destroys: true,
2454
+ migrates: false,
2455
+ },
2456
+ },
2457
+ blocked,
2458
+ };
2459
+ }
2460
+ }
2415
2461
  if (toggled)
2416
2462
  return { step: toggled, blocked };
2417
- if (!entry)
2463
+ if (!entry || noRepo)
2418
2464
  return { step: null, blocked };
2419
2465
  const live = byKey.get(resourceKey('coolify', 'app', stepKey));
2420
2466
  if (!live)
@@ -230,24 +230,6 @@ const RATE_LIMIT_RULE_DESCRIPTION = 'foundry: rate limit sign-in paths';
230
230
  */
231
231
  const RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS = 10;
232
232
  const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
233
- /**
234
- * The shared standard app a level-1 org with no `bespokeAppRepo` gets
235
- * deployed (gap P1, `.plans/2026-09-26-level-1-org-map.md`): one repo, the
236
- * same for every such org, decided by William 2026-09-26. `coolifyApp` below
237
- * names it as the git source in exactly the shape `bespokeAppRepo` itself
238
- * uses (`org.ts`'s `DeclaredOrg.bespokeAppRepo: string | null`) whenever an
239
- * org declares none of its own — the repo may not exist yet at decision
240
- * time, and Coolify's own GitHub App create call is what will fail loudly
241
- * against it, the same as any other repo name that does not exist.
242
- *
243
- * Hestia moved into this repo's own `apps/hestia` (estate DECISIONS.md,
244
- * 2026-09-28; the hestia repo archives once this move lands), so the
245
- * standard app now builds from foundry's own repo, told apart from
246
- * `packages/foundry` by `dockerfile_location` — the same one-repo,
247
- * multiple-apps shape `cmsSite`/`CMS_APP_DOCKERFILE` already use below.
248
- */
249
- const DEFAULT_APP_REPO = 'wtfalch/foundry';
250
- const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
251
233
  /**
252
234
  * The shared repo `cmsSite` builds its cms admin app from, always, and its
253
235
  * site app from by default (https://github.com/wtfalch/cms) -- the
@@ -275,7 +257,7 @@ const CMS_APP_PORT = 3000;
275
257
  * #164: the repo `coolifyAgora` builds the org's Agora from
276
258
  * (https://github.com/wtfalch/agora) -- the issue's own "How" names this
277
259
  * exact Dockerfile, and it is the only build strategy any app this file
278
- * provisions ever uses ({@link DEFAULT_APP_DOCKERFILE}/{@link CMS_APP_DOCKERFILE}/
260
+ * provisions ever uses ({@link CMS_APP_DOCKERFILE}/
279
261
  * `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
280
262
  * `createGithubAppApplication` build-from-repo call, never a registry
281
263
  * image): "Coolify builds each org's Agora from the agora repo" is not a
@@ -1868,7 +1850,19 @@ export class FoundryStepExecutor {
1868
1850
  const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
1869
1851
  if (action.desired.enabled === false) {
1870
1852
  if (!recordedAppId) {
1871
- throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
1853
+ throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to ${action.desired.delete === true ? 'delete' : 'park'}`);
1854
+ }
1855
+ // `planApp` sets `delete` only on the retired default app. A 404 counts
1856
+ // as deleted, so a retry after a crash past the Coolify delete succeeds.
1857
+ if (action.desired.delete === true) {
1858
+ try {
1859
+ await this.ports.coolify.deleteApplication(recordedAppId);
1860
+ }
1861
+ catch (error) {
1862
+ if (!isNotFoundError(error))
1863
+ throw error;
1864
+ }
1865
+ return {};
1872
1866
  }
1873
1867
  await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
1874
1868
  return { resourceId: recordedAppId };
@@ -1876,10 +1870,12 @@ export class FoundryStepExecutor {
1876
1870
  const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
1877
1871
  if (!entry)
1878
1872
  throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
1879
- // A level-1 org with no bespoke app of its own gets the shared standard
1880
- // app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
1881
- // with no `repo`.
1882
- const appRepo = entry.repo ?? DEFAULT_APP_REPO;
1873
+ // `planApp` blocks a `repo: null` entry at plan time; reaching here with
1874
+ // one means a stale or hand-built plan.
1875
+ if (entry.repo === null) {
1876
+ throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' has no repo`);
1877
+ }
1878
+ const appRepo = entry.repo;
1883
1879
  const projectUuid = await this.ensureCoolifyProject(org, context);
1884
1880
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
1885
1881
  // The recorded id first; the name when nothing is recorded, or when the
@@ -1953,9 +1949,6 @@ export class FoundryStepExecutor {
1953
1949
  // copies `base_directory`, and reads `publish_directory` only for a
1954
1950
  // built app.
1955
1951
  base_directory: entry.folder === null ? undefined : `/${entry.folder}`,
1956
- // Only the shared standard app builds from this repo; a bespoke
1957
- // app's Dockerfile is at its own repo's root.
1958
- dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
1959
1952
  });
1960
1953
  appId = created.uuid;
1961
1954
  // Create sent every setting, so nothing is left to patch.
@@ -12,8 +12,9 @@ export type { PlatformOrgClientFactories } from './platform-org-ports.js';
12
12
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
13
13
  export type { AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot } from './adopt.js';
14
14
  export { AdoptRefusedError } from './adopt.js';
15
- export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
15
+ export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, FoundryApplyOptions, } from './plan-apply.js';
16
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
+ export type { UnconfirmedUpgrade } from './confirm-upgrade.js';
17
18
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
19
  export type { PlatformAuthDeps, PlatformAuthSmtp, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
19
20
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
@@ -5,7 +5,7 @@ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDUL
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
8
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
8
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, UpgradeConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
9
9
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
10
10
  // #171: how a host signs files management requests with the delivered key.
11
11
  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';
@@ -7,6 +7,7 @@ import type { AppliedState } from '../state.js';
7
7
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
8
8
  import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
9
9
  import { type AgoraHostPort } from './agora.js';
10
+ import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
10
11
  import { type StepAction } from './diff.js';
11
12
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
12
13
  import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
@@ -313,6 +314,15 @@ export declare class StalePlanError extends Error {
313
314
  export declare class ConfirmationRequiredError extends Error {
314
315
  constructor(orgId: OrgId);
315
316
  }
317
+ /**
318
+ * A plan holds a version change that needs confirmation
319
+ * (`VersionChangeItem.needsConfirmation`) and `confirmUpgrade` does not name
320
+ * its service exactly. `upgrades` lists every such service with its versions.
321
+ */
322
+ export declare class UpgradeConfirmationRequiredError extends Error {
323
+ readonly upgrades: readonly UnconfirmedUpgrade[];
324
+ constructor(upgrades: readonly UnconfirmedUpgrade[]);
325
+ }
316
326
  export declare class NotAuthorizedError extends Error {
317
327
  constructor(actor: AuditActor, orgId: OrgId);
318
328
  }
@@ -336,6 +346,11 @@ export declare class PlatformStalePlanError extends Error {
336
346
  export declare class PlatformConfirmationRequiredError extends Error {
337
347
  constructor();
338
348
  }
349
+ /** The third argument of `apply` and `applyPlatform`. */
350
+ export interface FoundryApplyOptions {
351
+ /** Service names (`VersionChangeItem.service`), matched exactly, whose version change the operator confirmed. */
352
+ confirmUpgrade?: readonly string[];
353
+ }
339
354
  export interface FoundryPlanApplyOptions {
340
355
  store: DeclaredOrgStore;
341
356
  readers: LiveReaders;
@@ -518,7 +533,7 @@ export declare class FoundryPlanApply implements PlanApply {
518
533
  */
519
534
  release(id: OrgId, entryKey: string): Promise<AppliedState>;
520
535
  private computeAdopt;
521
- apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
536
+ apply(plan: Plan, confirmedOrgName: string, options?: FoundryApplyOptions): Promise<AppliedState>;
522
537
  /**
523
538
  * After an org apply: gives the platform files runner a changed share-domain
524
539
  * set (its Coolify `domains` and `FILES_PUBLIC_HOSTS`, derived from the
@@ -543,7 +558,7 @@ export declare class FoundryPlanApply implements PlanApply {
543
558
  * shape of their own. `confirmedName` must be the literal word `platform`
544
559
  * rather than a declared name -- `DeclaredPlatform` has none.
545
560
  */
546
- applyPlatform(plan: Plan, confirmedName: string): Promise<{
561
+ applyPlatform(plan: Plan, confirmedName: string, options?: FoundryApplyOptions): Promise<{
547
562
  serviceIds: Record<string, string>;
548
563
  }>;
549
564
  /**
@@ -6,6 +6,7 @@ import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
6
6
  import { StaleLeaseError, } from '../store.js';
7
7
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
8
8
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
9
+ import { unconfirmedUpgrades } from './confirm-upgrade.js';
9
10
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
10
11
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
11
12
  import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
@@ -53,6 +54,21 @@ export class ConfirmationRequiredError extends Error {
53
54
  this.name = 'ConfirmationRequiredError';
54
55
  }
55
56
  }
57
+ /**
58
+ * A plan holds a version change that needs confirmation
59
+ * (`VersionChangeItem.needsConfirmation`) and `confirmUpgrade` does not name
60
+ * its service exactly. `upgrades` lists every such service with its versions.
61
+ */
62
+ export class UpgradeConfirmationRequiredError extends Error {
63
+ upgrades;
64
+ constructor(upgrades) {
65
+ super(`the plan changes versions that must be confirmed by service name: ${upgrades
66
+ .map((u) => `${u.service} ${u.from ?? 'unknown'} -> ${u.to ?? 'unknown'}`)
67
+ .join(', ')}; pass confirmUpgrade naming each one`);
68
+ this.upgrades = upgrades;
69
+ this.name = 'UpgradeConfirmationRequiredError';
70
+ }
71
+ }
56
72
  export class NotAuthorizedError extends Error {
57
73
  constructor(actor, orgId) {
58
74
  super(`${actor.display} does not hold the permission to apply org ${orgId} (#9, #19)`);
@@ -341,7 +357,7 @@ export class FoundryPlanApply {
341
357
  previous,
342
358
  };
343
359
  }
344
- async apply(plan, confirmedOrgName) {
360
+ async apply(plan, confirmedOrgName, options = {}) {
345
361
  const orgId = plan.orgId;
346
362
  const authorized = await this.authz.canApply(this.actor, orgId);
347
363
  if (!authorized) {
@@ -354,6 +370,11 @@ export class FoundryPlanApply {
354
370
  await this.refuse(plan, 'stale: live state has moved since this plan was computed');
355
371
  throw new StalePlanError(orgId);
356
372
  }
373
+ const unconfirmed = unconfirmedUpgrades(freshPlan.versionChanges, options.confirmUpgrade);
374
+ if (unconfirmed.length > 0) {
375
+ await this.refuse(plan, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
376
+ throw new UpgradeConfirmationRequiredError(unconfirmed);
377
+ }
357
378
  if (planNeedsConfirmation(plan.steps) &&
358
379
  confirmedOrgName.trim() !== org.displayName.trim()) {
359
380
  await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
@@ -426,7 +447,7 @@ export class FoundryPlanApply {
426
447
  * shape of their own. `confirmedName` must be the literal word `platform`
427
448
  * rather than a declared name -- `DeclaredPlatform` has none.
428
449
  */
429
- async applyPlatform(plan, confirmedName) {
450
+ async applyPlatform(plan, confirmedName, options = {}) {
430
451
  const { store, executor } = this.requirePlatformDeps();
431
452
  const declared = await store.get();
432
453
  if (!declared)
@@ -449,6 +470,11 @@ export class FoundryPlanApply {
449
470
  await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
450
471
  throw new PlatformStalePlanError();
451
472
  }
473
+ const unconfirmed = unconfirmedUpgrades(freshPlan.versionChanges, options.confirmUpgrade);
474
+ if (unconfirmed.length > 0) {
475
+ await this.refusePlatform(plan, platform.ownerOrgId, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
476
+ throw new UpgradeConfirmationRequiredError(unconfirmed);
477
+ }
452
478
  if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
453
479
  await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
454
480
  throw new PlatformConfirmationRequiredError();
@@ -803,6 +829,10 @@ export class FoundryPlanApply {
803
829
  const { system, kind, key } = planned.action;
804
830
  serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
805
831
  }
832
+ else if (planned.action.desired.delete === true) {
833
+ const { system, kind, key } = planned.action;
834
+ delete serviceIds[`${system}:${kind}:${key}`];
835
+ }
806
836
  checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
807
837
  }
808
838
  }
@@ -216,6 +216,8 @@ export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_ow
216
216
  export declare const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = "zitadel_platform_reserved_admin_username";
217
217
  /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
218
218
  export declare const PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_platform_registration_closed";
219
+ /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
220
+ export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
219
221
  export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
220
222
  export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
221
223
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -403,6 +405,15 @@ export declare const PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY = "stalwart_platf
403
405
  */
404
406
  export declare const PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY = "mail_applied_hostname";
405
407
  export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git_ref";
408
+ /**
409
+ * #661: recorded with {@link PLATFORM_MAIL_ROUTE_WANTED} once the mail
410
+ * server's settings write included the rule that delivers hosted recipients
411
+ * locally (`@wtfalch/email` 0.20.0). A platform applied before it lacks the id,
412
+ * so `planMail` plans the step once more; that run redeploys nothing, mints
413
+ * no key and creates nothing in the sign-in server.
414
+ */
415
+ export declare const PLATFORM_MAIL_ROUTE_SCOPE_KEY = "mail_route_applied";
416
+ export declare const PLATFORM_MAIL_ROUTE_WANTED = "local-first";
406
417
  /**
407
418
  * #177: where the mail server answers on the Coolify network
408
419
  * (`http://<custom_internal_name>:<port>`), recorded by the step the same way
@@ -1029,6 +1040,12 @@ export interface PlanPlatformOptions {
1029
1040
  * repair step is not planned.
1030
1041
  */
1031
1042
  archonIdTokenUserinfo?: boolean;
1043
+ /**
1044
+ * The executor has the platform's sign-in dependencies and a ZITADEL
1045
+ * client for its instance. Not a block: without it the sign-in policy
1046
+ * step is not planned.
1047
+ */
1048
+ authSignInPolicy?: boolean;
1032
1049
  };
1033
1050
  /**
1034
1051
  * Steps whose live state differs from the declared one in a way the recorded
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { VERSIONS } from '../bootstrap/versions.js';
3
3
  import { APP_ENTRY_KEY } from '../org.js';
4
4
  import { VERSION_CHANGE_KIND, } from '../plan.js';
5
+ import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
5
6
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
6
7
  import { legacyAppHostname } from './hostnames.js';
7
8
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
@@ -78,6 +79,9 @@ export function versionChangeLine(service, from, to, note) {
78
79
  kind: VERSION_CHANGE_KIND,
79
80
  service,
80
81
  description: `${service}: ${from} -> ${to} (${note})`,
82
+ from,
83
+ to,
84
+ needsConfirmation: versionChangeNeedsConfirmation(service, from, to),
81
85
  };
82
86
  }
83
87
  /**
@@ -99,6 +103,8 @@ export function versionNotAppliedLine(service, appLive, applied, pinned) {
99
103
  kind: VERSION_CHANGE_KIND,
100
104
  service,
101
105
  description: `${service}: pinned ${pinned}, running tag unknown`,
106
+ to: pinned,
107
+ needsConfirmation: false,
102
108
  };
103
109
  }
104
110
  if (applied === pinned)
@@ -107,6 +113,10 @@ export function versionNotAppliedLine(service, appLive, applied, pinned) {
107
113
  kind: VERSION_CHANGE_KIND,
108
114
  service,
109
115
  description: `${service}: pinned ${pinned}, running ${applied} (not applied by foundry)`,
116
+ from: applied,
117
+ to: pinned,
118
+ // #254: no apply acts on this line, so there is no upgrade to confirm.
119
+ needsConfirmation: false,
110
120
  };
111
121
  }
112
122
  /**
@@ -243,6 +253,8 @@ export const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_owner_invi
243
253
  export const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = 'zitadel_platform_reserved_admin_username';
244
254
  /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
245
255
  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';
246
258
  export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
247
259
  export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
248
260
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -441,6 +453,15 @@ export const PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY = 'stalwart_platform_admi
441
453
  */
442
454
  export const PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY = 'mail_applied_hostname';
443
455
  export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
456
+ /**
457
+ * #661: recorded with {@link PLATFORM_MAIL_ROUTE_WANTED} once the mail
458
+ * server's settings write included the rule that delivers hosted recipients
459
+ * locally (`@wtfalch/email` 0.20.0). A platform applied before it lacks the id,
460
+ * so `planMail` plans the step once more; that run redeploys nothing, mints
461
+ * no key and creates nothing in the sign-in server.
462
+ */
463
+ export const PLATFORM_MAIL_ROUTE_SCOPE_KEY = 'mail_route_applied';
464
+ export const PLATFORM_MAIL_ROUTE_WANTED = 'local-first';
444
465
  /**
445
466
  * #177: where the mail server answers on the Coolify network
446
467
  * (`http://<custom_internal_name>:<port>`), recorded by the step the same way
@@ -1174,6 +1195,10 @@ export function planPlatform(platform, live, options = {}) {
1174
1195
  const idTokenUserinfo = planArchonIdTokenUserinfo(platform, recordedIds, blocked.archonIdTokenUserinfo ?? false);
1175
1196
  if (idTokenUserinfo)
1176
1197
  plannedSteps.push(idTokenUserinfo);
1198
+ // #669: after the ID token repair too, which is proven; a policy call that fails every time holds up nothing.
1199
+ const signInPolicy = planAuthSignInPolicy(platform, recordedIds, blocked.authSignInPolicy ?? false);
1200
+ if (signInPolicy)
1201
+ plannedSteps.push(signInPolicy);
1177
1202
  // #251: one line per service whose recorded applied ref no longer matches
1178
1203
  // what the next apply would use (or, for zitadel, what is actually live).
1179
1204
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -1717,10 +1742,7 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1717
1742
  Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) &&
1718
1743
  // The ZITADEL run options reach the container only through the auth
1719
1744
  // step's redeploy, so a platform recorded before them still plans it.
1720
- recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
1721
- // #394: a platform applied before these existed plans the step again.
1722
- Boolean(recordedIds[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) &&
1723
- Boolean(recordedIds[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY])) {
1745
+ recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
1724
1746
  return { step: null, blocked: null };
1725
1747
  }
1726
1748
  // The admin key was minted but never reached the host. ZITADEL shows a
@@ -1856,9 +1878,16 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1856
1878
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1857
1879
  // #177: a mail step applied before this key existed never recorded it, and
1858
1880
  // org steps read it for Agora's mail upstream. Missing means unapplied.
1859
- recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
1881
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
1882
+ recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED) {
1860
1883
  return { step: null, blocked: null };
1861
1884
  }
1885
+ // Only the route is missing: say so, the app is not redeployed.
1886
+ const onlyRouteMissing = recordedIds[key] &&
1887
+ recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
1888
+ recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
1889
+ recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1890
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY];
1862
1891
  // Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
1863
1892
  // reached the host: report it, plan nothing, the same rule {@link planAuth}
1864
1893
  // follows for ZITADEL's own admin key.
@@ -1886,7 +1915,9 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1886
1915
  desired: { hostname: platform.mail.hostname, gitRef: platform.mail.gitRef },
1887
1916
  },
1888
1917
  step: {
1889
- description: `provision mail (Stalwart) at ${platform.mail.hostname}`,
1918
+ description: onlyRouteMissing
1919
+ ? `write the mail server's local-delivery route at ${platform.mail.hostname}`
1920
+ : `provision mail (Stalwart) at ${platform.mail.hostname}`,
1890
1921
  destroys: false,
1891
1922
  migrates: false,
1892
1923
  },
@@ -2231,6 +2262,31 @@ function planArchonIdTokenUserinfo(platform, recordedIds, wired) {
2231
2262
  },
2232
2263
  };
2233
2264
  }
2265
+ /**
2266
+ * #394, #669: the platform's sign-in org reserves the username `admin` and
2267
+ * switches self-registration off. One `zitadel:sign-in-policy` step keyed
2268
+ * {@link PLATFORM_AUTH_KEY}, planned while either recorded key is missing and
2269
+ * the sign-in's ZITADEL org is recorded. Not planned before that, and not
2270
+ * blocked: nothing is owed until the sign-in exists.
2271
+ */
2272
+ function planAuthSignInPolicy(platform, recordedIds, wired) {
2273
+ if (!wired || !platform.auth.enabled)
2274
+ return null;
2275
+ if (!recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY])
2276
+ return null;
2277
+ if (recordedIds[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY] &&
2278
+ recordedIds[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
2279
+ return null;
2280
+ }
2281
+ return {
2282
+ action: { system: 'zitadel', kind: 'sign-in-policy', key: PLATFORM_AUTH_KEY, desired: {} },
2283
+ step: {
2284
+ description: "reserve the username 'admin' and switch self-registration off on the platform's sign-in org",
2285
+ destroys: false,
2286
+ migrates: false,
2287
+ },
2288
+ };
2289
+ }
2234
2290
  /**
2235
2291
  * #604: the platform's ZITADEL as a sign-in provider in Cloudflare Access: a
2236
2292
  * ZITADEL client for Cloudflare, then edge's `ensureProvider`. One
@@ -10,7 +10,7 @@ import type { TunnelPort } from './edge-tunnels.js';
10
10
  import type { CloudflarePort, CoolifyPort, FoundryStepExecutorPorts, ZitadelPort } from './executor.js';
11
11
  import type { LiveResource, PlatformCloudflareReader, PlatformCoolifyReader } from './live.js';
12
12
  import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
13
- import { type BrokerDeployRecord } from './platform-diff.js';
13
+ import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platform-diff.js';
14
14
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
15
15
  import { type PlatformUptimeDeps } from './uptime-worker.js';
16
16
  /**
@@ -336,6 +336,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
336
336
  archonRunsInsideApp?: boolean;
337
337
  archonDeployRunning?: string;
338
338
  archonIdTokenUserinfo?: boolean;
339
+ authSignInPolicy?: boolean;
339
340
  }>;
340
341
  /**
341
342
  * foundry#542: why the uptime Worker cannot run yet, which optional secrets
@@ -603,8 +604,7 @@ export interface PlatformArchonRuntime {
603
604
  * org (`foundry:<orgId>`), with the platform's step key in the org id's place.
604
605
  */
605
606
  export declare const PLATFORM_ARCHON_ACCESS_APP_NAME = "foundry:platform:archon";
606
- /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
607
- export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
607
+ export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
608
608
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
609
609
  export declare const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = "zitadel_platform_archon_client_id";
610
610
  /**
@@ -1246,4 +1246,3 @@ export declare function filesPublicHostsValue(publicHosts: string | undefined, s
1246
1246
  * from the declared orgs, for its apply and its drift check alike.
1247
1247
  */
1248
1248
  export declare function filesShareDomains(orgs: readonly DeclaredOrg[]): string[];
1249
- export {};
@@ -9,7 +9,7 @@ import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrSta
9
9
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
10
10
  import { healthCheckSettings } from './health-check-settings.js';
11
11
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
12
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
12
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
13
13
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
14
14
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
15
15
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
@@ -365,6 +365,16 @@ export class FoundryPlatformStepExecutor {
365
365
  console.error(`platform:archon: could not reach the platform's ZITADEL client to plan the ID token repair: ${e instanceof Error ? e.message : String(e)}`);
366
366
  }
367
367
  }
368
+ // Not a block: without the auth dependencies and a ZITADEL client for the instance the sign-in policy step is not planned.
369
+ if (this.auth && platform.auth.enabled && recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY]) {
370
+ try {
371
+ this.auth.zitadelForInstance?.(`https://${platform.auth.hostname}`);
372
+ blocked.authSignInPolicy = true;
373
+ }
374
+ catch (e) {
375
+ console.error(`platform:auth: could not reach the platform's ZITADEL client to plan the sign-in policy: ${e instanceof Error ? e.message : String(e)}`);
376
+ }
377
+ }
368
378
  // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
369
379
  if (this.archon?.runsInsideApp) {
370
380
  blocked.archonRunsInsideApp = true;
@@ -608,6 +618,14 @@ export class FoundryPlatformStepExecutor {
608
618
  }
609
619
  return switchOnArchonIdTokenUserinfo(context, this.archon);
610
620
  }
621
+ if (action.system === 'zitadel' &&
622
+ action.kind === 'sign-in-policy' &&
623
+ action.key === PLATFORM_AUTH_KEY) {
624
+ if (!this.auth) {
625
+ throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
626
+ }
627
+ return applyPlatformSignInPolicy(context, this.auth);
628
+ }
611
629
  if (action.system === 'cloudflare' && action.kind === 'access-provider') {
612
630
  if (!this.accessProvider) {
613
631
  throw new Error('cloudflare:access-provider needs FoundryPlatformStepExecutor to be constructed with its Access provider dependencies (#604)');
@@ -1820,29 +1838,47 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1820
1838
  }
1821
1839
  }
1822
1840
  }
1823
- // ---- reserved username and closed registration, once each (#394) ----------
1824
- // Outside the blocks above so a platform applied before this existed gets
1825
- // it on its next apply; `planAuth` plans the step until both keys exist.
1826
- // Last, so a failure here holds up neither the SMTP provider nor the invite.
1827
- const policyRecorded = context.recordedIds();
1828
- if (!policyRecorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY] ||
1829
- !policyRecorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1830
- const policyZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1831
- let policyOrgId = policyRecorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
1832
- if (!policyOrgId) {
1833
- policyOrgId = (await policyZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME)).org.id;
1834
- await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, policyOrgId);
1841
+ return { resourceId: zitadelAppId };
1842
+ }
1843
+ /**
1844
+ * Reserves the username `admin` and switches self-registration off in the
1845
+ * platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
1846
+ * Each call records its own key right after it succeeds, so a failure in the
1847
+ * second leaves the first recorded and throws; the next plan names the step
1848
+ * again. The error names the org only, never the cause.
1849
+ */
1850
+ async function applyPlatformSignInPolicy(context, deps) {
1851
+ const { platform } = context;
1852
+ const recorded = context.recordedIds();
1853
+ const orgId = recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
1854
+ if (!orgId) {
1855
+ throw new Error("platform:auth: the sign-in's ZITADEL org is not recorded yet -- cannot reserve the admin username or close registration");
1856
+ }
1857
+ const zitadel = deps.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? deps.zitadel;
1858
+ const record = async (key, value) => {
1859
+ context.recordScopeId(key, value);
1860
+ await context.persist?.();
1861
+ };
1862
+ if (!recorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) {
1863
+ let userId;
1864
+ try {
1865
+ ({ userId } = await zitadel.reserveUserName(orgId, PLATFORM_AUTH_RESERVED_USERNAME));
1835
1866
  }
1836
- if (!policyRecorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) {
1837
- const { userId } = await policyZitadel.reserveUserName(policyOrgId, PLATFORM_AUTH_RESERVED_USERNAME);
1838
- await record(PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, userId);
1867
+ catch {
1868
+ throw new Error(`platform:auth: reserving the username '${PLATFORM_AUTH_RESERVED_USERNAME}' failed in ZITADEL org ${orgId}`);
1839
1869
  }
1840
- if (!policyRecorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1841
- await policyZitadel.closeSelfRegistration(policyOrgId);
1842
- await record(PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, 'closed');
1870
+ await record(PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, userId);
1871
+ }
1872
+ if (!recorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1873
+ try {
1874
+ await zitadel.closeSelfRegistration(orgId);
1843
1875
  }
1876
+ catch {
1877
+ throw new Error(`platform:auth: closing self-registration failed in ZITADEL org ${orgId}`);
1878
+ }
1879
+ await record(PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, 'closed');
1844
1880
  }
1845
- return { resourceId: zitadelAppId };
1881
+ return {};
1846
1882
  }
1847
1883
  /** Lettermint's SMTP relay, as ZITADEL's SMTP provider wants it: `host:port`, STARTTLS. */
1848
1884
  const PLATFORM_AUTH_SMTP_HOST = 'smtp.lettermint.co:587';
@@ -2326,7 +2362,10 @@ async function provisionPlatformMail(context, deps) {
2326
2362
  const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
2327
2363
  const recordedProjectId = recorded[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY];
2328
2364
  const adminKeyRecorded = Boolean(recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]);
2329
- if (adminKeyRecorded && recordedProjectId !== project.id) {
2365
+ // #661: a platform applied before the local-delivery route existed gets it
2366
+ // here, over the same recovery-admin credential, and mints nothing.
2367
+ const routeRecorded = recorded[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED;
2368
+ if (adminKeyRecorded && (recordedProjectId !== project.id || !routeRecorded)) {
2330
2369
  // #346 round 3: the OIDC-directory write below (next block) runs only
2331
2370
  // once, while the admin key is unrecorded -- so once it is recorded, a
2332
2371
  // changed project id (the project was deleted and recreated, ZITADEL was
@@ -2366,6 +2405,7 @@ async function provisionPlatformMail(context, deps) {
2366
2405
  },
2367
2406
  });
2368
2407
  await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
2408
+ await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2369
2409
  }
2370
2410
  catch (error) {
2371
2411
  throw redactedMailError(error, [password]);
@@ -2405,6 +2445,7 @@ async function provisionPlatformMail(context, deps) {
2405
2445
  oidc: { issuerUrl: issuer, audience: project.id },
2406
2446
  },
2407
2447
  });
2448
+ await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2408
2449
  // Recorded and persisted before the secret leaves this function: if
2409
2450
  // delivery fails for good, the next plan reports the lost key rather
2410
2451
  // than skipping or minting again.
@@ -2718,9 +2759,8 @@ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
2718
2759
  const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
2719
2760
  const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
2720
2761
  const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
2721
- /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
2722
- export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
2723
2762
  const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
2763
+ export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
2724
2764
  /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
2725
2765
  const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
2726
2766
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
@@ -21,8 +21,7 @@ import type { VersionChangeItem } from '../plan.js';
21
21
  * `coolifyApplicationUuid` is the id `plan/diff.ts`'s `appStepKey` keys an
22
22
  * org's hosting entry under in `AppliedState.serviceIds`
23
23
  * (`` `coolify:app:${appStepKey(org, entry.key)}` ``, `org.ts`); `repo` is
24
- * `HostingEntry.repo`, falling back to whatever default an app with none
25
- * declared resolves to (`plan/executor.ts`'s own `DEFAULT_APP_REPO`).
24
+ * `HostingEntry.repo`.
26
25
  */
27
26
  export interface OrgAppVersionRef {
28
27
  orgId: OrgId;
package/dist/plan.d.ts CHANGED
@@ -99,4 +99,16 @@ export interface VersionChangeItem {
99
99
  /** The service this line is about, e.g. 'mail', 'archon', 'zitadel'. */
100
100
  service: string;
101
101
  description: string;
102
+ /** The version running or last applied; absent when that is unknown. */
103
+ from?: string;
104
+ /** The version the declaration or the foundry pin now asks for. */
105
+ to?: string;
106
+ /**
107
+ * True when `apply` and `applyPlatform` refuse the plan until
108
+ * `confirmUpgrade` names {@link service}: any change on a stateful service
109
+ * (`plan/confirm-upgrade.ts`), or a major or minor jump on any service. An
110
+ * unknown running version is never a change, so it is false. So is a line
111
+ * `apply` does not act on ("not applied by foundry").
112
+ */
113
+ needsConfirmation: boolean;
102
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,7 +45,7 @@
45
45
  "@wtfalch/audit": "0.6.0",
46
46
  "@wtfalch/auth": "0.16.0",
47
47
  "@wtfalch/edge": "0.12.0",
48
- "@wtfalch/email": "^0.17.0",
48
+ "@wtfalch/email": "^0.20.0",
49
49
  "@wtfalch/providers": "0.7.5",
50
50
  "ssh2": "1.17.0",
51
51
  "zod": "4.6.5"