@wtfalch/foundry 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.js +3 -2
  3. package/dist/org.d.ts +37 -0
  4. package/dist/org.js +135 -10
  5. package/dist/plan/agora.d.ts +14 -0
  6. package/dist/plan/agora.js +21 -0
  7. package/dist/plan/backup-gate.d.ts +15 -0
  8. package/dist/plan/backup-gate.js +38 -0
  9. package/dist/plan/boule-owners-http.d.ts +12 -0
  10. package/dist/plan/boule-owners-http.js +59 -0
  11. package/dist/plan/boule.d.ts +38 -2
  12. package/dist/plan/boule.js +72 -1
  13. package/dist/plan/company-backups.d.ts +7 -1
  14. package/dist/plan/company-backups.js +42 -2
  15. package/dist/plan/cookie-domain.d.ts +41 -0
  16. package/dist/plan/cookie-domain.js +71 -0
  17. package/dist/plan/desktop-auth.d.ts +58 -0
  18. package/dist/plan/desktop-auth.js +144 -0
  19. package/dist/plan/desktop-callback.d.ts +26 -0
  20. package/dist/plan/desktop-callback.js +59 -0
  21. package/dist/plan/diff.d.ts +13 -4
  22. package/dist/plan/diff.js +272 -84
  23. package/dist/plan/executor.d.ts +136 -6
  24. package/dist/plan/executor.js +725 -98
  25. package/dist/plan/health-gaps.js +5 -0
  26. package/dist/plan/index.d.ts +3 -0
  27. package/dist/plan/index.js +2 -0
  28. package/dist/plan/job-app.d.ts +23 -0
  29. package/dist/plan/job-app.js +41 -0
  30. package/dist/plan/live.d.ts +6 -0
  31. package/dist/plan/live.js +16 -0
  32. package/dist/plan/login-host.d.ts +28 -0
  33. package/dist/plan/login-host.js +36 -0
  34. package/dist/plan/org-names.js +9 -1
  35. package/dist/plan/org-postgres-job.d.ts +22 -0
  36. package/dist/plan/org-postgres-job.js +91 -0
  37. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  38. package/dist/plan/org-postgres-migrate.js +466 -0
  39. package/dist/plan/org-postgres.d.ts +90 -0
  40. package/dist/plan/org-postgres.js +293 -0
  41. package/dist/plan/plan-apply.d.ts +30 -2
  42. package/dist/plan/plan-apply.js +104 -7
  43. package/dist/plan/platform-diff.d.ts +45 -0
  44. package/dist/plan/platform-diff.js +159 -4
  45. package/dist/plan/platform-org.d.ts +2 -0
  46. package/dist/plan/platform-org.js +4 -0
  47. package/dist/plan/platform-providers.d.ts +12 -2
  48. package/dist/plan/platform-providers.js +279 -9
  49. package/dist/plan/post-logout-uris.d.ts +22 -0
  50. package/dist/plan/post-logout-uris.js +43 -0
  51. package/dist/plan/providers-readers.d.ts +10 -1
  52. package/dist/plan/providers-readers.js +41 -1
  53. package/dist/plan/sign-in-bindings.d.ts +4 -0
  54. package/dist/plan/sign-in-bindings.js +10 -0
  55. package/dist/plan/sign-in-org.d.ts +19 -6
  56. package/dist/plan/sign-in-org.js +35 -10
  57. package/dist/plan/sign-in.d.ts +12 -5
  58. package/dist/plan/sign-in.js +12 -5
  59. package/dist/plan/signin-db-move.js +10 -34
  60. package/package.json +5 -5
