@wtfalch/foundry 0.45.1 → 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 +4 -2
  2. package/dist/index.js +2 -1
  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
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg } from '../org.js';
1
+ import { type DeclaredOrg } from '../org.js';
2
2
  import type { UnmanagedItem } from '../plan.js';
3
3
  import type { DraftStep } from './diff.js';
4
4
  /**
@@ -11,6 +11,8 @@ import type { DraftStep } from './diff.js';
11
11
  * `app` is the hosting entry's database, recorded as a resource id.
12
12
  */
13
13
  export declare function companyDatabaseKeys(org: Pick<DeclaredOrg, 'id'>): Readonly<Record<string, string>>;
14
+ /** The {@link companyDatabaseKeys} name of each home's legacy Coolify database, which its backup schedule is recorded under. */
15
+ export declare const LEGACY_BACKUP_NAMES: Readonly<Record<string, string>>;
14
16
  /**
15
17
  * The database a company backup schedule must name when the Coolify resource
16
18
  * is not left to pick its first database. Absent: the resource's first database.
@@ -20,6 +22,10 @@ export declare const COMPANY_BACKUP_DATABASE_NAMES: Readonly<Record<string, stri
20
22
  export declare const BACKUP_SCOPE_PREFIX = "backup_";
21
23
  /** The recorded-id key a company database's backup schedule uuid is stored under. */
22
24
  export declare function backupScopeKey(db: string): string;
25
+ /** The recorded-id key that holds the database list the company server's schedule was last set to. */
26
+ export declare const BACKUP_POSTGRES_DATABASES_SCOPE_KEY = "backup_postgres_databases";
27
+ /** Comma-separated `databases_to_backup` for the server schedule: every home on the server, sorted. */
28
+ export declare function serverBackupDatabases(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): string;
23
29
  /** The databases an upgrade of `service` can migrate, by the names in {@link companyDatabaseKeys}. Empty: not coverable here. */
24
30
  export declare function databasesForService(service: string): readonly string[] | 'all';
25
31
  /**
@@ -1,6 +1,8 @@
1
+ import { ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, orgServiceDatabase, } from '../org.js';
1
2
  import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
2
3
  import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
3
4
  import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
5
+ import { serviceOnServer } from './org-postgres.js';
4
6
  import { signinDbBackupMissing } from './signin-db-backup.js';
5
7
  import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db-tls.js';
6
8
  /**
@@ -29,8 +31,21 @@ export function companyDatabaseKeys(org) {
29
31
  boule: BOULE_DATABASE_SCOPE_KEY,
30
32
  chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
31
33
  chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
34
+ postgres: ORG_POSTGRES_SERVER_SCOPE_KEY,
32
35
  };
33
36
  }
37
+ /** The {@link companyDatabaseKeys} name of each home's legacy Coolify database, which its backup schedule is recorded under. */
38
+ export const LEGACY_BACKUP_NAMES = {
39
+ app: 'app',
40
+ cms: 'cms',
41
+ boule: 'boule',
42
+ chat_service: 'chat',
43
+ calls_service: 'calls',
44
+ chat_synapse: 'chat_synapse',
45
+ chat_mas: 'chat_mas',
46
+ calls_synapse: 'synapse',
47
+ calls_mas: 'mas',
48
+ };
34
49
  /**
35
50
  * The database a company backup schedule must name when the Coolify resource
36
51
  * is not left to pick its first database. Absent: the resource's first database.
@@ -44,6 +59,15 @@ export const BACKUP_SCOPE_PREFIX = 'backup_';
44
59
  export function backupScopeKey(db) {
45
60
  return `${BACKUP_SCOPE_PREFIX}${db}`;
46
61
  }
62
+ /** The recorded-id key that holds the database list the company server's schedule was last set to. */
63
+ export const BACKUP_POSTGRES_DATABASES_SCOPE_KEY = 'backup_postgres_databases';
64
+ /** Comma-separated `databases_to_backup` for the server schedule: every home on the server, sorted. */
65
+ export function serverBackupDatabases(org, recordedIds) {
66
+ return ORG_SERVICE_SCHEMAS.filter((home) => serviceOnServer(org, home, recordedIds))
67
+ .map((home) => orgServiceDatabase(org, home).database)
68
+ .sort()
69
+ .join(',');
70
+ }
47
71
  /** The databases an upgrade of `service` can migrate, by the names in {@link companyDatabaseKeys}. Empty: not coverable here. */