@@ -0,0 +1,293 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, orgServiceDatabase, } from '../org.js';
3
+ import { JobFailure, waitForJob } from './job-app.js';
4
+ import { ORG_POSTGRES_JOB_ADMIN_ENV, jobCreatesRuntimeRole, orgPostgresPasswordEnv, orgPostgresProvisionDockerfile, orgPostgresRuntimePasswordEnv, } from './org-postgres-job.js';
5
+ import { boundedCall, withCallTimeout } from './signin-db.js';
6
+ /** Recorded once a live company's data for `home` is copied to the org's server (#444). */
7
+ export const orgPostgresMigratedScopeKey = (home) => `org_postgres_migrated.${home}`;
8
+ /** Whether `home` still has a legacy Coolify database that has not been moved to the server. */
9
+ export function legacyUnmigrated(org, home, recordedIds) {
10
+ const legacy = home.legacyKey(org);
11
+ return (legacy !== null &&
12
+ Boolean(recordedIds[legacy]) &&
13
+ !recordedIds[orgPostgresMigratedScopeKey(home.schema)]);
14
+ }
15
+ /**
16
+ * Pure. Where a consumer finds its database, from recorded ids alone: on the
17
+ * server once the home is recorded and either it never had a legacy database
18
+ * or its migration is recorded. A live company stays on its Coolify database
19
+ * until then.
20
+ */
21
+ export function serviceOnServer(org, home, recordedIds) {
22
+ return Boolean(recordedIds[home.scopeKey]) && !legacyUnmigrated(org, home, recordedIds);
23
+ }
24
+ /** Pure. The home's endpoint on the server whose Coolify `internal_db_url` is given; null when that URL has no host. */
25
+ export function serviceEndpoint(org, home, serverInternalUrl) {
26
+ if (!URL.canParse(serverInternalUrl))
27
+ return null;
28
+ const url = new URL(serverInternalUrl);
29
+ if (!url.hostname)
30
+ return null;
31
+ const names = orgServiceDatabase(org, home);
32
+ return {
33
+ host: url.hostname,
34
+ port: url.port || '5432',
35
+ database: names.database,
36
+ ownerUser: names.ownerRole,
37
+ runtimeUser: names.runtimeRole,
38
+ };
39
+ }
40
+ /** Pure. The owner URL, and the runtime URL when a runtime password is given. */
41
+ export function serviceUrls(endpoint, ownerPassword, runtimePassword) {
42
+ const build = (user, password) => `postgres://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${endpoint.host}:${endpoint.port}/${endpoint.database}`;
43
+ return {
44
+ owner: build(endpoint.ownerUser, ownerPassword),
45
+ runtime: runtimePassword === null ? null : build(endpoint.runtimeUser, runtimePassword),
46
+ };
47
+ }
48
+ // Restore path: roles are not in a Coolify dump. Re-run the provisioning job (idempotent; it is
49
+ // given a password only for a home not recorded), then `pg_restore` each dump with
50
+ // `--no-owner --no-acl --role=<owner>`. Template apps create `<db>_rt` again on first boot.
51
+ /** The server's name in Coolify. */
52
+ export const orgPostgresServerName = (org) => `${org.slug}-postgres`;
53
+ /** The job app's name in Coolify. */
54
+ export const orgPostgresJobName = (org) => `${org.slug}-postgres-job`;
55
+ /** The job app's id while it exists. */
56
+ export const ORG_POSTGRES_JOB_APP_SCOPE_KEY = 'org_postgres_job_app';
57
+ /** An opaque id, set between a password's mint and its delivery. Never the password. */
58
+ export const orgPostgresUndeliveredScopeKey = (home) => `org_postgres_undelivered.${home}`;
59
+ /** Where the host holds a home's password, once delivered. */
60
+ export const orgPostgresSecretRef = (org, home, role) => `org-postgres:${org.slug}:${home}:${role}`;
61
+ /** How many times each password is handed to `deliverSecret`. */
62
+ export const ORG_POSTGRES_DELIVERY_ATTEMPTS = 3;
63
+ /**
64
+ * The two steps that make the company's Postgres server and its databases true, from recorded ids
65
+ * alone: the server while it is not recorded, the databases of every enabled home that is not. The
66
+ * databases step needs no precondition beyond the server step before it in the same plan.
67
+ */
68
+ export function planOrgPostgresSteps(org, plan) {
69
+ const steps = [];
70
+ if (plan.serverMissing) {
71
+ steps.push({
72
+ action: { system: 'coolify', kind: 'org-postgres', key: org.id, desired: {} },
73
+ step: {
74
+ description: `create ${org.slug}'s one Postgres server (${orgPostgresServerName(org)})`,
75
+ destroys: false,
76
+ migrates: false,
77
+ },
78
+ });
79
+ }
80
+ if (plan.missingSchemas.length > 0) {
81
+ const homes = plan.missingSchemas.map((home) => home.schema);
82
+ steps.push({
83
+ action: {
84
+ system: 'coolify',
85
+ kind: 'org-postgres-databases',
86
+ key: org.id,
87
+ desired: { homes },
88
+ },
89
+ step: {
90
+ description: `create ${org.slug}'s databases and roles on its Postgres server: ${homes.join(', ')}`,
91
+ destroys: false,
92
+ migrates: false,
93
+ },
94
+ });
95
+ }
96
+ return steps;
97
+ }
98
+ const mint = () => randomBytes(24).toString('base64url');
99
+ const mintOpaque = () => randomBytes(12).toString('hex');
100
+ /** Creates the company's Postgres server once and starts it. Returns the Coolify uuid. */
101
+ export async function provisionOrgPostgresServer(scope, deps, org) {
102
+ const coolify = withCallTimeout(deps.coolify);
103
+ let id = scope.recordedIds()[ORG_POSTGRES_SERVER_SCOPE_KEY];
104
+ if (!id) {
105
+ const projectUuid = await deps.ensureProject();
106
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
107
+ const name = orgPostgresServerName(org);
108
+ const found = (await coolify.listDatabases()).find((db) => db.name === name && db.environment_id === environment.id);
109
+ if (found) {
110
+ id = found.uuid;
111
+ }
112
+ else {
113
+ const created = await coolify.createPostgresDatabase({
114
+ project_uuid: projectUuid,
115
+ server_uuid: await deps.serverUuid(),
116
+ environment_name: environment.name,
117
+ environment_uuid: environment.uuid,
118
+ name,
119
+ postgres_db: 'postgres',
120
+ postgres_user: 'postgres',
121
+ });
122
+ id = created.uuid;
123
+ }
124
+ // Recorded before it is started: a crash after the create must not make a second container.
125
+ scope.recordScopeId(ORG_POSTGRES_SERVER_SCOPE_KEY, id);
126
+ await scope.persist();
127
+ }
128
+ await boundedCall(deps.startDatabase(id), 'Coolify startDatabase');
129
+ return { resourceId: id };
130
+ }
131
+ /**
132
+ * Creates the databases and roles of the homes in `homeKeys` that are not recorded, with one job app
133
+ * for all of them, then hands each owner password to the host and records the home.
134
+ *
135
+ * One mint (CLAUDE.md platform rule 1): each home's password is minted once per attempt, an opaque id
136
+ * is recorded under `org_postgres_undelivered.<home>` and persisted before the job runs, and the home
137
+ * is recorded only after the password is delivered. A run that dies between the two finds the id,
138
+ * and mints again: no consumer has read the old password, and the job sets the new one.
139
+ */
140
+ export async function provisionOrgPostgresDatabases(scope, deps, org, homeKeys) {
141
+ const label = `${org.slug}'s Postgres databases`;
142
+ const coolify = withCallTimeout(deps.coolify);
143
+ const recorded = scope.recordedIds();
144
+ const serverId = recorded[ORG_POSTGRES_SERVER_SCOPE_KEY];
145
+ if (!serverId)
146
+ throw new Error(`${label}: the Postgres server is not recorded yet`);
147
+ const homes = ORG_SERVICE_SCHEMAS.filter((home) => homeKeys.includes(home.schema) && !recorded[home.scopeKey]);
148
+ if (homes.length === 0)
149
+ return;
150
+ const databases = homes.map((home) => orgServiceDatabase(org, home));
151
+ const onSecret = (value) => deps.onSecret?.(value);
152
+ const server = await boundedCall(deps.startDatabase(serverId), 'Coolify startDatabase');
153
+ if (!server.internal_db_url)
154
+ throw new Error(`${label}: the Postgres server has no internal URL`);
155
+ const adminUrl = server.internal_db_url;
156
+ onSecret(adminUrl);
157
+ if (URL.canParse(adminUrl)) {
158
+ const password = decodeURIComponent(new URL(adminUrl).password);
159
+ if (password)
160
+ onSecret(password);
161
+ }
162
+ const mintPassword = deps.mintPassword ?? mint;
163
+ const passwords = new Map();
164
+ for (const home of databases) {
165
+ const owner = mintPassword();
166
+ const runtime = jobCreatesRuntimeRole(home) ? mintPassword() : null;
167
+ onSecret(owner);
168
+ if (runtime)
169
+ onSecret(runtime);
170
+ passwords.set(home.schema, { owner, runtime });
171
+ scope.recordScopeId(orgPostgresUndeliveredScopeKey(home.schema), (deps.mintId ?? mintOpaque)());
172
+ }
173
+ await scope.persist();
174
+ await runJob(scope, deps, org, label, coolify, databases, adminUrl, passwords);
175
+ const failed = [];
176
+ for (const home of databases) {
177
+ const held = passwords.get(home.schema);
178
+ if (!held)
179
+ continue;
180
+ try {
181
+ await deliver(deps, orgPostgresSecretRef(org, home.schema, 'owner'), held.owner);
182
+ if (held.runtime) {
183
+ await deliver(deps, orgPostgresSecretRef(org, home.schema, 'runtime'), held.runtime);
184
+ }
185
+ }
186
+ catch {
187
+ failed.push(home.schema);
188
+ continue;
189
+ }
190
+ scope.recordScopeId(`org_postgres_schema.${home.schema}`, home.database);
191
+ scope.recordScopeId(orgPostgresUndeliveredScopeKey(home.schema), '');
192
+ await scope.persist();
193
+ }
194
+ if (failed.length > 0) {
195
+ throw new Error(`${label}: the passwords for ${failed.join(', ')} were set but could not be delivered after ${ORG_POSTGRES_DELIVERY_ATTEMPTS} attempts; the next apply sets and delivers new ones`);
196
+ }
197
+ }
198
+ async function deliver(deps, ref, secret) {
199
+ for (let attempt = 1; attempt <= ORG_POSTGRES_DELIVERY_ATTEMPTS; attempt += 1) {
200
+ try {
201
+ await boundedCall(deps.deliverSecret(ref, secret), 'deliverSecret');
202
+ return;
203
+ }
204
+ catch {
205
+ // The cause may carry the secret; nothing of it is kept.
206
+ }
207
+ }
208
+ throw new Error('delivery failed');
209
+ }
210
+ async function runJob(scope, deps, org, label, coolify, databases, adminUrl, passwords) {
211
+ const jobName = orgPostgresJobName(org);
212
+ let jobAppId;
213
+ try {
214
+ const projectUuid = await deps.ensureProject();
215
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
216
+ // A job left by an earlier run holds an old script and old passwords: it is replaced, never reused.
217
+ const leftover = await coolify.findApplicationByName(jobName, {
218
+ environmentIds: [environment.id],
219
+ });
220
+ if (leftover)
221
+ await coolify.raw('DELETE', `/applications/${leftover.uuid}`);
222
+ const created = await coolify.createDockerfileApplication({
223
+ project_uuid: projectUuid,
224
+ server_uuid: await deps.serverUuid(),
225
+ environment_name: environment.name,
226
+ environment_uuid: environment.uuid,
227
+ dockerfile: orgPostgresProvisionDockerfile(databases),
228
+ name: jobName,
229
+ description: `${org.slug}'s Postgres databases job`,
230
+ instant_deploy: false,
231
+ autogenerate_domain: false,
232
+ });
233
+ jobAppId = created.uuid;
234
+ scope.recordScopeId(ORG_POSTGRES_JOB_APP_SCOPE_KEY, jobAppId);
235
+ await scope.persist();
236
+ await coolify.updateApplication(jobAppId, { health_check_enabled: false });
237
+ await coolify.bulkUpsertEnvs(jobAppId, [
238
+ { key: ORG_POSTGRES_JOB_ADMIN_ENV, value: adminUrl },
239
+ ...databases.flatMap((home) => {
240
+ const held = passwords.get(home.schema);
241
+ if (!held)
242
+ return [];
243
+ return [
244
+ { key: orgPostgresPasswordEnv(home.schema), value: held.owner },
245
+ ...(held.runtime
246
+ ? [{ key: orgPostgresRuntimePasswordEnv(home.schema), value: held.runtime }]
247
+ : []),
248
+ ];
249
+ }),
250
+ ]);
251
+ await coolify.deploy(jobAppId, true);
252
+ await waitForJob(coolify, jobAppId, deps.runtime ?? {}, {
253
+ job: 'the Postgres job',
254
+ stopped: 'the Postgres job stopped before it finished',
255
+ });
256
+ }
257
+ catch (error) {
258
+ const why = error instanceof JobFailure ? error.message : 'a Coolify call failed or timed out';
259
+ // The failed job app must not keep running with the admin URL and passwords in its env.
260
+ let cleaned = '';
261
+ if (jobAppId) {
262
+ const id = jobAppId;
263
+ const blank = [
264
+ ORG_POSTGRES_JOB_ADMIN_ENV,
265
+ ...databases.flatMap((home) => [
266
+ orgPostgresPasswordEnv(home.schema),
267
+ orgPostgresRuntimePasswordEnv(home.schema),
268
+ ]),
269
+ ].map((key) => ({ key, value: '' }));
270
+ try {
271
+ await boundedCall(coolify.raw('POST', `/applications/${id}/stop`), 'stop the job');
272
+ }
273
+ catch {
274
+ cleaned += '; the job app could not be stopped, stop it in Coolify';
275
+ }
276
+ try {
277
+ await boundedCall(coolify.bulkUpsertEnvs(id, blank), 'clear the job env');
278
+ }
279
+ catch {
280
+ cleaned +=
281
+ '; its env could not be cleared, delete the app in Coolify (it holds the admin URL)';
282
+ }
283
+ }
284
+ throw new Error(`${label}: ${why}${jobAppId ? `; the job app ${jobAppId} is left stopped without secrets for inspection and the next apply replaces it${cleaned}` : ''}; apply again`);
285
+ }
286
+ try {
287
+ await coolify.raw('DELETE', `/applications/${jobAppId}`);
288
+ }
289
+ catch {
290
+ throw new Error(`${label}: the job ran, but its app ${jobAppId} could not be deleted; delete it in Coolify (it holds the admin URL), then apply again`);
291
+ }
292
+ scope.recordScopeId(ORG_POSTGRES_JOB_APP_SCOPE_KEY, '');
293
+ }
@@ -14,6 +14,7 @@ import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
14
14
  import { type StepAction } from './diff.js';