48
72
  export function databasesForService(service) {
49
73
  switch (service) {
@@ -80,7 +104,18 @@ export function planCompanyBackups(org, recordedIds, storageUuid) {
80
104
  return { steps, blocked };
81
105
  for (const [db, key] of Object.entries(companyDatabaseKeys(org))) {
82
106
  const databaseId = recordedIds[key];
83
- if (!databaseId || recordedIds[backupScopeKey(db)])
107
+ if (!databaseId)
108
+ continue;
109
+ let databaseNames;
110
+ if (db === 'postgres') {
111
+ databaseNames = serverBackupDatabases(org, recordedIds);
112
+ if (!databaseNames)
113
+ continue;
114
+ if (recordedIds[backupScopeKey(db)] &&
115
+ recordedIds[BACKUP_POSTGRES_DATABASES_SCOPE_KEY] === databaseNames)
116
+ continue;
117
+ }
118
+ else if (recordedIds[backupScopeKey(db)])
84
119
  continue;
85
120
  if (!storageUuid) {
86
121
  blocked.push({
@@ -94,7 +129,12 @@ export function planCompanyBackups(org, recordedIds, storageUuid) {
94
129
  system: 'coolify',
95
130
  kind: 'backup',
96
131
  key: `${org.slug}:${db}`,
97
- desired: { databaseId, database: db, storageUuid },
132
+ desired: {
133
+ databaseId,
134
+ database: db,
135
+ storageUuid,
136
+ ...(databaseNames ? { databaseNames } : {}),
137
+ },
98
138
  },
99
139
  step: {
100
140
  description: `schedule a nightly backup of ${org.slug}'s ${db} database to the platform's backup bucket`,
@@ -0,0 +1,41 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ /** The auth release that holds both the shared-host organisation check (577) and the session binding (795). */
3
+ export declare const MIN_AUTH_FOR_SESSION_BINDING = "0.28.0";
4
+ export declare const MIN_AUTH_FOR_CLIENT_ORGS = "0.28.0";
5
+ /**
6
+ * Scope id set to `'1'` once `LOGIN_SESSION_BIND_KEY` is live on the in-app login app (written, and the
7
+ * app deployed after it). No binding carries a cookie domain before it.
8
+ */
9
+ export declare const SIGN_IN_BIND_KEY_SCOPE_KEY = "sign_in_bind_key_ready";
10
+ /** The env that holds the key: at least 32 characters, secret, on the in-app login app only. */
11
+ export declare const SIGN_IN_BIND_KEY_ENV = "LOGIN_SESSION_BIND_KEY";
12
+ export declare const SIGN_IN_BIND_KEY_MIN_LENGTH = 32;
13
+ /** The cookie domain the app-host bindings were last written with (`''` for none). */
14
+ export declare const LOGIN_COOKIE_DOMAIN_SCOPE_KEY = "login_cookie_domain";
15
+ /**
16
+ * True only for a `vX.Y.Z` tag at or above `min`. A branch, a sha or anything else is false, so a
17
+ * platform on `main` never passes a gate that needs a release.
18
+ */
19
+ export declare function authAtLeast(gitRef: string, min: string): boolean;
20
+ export type CookieDomainDecision = {
21
+ domain: string;
22
+ } | {
23
+ domain: null;
24
+ reason: string;
25
+ };
26
+ /** Reasons that wait on a release or a key; the planner reports these. The others are the declaration's own shape. */
27
+ export declare const COOKIE_DOMAIN_WAITING_REASONS: Set<string>;
28
+ /**
29
+ * The company-wide cookie domain for an org's app-host bindings, or why there is none. A domain is
30
+ * returned only when the sign-in is in-app with app hosts bound, the org has its own domain, nothing
31
+ * but estate apps is public under it, the login is a release that binds sessions to the host, and the
32
+ * bind key is live. A company on a shared domain never gets one: a cookie there would span companies.
33
+ */
34
+ export declare function cookieDomainAllowed(input: {
35
+ org: DeclaredOrg;
36
+ recorded: Readonly<Record<string, string>>;
37
+ authGitRef: string;
38
+ bindKeyReady: boolean;
39
+ }): CookieDomainDecision;
40
+ /** The fixed line the planner reports while a cookie domain waits on the login. */
41
+ export declare function cookieDomainWaitingLine(slug: string, reason: string): string;
@@ -0,0 +1,71 @@
1
+ import { ownsServices } from '../org.js';
2
+ import { inAppApps } from './login-host.js';
3
+ /** The auth release that holds both the shared-host organisation check (577) and the session binding (795). */
4
+ export const MIN_AUTH_FOR_SESSION_BINDING = '0.28.0';
5
+ export const MIN_AUTH_FOR_CLIENT_ORGS = '0.28.0';
6
+ /**
7
+ * Scope id set to `'1'` once `LOGIN_SESSION_BIND_KEY` is live on the in-app login app (written, and the
8
+ * app deployed after it). No binding carries a cookie domain before it.
9
+ */
10
+ export const SIGN_IN_BIND_KEY_SCOPE_KEY = 'sign_in_bind_key_ready';
11
+ /** The env that holds the key: at least 32 characters, secret, on the in-app login app only. */
12
+ export const SIGN_IN_BIND_KEY_ENV = 'LOGIN_SESSION_BIND_KEY';
13
+ export const SIGN_IN_BIND_KEY_MIN_LENGTH = 32;
14
+ /** The cookie domain the app-host bindings were last written with (`''` for none). */
15
+ export const LOGIN_COOKIE_DOMAIN_SCOPE_KEY = 'login_cookie_domain';
16
+ const TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
17
+ /**
18
+ * True only for a `vX.Y.Z` tag at or above `min`. A branch, a sha or anything else is false, so a
19
+ * platform on `main` never passes a gate that needs a release.
20
+ */
21
+ export function authAtLeast(gitRef, min) {
22
+ const have = TAG.exec(gitRef);
23
+ const want = /^(\d+)\.(\d+)\.(\d+)$/.exec(min);
24
+ if (!have || !want)
25
+ return false;
26
+ for (let i = 1; i <= 3; i++) {
27
+ const a = Number(have[i]);
28
+ const b = Number(want[i]);
29
+ if (a !== b)
30
+ return a > b;
31
+ }
32
+ return true;
33
+ }
34
+ /** Reasons that wait on a release or a key; the planner reports these. The others are the declaration's own shape. */
35
+ export const COOKIE_DOMAIN_WAITING_REASONS = new Set([
36
+ 'auth below 0.28.0',
37
+ 'the session bind key is not live on the in-app login',
38
+ ]);
39
+ /**
40
+ * The company-wide cookie domain for an org's app-host bindings, or why there is none. A domain is
41
+ * returned only when the sign-in is in-app with app hosts bound, the org has its own domain, nothing
42
+ * but estate apps is public under it, the login is a release that binds sessions to the host, and the
43
+ * bind key is live. A company on a shared domain never gets one: a cookie there would span companies.
44
+ */
45
+ export function cookieDomainAllowed(input) {
46
+ const { org, recorded } = input;
47
+ if (inAppApps(org, recorded).length === 0) {
48
+ return { domain: null, reason: 'in-app sign-in has no app host bound' };
49
+ }
50
+ if (!org.domain)
51
+ return { domain: null, reason: 'the company has no domain of its own' };
52
+ // An own org's in-app login app is not given the key by the platform.
53
+ if (ownsServices(org)) {
54
+ return { domain: null, reason: 'the company runs its own sign-in' };
55
+ }
56
+ const publicApps = org.hosting.entries.some((e) => e.hostnames.length > 0 || e.worker !== undefined || e.workerApp !== undefined);
57
+ if (publicApps) {
58
+ return { domain: null, reason: 'a hosted app is public under the company domain' };
59
+ }
60
+ if (!authAtLeast(input.authGitRef, MIN_AUTH_FOR_SESSION_BINDING)) {
61
+ return { domain: null, reason: 'auth below 0.28.0' };
62
+ }
63
+ if (!input.bindKeyReady) {
64
+ return { domain: null, reason: 'the session bind key is not live on the in-app login' };
65
+ }
66
+ return { domain: org.domain };
67
+ }
68
+ /** The fixed line the planner reports while a cookie domain waits on the login. */
69
+ export function cookieDomainWaitingLine(slug, reason) {
70
+ return `${slug}'s company-wide sign-in cookie waits: ${reason}`;
71
+ }
@@ -0,0 +1,58 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import { type AgoraHostValues, DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_PORT, desktopAuthInternalName, desktopAuthUpstream } from './agora.js';
3
+ import type { DraftStep } from './diff.js';
4
+ export { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_PORT, desktopAuthInternalName, desktopAuthUpstream, };
5
+ /**
6
+ * #440: Agora's desktop sign-in backend (`apps/desktop-auth` of the agora repo), one internal app per
7
+ * company on its Postgres server's `desktop_auth` home. Agora proxies `/auth/desktop/*` to it. Its own
8
+ * secrets (cookie, handoff key) live in its env only and are generated once.
9
+ */
10
+ export declare const DESKTOP_AUTH_DOCKERFILE = "/apps/desktop-auth/Dockerfile";
11
+ export declare const DESKTOP_AUTH_HOME = "desktop_auth";
12
+ /** The digest of the non-secret env last written; a changed hostname, issuer or id plans the step again. */
13
+ export declare const DESKTOP_AUTH_REVISION_SCOPE_KEY = "coolify_desktop_auth_revision";
14
+ /** The Agora origin the app was last written for (CLAUDE.md rule 3: the applied hostname is a scope id). */
15
+ export declare const DESKTOP_AUTH_ORIGIN_SCOPE_KEY = "coolify_desktop_auth_origin";
16
+ /** The callback URI last put on Agora's client, or `unsupported:<uri>` when the providers client cannot. */
17
+ export declare const DESKTOP_AUTH_REDIRECT_SCOPE_KEY = "coolify_desktop_auth_redirect";
18
+ /** Recorded once the readiness probe through Agora answered. */
19
+ export declare const DESKTOP_AUTH_READY_SCOPE_KEY = "coolify_desktop_auth_ready";
20
+ /** `https://<agora host>/auth/desktop/callback`. */
21
+ export declare function desktopAuthCallbackUri(agoraOrigin: string): string;
22
+ export declare const desktopAuthOrigin: (org: DeclaredOrg) => string;
23
+ /** The route desktop-auth answers 200 on without a session; foundry probes it through Agora. */
24
+ export declare const DESKTOP_AUTH_READY_PATH = "/auth/desktop/error";
25
+ export declare const desktopAuthRedirectUnsupported: (uri: string) => string;
26
+ /** The values both the plan and the executor derive the env from. */
27
+ export interface DesktopAuthInputs {
28
+ origin: string;
29
+ issuer: string;
30
+ clientId: string;
31
+ organisationId: string;
32
+ projectId: string;
33
+ mailProjectId: string;
34
+ filesProjectId: string | null;
35
+ }
36
+ /** The inputs from the host values and recorded ids, and the names of those still missing. */
37
+ export declare function desktopAuthInputs(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, rawHost: AgoraHostValues): {
38
+ inputs: DesktopAuthInputs | null;
39
+ missing: string[];
40
+ };
41
+ /** The env that is not a secret and not a connection URL. `APP_URL` is the origin with its slash: the app refuses any other form. */
42
+ export declare function desktopAuthEnv(inputs: DesktopAuthInputs, runtimeRole: string): Record<string, string>;
43
+ export declare function desktopAuthRevision(env: Readonly<Record<string, string>>): string;
44
+ /**
45
+ * The `coolify:desktop-auth` step. Planned when the app is not recorded, when the env it was written for
46
+ * differs (a rename moves the origin), when it never ran healthy, when the callback URI is not on Agora's
47
+ * client, and when Agora carries the upstream but the readiness probe never answered. Not planned while
48
+ * Agora is blocked (its own line says why) or while the `desktop_auth` home cannot exist yet (blocked).
49
+ * `homePlanned` is true when the databases step in this plan creates the home.
50
+ */
51
+ export declare function planDesktopAuth(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, input: {
52
+ host: AgoraHostValues | null;
53
+ liveAgoraEnv: Readonly<Record<string, string>> | null;
54
+ homePlanned: boolean;
55
+ }): {
56
+ step: DraftStep | null;
57
+ blocked: string | null;
58
+ };
@@ -0,0 +1,144 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { ORG_SERVICE_SCHEMAS, orgServiceDatabase } from '../org.js';
3
+ import { AGORA_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_PORT, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, desktopAuthInternalName, desktopAuthUpstream, withRecordedMessagingProject, } from './agora.js';
4
+ import { ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
5
+ export { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_PORT, desktopAuthInternalName, desktopAuthUpstream, };
6
+ /**
7
+ * #440: Agora's desktop sign-in backend (`apps/desktop-auth` of the agora repo), one internal app per
8
+ * company on its Postgres server's `desktop_auth` home. Agora proxies `/auth/desktop/*` to it. Its own
9
+ * secrets (cookie, handoff key) live in its env only and are generated once.
10
+ */
11
+ export const DESKTOP_AUTH_DOCKERFILE = '/apps/desktop-auth/Dockerfile';
12
+ export const DESKTOP_AUTH_HOME = 'desktop_auth';
13
+ /** The digest of the non-secret env last written; a changed hostname, issuer or id plans the step again. */
14
+ export const DESKTOP_AUTH_REVISION_SCOPE_KEY = 'coolify_desktop_auth_revision';
15
+ /** The Agora origin the app was last written for (CLAUDE.md rule 3: the applied hostname is a scope id). */
16
+ export const DESKTOP_AUTH_ORIGIN_SCOPE_KEY = 'coolify_desktop_auth_origin';
17
+ /** The callback URI last put on Agora's client, or `unsupported:<uri>` when the providers client cannot. */
18
+ export const DESKTOP_AUTH_REDIRECT_SCOPE_KEY = 'coolify_desktop_auth_redirect';
19
+ /** Recorded once the readiness probe through Agora answered. */
20
+ export const DESKTOP_AUTH_READY_SCOPE_KEY = 'coolify_desktop_auth_ready';
21
+ /** `https://<agora host>/auth/desktop/callback`. */
22
+ export function desktopAuthCallbackUri(agoraOrigin) {
23
+ return `${agoraOrigin}/auth/desktop/callback`;
24
+ }
25
+ export const desktopAuthOrigin = (org) => `https://${agoraHostnameFor(org)}`;
26
+ /** The route desktop-auth answers 200 on without a session; foundry probes it through Agora. */
27
+ export const DESKTOP_AUTH_READY_PATH = '/auth/desktop/error';
28
+ export const desktopAuthRedirectUnsupported = (uri) => `unsupported:${uri}`;
29
+ /** The inputs from the host values and recorded ids, and the names of those still missing. */
30
+ export function desktopAuthInputs(org, recorded, rawHost) {
31
+ const host = withRecordedMessagingProject(rawHost, recorded);
32
+ const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
33
+ const organisationId = recorded[ZITADEL_ORG_SCOPE_KEY];
34
+ const missing = [];
35
+ if (!host.issuer)
36
+ missing.push('issuer');
37
+ if (!clientId)
38
+ missing.push('clientId');
39
+ if (!organisationId)
40
+ missing.push('zitadelOrgId');
41
+ if (!host.messagingProjectId)
42
+ missing.push('messagingProjectId');
43
+ if (!host.mailProjectId)
44
+ missing.push('mailProjectId');
45
+ if (missing.length > 0 || !host.issuer || !clientId || !organisationId) {
46
+ return { inputs: null, missing };
47
+ }
48
+ return {
49
+ inputs: {
50
+ origin: desktopAuthOrigin(org),
51
+ issuer: host.issuer,
52
+ clientId,
53
+ organisationId,
54
+ projectId: host.messagingProjectId,
55
+ mailProjectId: host.mailProjectId,
56
+ filesProjectId: recorded[ZITADEL_FILES_PROJECT_SCOPE_KEY] ?? null,
57
+ },
58
+ missing,
59
+ };
60
+ }
61
+ /** The env that is not a secret and not a connection URL. `APP_URL` is the origin with its slash: the app refuses any other form. */
62
+ export function desktopAuthEnv(inputs, runtimeRole) {
63
+ return {
64
+ APP_URL: `${inputs.origin}/`,
65
+ AUTH_ISSUER: inputs.issuer,
66
+ AUTH_CLIENT_ID: inputs.clientId,
67
+ AUTH_ORGANIZATION_ID: inputs.organisationId,
68
+ AUTH_PROJECT_ID: inputs.projectId,
69
+ MAIL_PROJECT_ID: inputs.mailProjectId,
70
+ ...(inputs.filesProjectId ? { FILES_PROJECT_ID: inputs.filesProjectId } : {}),
71
+ DESKTOP_AUTH_RUNTIME_ROLE: runtimeRole,
72
+ PORT: DESKTOP_AUTH_PORT,
73
+ };
74
+ }
75
+ export function desktopAuthRevision(env) {
76
+ const sorted = Object.entries(env).sort(([a], [b]) => (a < b ? -1 : 1));
77
+ return createHash('sha256').update(JSON.stringify(sorted)).digest('hex').slice(0, 16);
78
+ }
79
+ /** The desktop-auth runtime role's name; the plan needs it for the revision before any URL exists. */
80
+ function runtimeRoleFor(org) {
81
+ const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === DESKTOP_AUTH_HOME);
82
+ if (!home)
83
+ throw new Error('the desktop_auth home is missing from the topology');
84
+ return orgServiceDatabase(org, home).runtimeRole;
85
+ }
86
+ /**
87
+ * The `coolify:desktop-auth` step. Planned when the app is not recorded, when the env it was written for
88
+ * differs (a rename moves the origin), when it never ran healthy, when the callback URI is not on Agora's
89
+ * client, and when Agora carries the upstream but the readiness probe never answered. Not planned while
90
+ * Agora is blocked (its own line says why) or while the `desktop_auth` home cannot exist yet (blocked).
91
+ * `homePlanned` is true when the databases step in this plan creates the home.
92
+ */
93
+ export function planDesktopAuth(org, recorded, input) {
94
+ if (!input.host)
95
+ return { step: null, blocked: null };
96
+ const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === DESKTOP_AUTH_HOME);
97
+ if (!home || (!recorded[home.scopeKey] && !input.homePlanned)) {
98
+ return {
99
+ step: null,
100
+ blocked: `${org.slug}'s desktop sign-in service is blocked, not planned: its database on the company Postgres server is not recorded yet`,
101
+ };
102
+ }
103
+ const { inputs } = desktopAuthInputs(org, recorded, input.host);
104
+ const callback = desktopAuthCallbackUri(desktopAuthOrigin(org));
105
+ const redirect = recorded[DESKTOP_AUTH_REDIRECT_SCOPE_KEY];
106
+ const blocked = recorded[DESKTOP_AUTH_APP_SCOPE_KEY] && redirect === desktopAuthRedirectUnsupported(callback)
107
+ ? `${org.slug}'s Agora client lacks the desktop callback ${callback}: the providers client cannot change redirect URIs; add it by hand in ZITADEL or update @wtfalch/providers, then remove the recorded key ${DESKTOP_AUTH_REDIRECT_SCOPE_KEY}`
108
+ : null;
109
+ const revision = inputs ? desktopAuthRevision(desktopAuthEnv(inputs, runtimeRoleFor(org))) : null;
110
+ const upstreamLive = Boolean(input.liveAgoraEnv?.AGORA_DESKTOP_AUTH_UPSTREAM);
111
+ const reasons = [];
112
+ if (!recorded[DESKTOP_AUTH_APP_SCOPE_KEY])
113
+ reasons.push('provision');
114
+ else {
115
+ if (revision && recorded[DESKTOP_AUTH_REVISION_SCOPE_KEY] !== revision)
116
+ reasons.push('env');
117
+ if (!recorded[DESKTOP_AUTH_DEPLOYED_SCOPE_KEY])
118
+ reasons.push('never ran healthy');
119
+ if (redirect !== callback && !blocked)
120
+ reasons.push('callback URI not on the Agora client');
121
+ if (upstreamLive && !recorded[DESKTOP_AUTH_READY_SCOPE_KEY])
122
+ reasons.push('not probed');
123
+ }
124
+ if (reasons.length === 0)
125
+ return { step: null, blocked };
126
+ return {
127
+ step: {
128
+ action: {
129
+ system: 'coolify',
130
+ kind: 'desktop-auth',
131
+ key: org.id,
132
+ desired: { host: input.host, agoraOrigin: desktopAuthOrigin(org) },
133
+ },
134
+ step: {
135
+ description: reasons[0] === 'provision'
136
+ ? `provision ${org.slug}'s desktop sign-in service`
137
+ : `update ${org.slug}'s desktop sign-in service (${reasons.join('; ')})`,
138
+ destroys: false,
139
+ migrates: false,
140
+ },
141
+ },
142
+ blocked,
143
+ };
144
+ }
@@ -0,0 +1,26 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import type { LiveResource } from './live.js';
4
+ /**
5
+ * #339: the desktop app signs in through the company's web app, so every
6
+ * `@wtfalch/auth` web client foundry makes registers this path beside
7
+ * `/auth/callback`. Agora's own client (`/callback`) is not one of them.
8
+ */
9
+ export declare const DESKTOP_CALLBACK_PATH = "/auth/desktop/callback";
10
+ export declare function desktopCallbackUri(host: string): string;
11
+ /** The URIs to add to an app's current list: current first, then each wanted one it lacks. */
12
+ export declare function unionRedirectUris(current: readonly string[], wanted: readonly string[]): string[];
13
+ /**
14
+ * The ZITADEL app id of company app `appKey` once its desktop callback was
15
+ * registered. `planDesktopCallbacks` names an app while its recorded id
16
+ * differs, so an app made before the callback existed, or made again under a
17
+ * new id, is repaired once.
18
+ */
19
+ export declare function zitadelAppDesktopCallbackAppliedScopeKey(appKey: string): string;
20
+ /**
21
+ * One `zitadel:desktop-callback` step names every live web sign-in app whose
22
+ * recorded id differs from its marker. Plans nothing when the host's ZITADEL
23
+ * client lacks the two redirect URI calls (`wired`), before the org and
24
+ * project are recorded, or while the platform's sign-in is not ready.
25
+ */
26
+ export declare function planDesktopCallbacks(org: DeclaredOrg, live: readonly LiveResource[], recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], authReady: boolean, wired: boolean): DraftStep | null;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * #339: the desktop app signs in through the company's web app, so every
3
+ * `@wtfalch/auth` web client foundry makes registers this path beside
4
+ * `/auth/callback`. Agora's own client (`/callback`) is not one of them.
5
+ */
6
+ export const DESKTOP_CALLBACK_PATH = '/auth/desktop/callback';
7
+ export function desktopCallbackUri(host) {
8
+ return `https://${host}${DESKTOP_CALLBACK_PATH}`;
9
+ }
10
+ /** The URIs to add to an app's current list: current first, then each wanted one it lacks. */
11
+ export function unionRedirectUris(current, wanted) {
12
+ return [...new Set([...current, ...wanted])];
13
+ }
14
+ /**
15
+ * The ZITADEL app id of company app `appKey` once its desktop callback was
16
+ * registered. `planDesktopCallbacks` names an app while its recorded id
17
+ * differs, so an app made before the callback existed, or made again under a
18
+ * new id, is repaired once.
19
+ */
20
+ export function zitadelAppDesktopCallbackAppliedScopeKey(appKey) {
21
+ return `zitadel_app_desktop_callback_applied.${appKey}`;
22
+ }
23
+ /** The fixed Agora client keeps its own `/callback`; it is not built on `@wtfalch/auth`. */
24
+ const NO_DESKTOP_CALLBACK_APPS = new Set(['agora']);
25
+ /**
26
+ * One `zitadel:desktop-callback` step names every live web sign-in app whose
27
+ * recorded id differs from its marker. Plans nothing when the host's ZITADEL
28
+ * client lacks the two redirect URI calls (`wired`), before the org and
29
+ * project are recorded, or while the platform's sign-in is not ready.
30
+ */
31
+ export function planDesktopCallbacks(org, live, recordedIds, plannedSteps, authReady, wired) {
32
+ if (!wired || !authReady || !recordedIds.zitadel_org || !recordedIds.zitadel_project)
33
+ return null;
34
+ const apps = live
35
+ .filter((r) => r.system === 'zitadel' &&
36
+ r.kind === 'app' &&
37
+ r.config.appType === 'web' &&
38
+ !NO_DESKTOP_CALLBACK_APPS.has(r.key))
39
+ .map((r) => r.key)
40
+ // An app this plan recreates gets its marker when it is made.
41
+ .filter((appKey) => !plannedSteps.some((s) => s.action.system === 'zitadel' &&
42
+ s.action.kind === 'app' &&
43
+ s.action.key === appKey &&
44
+ s.action.desired.recreate === true))
45
+ .filter((appKey) => {
46
+ const appId = recordedIds[`zitadel:app:${appKey}`];
47
+ return (Boolean(appId) && recordedIds[zitadelAppDesktopCallbackAppliedScopeKey(appKey)] !== appId);
48
+ });
49
+ if (apps.length === 0)
50
+ return null;
51
+ return {
52
+ action: { system: 'zitadel', kind: 'desktop-callback', key: org.id, desired: { apps } },
53
+ step: {
54
+ description: `register the desktop sign-in callback for ${org.slug}'s sign-in apps (${apps.join(', ')})`,
55
+ destroys: false,
56
+ migrates: false,
57
+ },
58
+ };
59
+ }
@@ -2,7 +2,7 @@ import type { DeclaredOrg, OrgId, OrgServiceSchema } from '../org.js';
2
2
  import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
3
3
  import { type AgoraHostReading } from './agora.js';
4
4
  import { type BlueGreenServer } from './blue-green.js';
5
- import type { LiveResource } from './live.js';
5
+ import { type LiveResource } from './live.js';
6
6
  import { type OrgPlatformReadiness } from './platform-org.js';
7
7
  import { type RemoteDatabaseFacts } from './remote-database.js';
8
8
  /**
@@ -49,10 +49,16 @@ export interface PlanOrgOptions {
49
49
  oikosZoneReady?: boolean;
50
50
  /** #267: the executor was built with a `loginHostBindings` port. */
51
51
  loginHostBindings?: boolean;
52
+ /** The `loginHostBindings` port writes the `clientOrganizations` map (577). */
53
+ loginHostClientOrganizations?: boolean;
52
54
  /** A live read said the org's login host serves OIDC discovery as `https://<host>`. Absent: not read, so Agora keeps the platform issuer. */
53
55
  loginHostServesDiscovery?: boolean;
54
56
  /** The executor's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`; without it `planIdTokenUserinfo` plans nothing. */
55
57
  zitadelIdTokenUserinfo?: boolean;
58
+ /** The executor's ZITADEL client has `updateOidcAppPostLogoutRedirectUris`; without it `planPostLogoutUris` plans nothing. */
59
+ zitadelPostLogoutUris?: boolean;
60
+ /** The executor's ZITADEL client has `getOidcAppRedirectUris` and `updateOidcAppRedirectUris` (providers 0.12.0); without them `planDesktopCallbacks` plans nothing. */
61
+ zitadelRedirectUris?: boolean;
56
62
  /** The executor's ZITADEL client has `ensureOidcAppMcpAccess` and `ensureAccessTokenLifetime`; without them `planMcp` is blocked. */
57
63
  zitadelMcp?: boolean;
58
64
  /** The executor's ZITADEL client has `setOrgMetadata` and `removeOrgMetadata`; without them the customers organisation's sign-in metadata is reported blocked. */
@@ -203,9 +209,10 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
203
209
  export declare function isPlatformOwnOrg(org: Pick<DeclaredOrg, 'id'>, platformOwnerOrgId: string | null): boolean;
204
210
  /**
205
211
  * What {@link planOrgPostgres} reports about the org's one shared Postgres
206
- * server and its fixed schema topology (#444a, "manifest-and-plan-only"):
207
- * missing only, nothing applied. Deliberately **not** folded into
208
- * `plannedSteps` -- the same reason {@link VersionChangeItem} (`../plan.ts`)
212
+ * server and its fixed schema topology (#444a). Since #444 `planOrgPostgresSteps`
213
+ * turns it into `coolify:org-postgres` and `coolify:org-postgres-databases` steps; this
214
+ * report stays. The old note below on why it was **not** folded into
215
+ * `plannedSteps` is kept for the history -- the same reason {@link VersionChangeItem} (`../plan.ts`)
209
216
  * never is, restated in `computePlanDetail`'s own doc comment below, but
210
217
  * sharper here: `plan-apply.ts`'s `runSteps` executes every
211
218
  * `detail.plannedSteps` entry through `StepExecutor.execute`, which
@@ -236,6 +243,8 @@ export interface OrgPostgresPlan {
236
243
  * it as part of the full topology.
237
244
  */
238
245
  missingSchemas: readonly OrgServiceSchema[];
246
+ /** Homes whose data is still in a legacy Coolify database. Empty for a new company. Informational; a step exists only after the confirmation. */
247
+ legacyUnmigrated: readonly OrgServiceSchema[];
239
248
  }
240
249
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
241
250
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";