15
15
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
16
16
  import { type LoginHostDiscoveryPort } from './login-host.js';
17
+ import { type OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
17
18
  import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
18
19
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
19
20
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
@@ -62,9 +63,9 @@ export interface AuditLog {
62
63
  recordAdoption(entry: {
63
64
  actor: AuditActor;
64
65
  orgId: OrgId;
65
- action: 'adopt' | 'release';
66
+ action: 'adopt' | 'release' | 'confirm-org-postgres-migration';
66
67
  entryKey: string;
67
- outcome: 'adopted' | 'released' | 'refused';
68
+ outcome: 'adopted' | 'released' | 'refused' | 'recorded';
68
69
  reason?: string;
69
70
  at: Date;
70
71
  }): Promise<void>;
@@ -281,6 +282,8 @@ export interface PlatformStepExecutionContext {
281
282
  export interface PlatformDriftedSteps {
282
283
  archon?: boolean;
283
284
  auth?: boolean;
285
+ /** #643: a recorded sign-in app (ZITADEL, login or broker) is gone from Coolify or not running. */
286
+ authApps?: boolean;
284
287
  /** The host sets it. */
285
288
  edge?: boolean;
286
289
  /** #380 */
@@ -466,6 +469,12 @@ export interface FoundryPlanApplyOptions {
466
469
  * before zitadel-login's binding can be written.
467
470
  */
468
471
  loginHostBindings?: boolean;
472
+ /**
473
+ * True when the host's `loginHostBindings` port writes the `clientOrganizations` map
474
+ * (`upsertClientOrganizations` and `deleteClientOrganizations`). Absent or false: the plan says in one
475
+ * fixed line that the shared login does not check customers clients' organisation.
476
+ */
477
+ loginHostClientOrganizations?: boolean;
469
478
  /**
470
479
  * Reads `GET https://<login host>/.well-known/openid-configuration` for a
471
480
  * company's login host (5 s limit). Optional, and without it every company's
@@ -481,6 +490,15 @@ export interface FoundryPlanApplyOptions {
481
490
  * token of its existing sign-in apps.
482
491
  */
483
492
  zitadelIdTokenUserinfo?: boolean;
493
+ /** True when the host's ZITADEL client has `updateOidcAppPostLogoutRedirectUris`; without it no org plans the post-logout address update. */
494
+ zitadelPostLogoutUris?: boolean;
495
+ /**
496
+ * True when the host's ZITADEL client has `getOidcAppRedirectUris` and
497
+ * `updateOidcAppRedirectUris` (`@wtfalch/providers` 0.12.0). Optional, and
498
+ * false without them: no org plans the step that registers the desktop
499
+ * sign-in callback on its existing sign-in apps.
500
+ */
501
+ zitadelRedirectUris?: boolean;
484
502
  /**
485
503
  * Auth ADR 0002: true when the host's ZITADEL client has `ensureOidcAppMcpAccess`
486
504
  * and `ensureAccessTokenLifetime` (`@wtfalch/providers` 0.11.0). Without it every
@@ -644,8 +662,11 @@ export declare class FoundryPlanApply implements PlanApply {
644
662
  private readonly agora;
645
663
  private readonly jobsScheduler;
646
664
  private readonly loginHostBindings;
665
+ private readonly loginHostClientOrganizations;
647
666
  private readonly loginHostDiscovery;
648
667
  private readonly zitadelIdTokenUserinfo;
668
+ private readonly zitadelPostLogoutUris;
669
+ private readonly zitadelRedirectUris;
649
670
  private readonly zitadelMcp;
650
671
  private readonly zitadelOrgMetadata;
651
672
  private readonly broker;
@@ -706,6 +727,13 @@ export declare class FoundryPlanApply implements PlanApply {
706
727
  * {@link apply}. Every path writes an audit row.
707
728
  */
708
729
  release(id: OrgId, entryKey: string): Promise<AppliedState>;
730
+ /**
731
+ * Records that the staging copy of a live company's data was checked, which is the only thing that lets
732
+ * the org Postgres migration step run (#444). It is its own call, never part of an apply. Needs the
733
+ * platform permission. It stores the staging copy id, who confirmed and when, and writes an audit row.
734
+ * Throws {@link InvalidMigrationConfirmationError} for a confirmation that is not valid.
735
+ */
736
+ confirmOrgPostgresMigration(orgId: OrgId, confirmation: OrgPostgresMigrationConfirmation): Promise<void>;
709
737
  private computeAdopt;
710
738
  apply(plan: Plan, confirmedOrgName: string, options?: FoundryApplyOptions): Promise<AppliedState>;
711
739
  /**
@@ -1,15 +1,16 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
3
  import { sfuSlotPorts } from '../calls-sfu.js';
4
- import { heldEnvRef, ownsServices, validateHosting } from '../org.js';
4
+ import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '../org.js';
5
5
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
6
6
  import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
7
7
  import { StaleLeaseError, } from '../store.js';
8
8
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
9
9
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
10
- import { NetworkMoveBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
10
+ import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
11
11
  import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, entrySubject, } from './blue-green.js';
12
12
  import { readLiveBuilds } from './build-record.js';
13
+ import { LEGACY_BACKUP_NAMES } from './company-backups.js';
13
14
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
14
15
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
15
16
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
@@ -20,6 +21,7 @@ import { hostingNames } from './locked-hostnames.js';
20
21
  import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
21
22
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_MAX, OWN_MAIL_SMTP_PORT_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, isOwnMailSmtpPort, mailRelayFingerprint, } from './mail-relay.js';
22
23
  import { findNameClashes, nameClashMessage } from './org-names.js';
24
+ import { InvalidMigrationConfirmationError, ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, invalidMigrationConfirmation, migrateHomeOf, parseMigrationConfirmation, } from './org-postgres-migrate.js';
23
25
  import { ownHostnames } from './own-hostnames.js';
24
26
  import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
25
27
  import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
@@ -159,8 +161,11 @@ export class FoundryPlanApply {
159
161
  agora;
160
162
  jobsScheduler;
161
163
  loginHostBindings;
164
+ loginHostClientOrganizations;
162
165
  loginHostDiscovery;
163
166
  zitadelIdTokenUserinfo;
167
+ zitadelPostLogoutUris;
168
+ zitadelRedirectUris;
164
169
  zitadelMcp;
165
170
  zitadelOrgMetadata;
166
171
  broker;
@@ -194,8 +199,11 @@ export class FoundryPlanApply {
194
199
  this.agora = options.agora;
195
200
  this.jobsScheduler = options.jobsScheduler ?? false;
196
201
  this.loginHostBindings = options.loginHostBindings ?? false;
202
+ this.loginHostClientOrganizations = options.loginHostClientOrganizations ?? false;
197
203
  this.loginHostDiscovery = options.loginHostDiscovery;
198
204
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
205
+ this.zitadelPostLogoutUris = options.zitadelPostLogoutUris ?? false;
206
+ this.zitadelRedirectUris = options.zitadelRedirectUris ?? false;
199
207
  this.zitadelMcp = options.zitadelMcp ?? false;
200
208
  this.zitadelOrgMetadata = options.zitadelOrgMetadata ?? false;
201
209
  this.broker = options.broker ?? false;
@@ -379,6 +387,55 @@ export class FoundryPlanApply {
379
387
  return released;
380
388
  }));
381
389
  }
390
+ /**
391
+ * Records that the staging copy of a live company's data was checked, which is the only thing that lets
392
+ * the org Postgres migration step run (#444). It is its own call, never part of an apply. Needs the
393
+ * platform permission. It stores the staging copy id, who confirmed and when, and writes an audit row.
394
+ * Throws {@link InvalidMigrationConfirmationError} for a confirmation that is not valid.
395
+ */
396
+ async confirmOrgPostgresMigration(orgId, confirmation) {
397
+ const record = (outcome, reason) => this.audit.recordAdoption({
398
+ actor: this.actor,
399
+ orgId,
400
+ action: 'confirm-org-postgres-migration',
401
+ entryKey: 'org_postgres_migration',
402
+ outcome,
403
+ ...(reason === undefined ? {} : { reason }),
404
+ at: this.now(),
405
+ });
406
+ if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
407
+ await record('refused', 'not authorized');
408
+ throw new NotAuthorizedError(this.actor, orgId);
409
+ }
410
+ const invalid = invalidMigrationConfirmation(confirmation);
411
+ if (invalid) {
412
+ await record('refused', invalid);
413
+ throw new InvalidMigrationConfirmationError(invalid);
414
+ }
415
+ const value = {
416
+ stagingCopy: confirmation.stagingCopy,
417
+ confirmedBy: confirmation.confirmedBy,
418
+ confirmedAt: confirmation.confirmedAt,
419
+ };
420
+ await this.withMutationLease(companyScope(orgId), (reason) => record('refused', reason), (fence) => this.withRunnerSerialization(orgId, (reason) => record('refused', reason), async () => {
421
+ if (!(await this.store.get(orgId))) {
422
+ await record('refused', `no declared org ${orgId}`);
423
+ throw new DeclaredOrgNotFoundError(orgId);
424
+ }
425
+ const previous = await this.store.getAppliedState(orgId);
426
+ await this.store.putAppliedState({
427
+ orgId,
428
+ serviceIds: {
429
+ ...(previous?.serviceIds ?? {}),
430
+ [ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY]: JSON.stringify(value),
431
+ },
432
+ checkpoints: previous?.checkpoints ?? [],
433
+ failedFields: previous?.failedFields ?? [],
434
+ lastApplied: previous?.lastApplied ?? null,
435
+ }, fence);
436
+ await record('recorded');
437
+ }));
438
+ }
382
439
  async computeAdopt(id, input) {
383
440
  const org = await this.store.get(id);
384
441
  if (!org)
@@ -485,6 +542,40 @@ export class FoundryPlanApply {
485
542
  throw e;
486
543
  }
487
544
  }
545
+ // A migration to the company's Postgres server needs the recorded staging confirmation and a fresh dump
546
+ // of the old database. Both are checked before the first port call.
547
+ const migrations = detail.plannedSteps.filter((s) => s.action.system === 'coolify' && s.action.kind === 'org-postgres-migrate');
548
+ if (migrations.length > 0) {
549
+ const ids = (await this.store.getAppliedState(orgId))?.serviceIds ?? {};
550
+ if (!parseMigrationConfirmation(ids[ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY])) {
551
+ await this.refuse(plan, 'org postgres migration without a recorded staging confirmation');
552
+ throw new OrgPostgresMigrationNotConfirmedError();
553
+ }
554
+ try {
555
+ for (const migration of migrations) {
556
+ const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === migrateHomeOf(migration.action));
557
+ const legacyKey = home?.legacyKey(org);
558
+ const backupName = home ? LEGACY_BACKUP_NAMES[home.schema] : undefined;
559
+ if (!this.backupGate)
560
+ throw new OrgPostgresMigrationBackupMissingError('no-gate');
561
+ if (!legacyKey || !backupName) {
562
+ throw new OrgPostgresMigrationBackupMissingError('no-backup');
563
+ }
564
+ await requireFreshBackupForOrgPostgresMigration({
565
+ coolify: this.backupGate.coolify,
566
+ orgId,
567
+ recordedIds: ids,
568
+ now: () => this.now().getTime(),
569
+ }, legacyKey, backupName);
570
+ }
571
+ }
572
+ catch (e) {
573
+ if (e instanceof OrgPostgresMigrationBackupMissingError) {
574
+ await this.refuse(plan, 'org postgres migration without a fresh backup');
575
+ }
576
+ throw e;
577
+ }
578
+ }
488
579
  if (planNeedsConfirmation(plan.steps) &&
489
580
  confirmedOrgName.trim() !== org.displayName.trim()) {
490
581
  await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
@@ -1219,8 +1310,11 @@ export class FoundryPlanApply {
1219
1310
  : undefined;
1220
1311
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
1221
1312
  loginHostBindings: this.loginHostBindings,
1313
+ loginHostClientOrganizations: this.loginHostClientOrganizations,
1222
1314
  loginHostServesDiscovery,
1223
1315
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
1316
+ zitadelPostLogoutUris: this.zitadelPostLogoutUris,
1317
+ zitadelRedirectUris: this.zitadelRedirectUris,
1224
1318
  zitadelMcp: this.zitadelMcp,
1225
1319
  zitadelOrgMetadata: this.zitadelOrgMetadata,
1226
1320
  broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
@@ -1356,14 +1450,16 @@ export class FoundryPlanApply {
1356
1450
  ...(await this.sharedPlatformReadiness()),
1357
1451
  heldEnv: await this.heldEnvReadiness(org),
1358
1452
  };
1359
- if (!ownsServices(org))
1360
- return base;
1361
- const held = this.platformDeps?.heldKeys;
1362
1453
  const platform = await this.platformDeps?.store.get();
1363
- const readiness = orgPlatformReadinessFor(org, base, orgRecordedIds, held ? await heldOwnKeyRefs(held, org) : new Set());
1364
- return platform
1454
+ // The auth release gates the shared-host organisation check and the cookie domain of every company.
1455
+ const withRefs = (readiness) => platform
1365
1456
  ? { ...readiness, serviceGitRefs: serviceGitRefsOf(platform, org.versionPin) }
1366
1457
  : readiness;
1458
+ if (!ownsServices(org))
1459
+ return withRefs(base);
1460
+ const held = this.platformDeps?.heldKeys;
1461
+ const readiness = orgPlatformReadinessFor(org, base, orgRecordedIds, held ? await heldOwnKeyRefs(held, org) : new Set());
1462
+ return withRefs(readiness);
1367
1463
  }
1368
1464
  /**
1369
1465
  * The org's `heldSecret` envs: the refs that hold a value now, which of those differ from the live
@@ -2022,6 +2118,7 @@ const STEP_FIELDS = {
2022
2118
  'zitadel:owner': 'owners',
2023
2119
  'zitadel:customers': 'hosting.entries.uses.auth',
2024
2120
  'zitadel:id-token-userinfo': 'hosting.entries.uses.auth',
2121
+ 'zitadel:desktop-callback': 'hosting.entries.uses.auth',
2025
2122
  'zitadel:mcp-project': 'hosting.entries.uses.auth.mcp',
2026
2123
  'zitadel:mcp-access': 'hosting.entries.uses.auth.mcp',
2027
2124
  'zitadel:access-token-lifetime': 'hosting.entries.uses.auth.mcp',
@@ -60,6 +60,12 @@ export declare const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = "auth_z
60
60
  export declare const AUTH_ZITADEL_RUN_OPTIONS = "--entrypoint \"/app/zitadel start-from-init --masterkeyFromEnv --tlsMode external\"";
61
61
  /** The run options last applied to the ZITADEL app (value: the options string), so a changed value forces one redeploy. */
62
62
  export declare const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = "zitadel_platform_auth_run_options_applied";
63
+ /**
64
+ * The `gitRef` the platform's login, in-app login and broker apps were last built from (value: the ref). A
65
+ * different declared ref plans the auth step again: it patches `git_branch` on those three apps and
66
+ * redeploys them, so a release tag named in the declaration reaches the running login.
67
+ */
68
+ export declare const PLATFORM_AUTH_APPLIED_GIT_REF_SCOPE_KEY = "platform_auth_applied_git_ref";
63
69
  /**
64
70
  * #251: the Synapse/MAS image tags an org's calls Matrix stack last
65
71
  * successful apply actually set live. `coolifyCalls` (`executor.ts`)
@@ -611,6 +617,31 @@ export declare const PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY = "zitadel_platform_a
611
617
  * repair while this differs from the recorded client app id.
612
618
  */
613
619
  export declare const PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY = "archon_id_token_userinfo_applied";
620
+ /**
621
+ * The marker for Archon's sign-in client once its post-logout addresses were
622
+ * set: the client's ZITADEL app id and the set it holds
623
+ * (`postLogoutAppliedValue`). `planArchonPostLogoutUris` plans the update
624
+ * while this differs from what the declared hostname wants.
625
+ */
626
+ export declare const PLATFORM_ARCHON_POST_LOGOUT_APPLIED_SCOPE_KEY = "archon_post_logout_applied";
627
+ /**
628
+ * #394 (decision D2, release one): the role key on the platform's `mail`
629
+ * ZITADEL project that every mailbox user holds once the project's role check
630
+ * is on. This release only makes the role and grants it; the check stays off.
631
+ */
632
+ export declare const PLATFORM_MAIL_USER_ROLE = "mail:use";
633
+ /** The mail project id once {@link PLATFORM_MAIL_USER_ROLE} exists on it. */
634
+ export declare const PLATFORM_MAIL_ROLE_ENSURED_SCOPE_KEY = "zitadel_platform_mail_role";
635
+ /** `<mail project id>|<owner user id, or none>` once the owner holds the role. */
636
+ export declare const PLATFORM_MAIL_ROLE_GRANTED_SCOPE_KEY = "zitadel_platform_mail_role_granted";
637
+ /** The mail project id once its role check and has-project check are on (release two, behind an option). */
638
+ export declare const PLATFORM_MAIL_ROLE_CHECK_SCOPE_KEY = "zitadel_platform_mail_role_check";
639
+ /**
640
+ * The ZITADEL app id of Archon's sign-in client once `/auth/desktop/callback`
641
+ * was registered on it (#339). `planArchonDesktopCallback` plans the step
642
+ * while this differs from the recorded client app id.
643
+ */
644
+ export declare const PLATFORM_ARCHON_DESKTOP_CALLBACK_APPLIED_SCOPE_KEY = "archon_desktop_callback_applied";
614
645
  /**
615
646
  * The settings `coolifyApp` owns on an entry's app (`folder`, `port`,
616
647
  * `autoDeploy`) as the JSON the marker below holds. `coolifyApp` records it
@@ -1225,6 +1256,18 @@ export interface PlanPlatformOptions {
1225
1256
  * repair step is not planned.
1226
1257
  */
1227
1258
  archonIdTokenUserinfo?: boolean;
1259
+ /** The owner company's ZITADEL client has `updateOidcAppPostLogoutRedirectUris`. */
1260
+ archonPostLogoutUris?: boolean;
1261
+ /**
1262
+ * The Archon sign-in client's ZITADEL client has `getOidcAppRedirectUris`
1263
+ * and `updateOidcAppRedirectUris`. Not a block: without them the desktop
1264
+ * callback step is not planned.
1265
+ */
1266
+ archonDesktopCallback?: boolean;
1267
+ /** The mail step's ZITADEL client can make a project role and grant it. Not a block: without it no mail role step is planned. */
1268
+ mailRole?: boolean;
1269
+ /** The host asked for the mail project's role check (release two) and its client has `setProjectRoleChecks`. */
1270
+ mailRoleCheck?: boolean;
1228
1271
  /**
1229
1272
  * The executor has the platform's sign-in dependencies and a ZITADEL
1230
1273
  * client for its instance. Not a block: without it the sign-in policy
@@ -1265,6 +1308,8 @@ export interface PlanPlatformOptions {
1265
1308
  drifted?: {
1266
1309
  archon?: boolean;
1267
1310
  auth?: boolean;
1311
+ /** #643: a recorded sign-in app is missing or not running; plans the step again, minting nothing. */
1312
+ authApps?: boolean;
1268
1313
  edge?: boolean;
1269
1314
  files?: boolean;
1270
1315
  backupStorage?: boolean;