@wtfalch/foundry 0.42.1 → 0.44.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 (73) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +8 -4
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0055_release_requests.sql +20 -0
  5. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  6. package/dist/migrations/0057_org_customers.sql +29 -0
  7. package/dist/org.d.ts +55 -0
  8. package/dist/org.js +90 -27
  9. package/dist/plan/archon-blue-green.d.ts +54 -0
  10. package/dist/plan/archon-blue-green.js +72 -0
  11. package/dist/plan/blue-green-apply.d.ts +82 -0
  12. package/dist/plan/blue-green-apply.js +360 -0
  13. package/dist/plan/blue-green.d.ts +223 -0
  14. package/dist/plan/blue-green.js +444 -0
  15. package/dist/plan/boule.d.ts +43 -2
  16. package/dist/plan/boule.js +48 -1
  17. package/dist/plan/broker-entries.d.ts +28 -0
  18. package/dist/plan/broker-entries.js +46 -1
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/diff.d.ts +20 -0
  22. package/dist/plan/diff.js +207 -20
  23. package/dist/plan/executor.d.ts +134 -2
  24. package/dist/plan/executor.js +517 -25
  25. package/dist/plan/hostnames.d.ts +7 -0
  26. package/dist/plan/hostnames.js +17 -2
  27. package/dist/plan/locked-hostnames.d.ts +11 -0
  28. package/dist/plan/locked-hostnames.js +13 -0
  29. package/dist/plan/mcp.d.ts +57 -0
  30. package/dist/plan/mcp.js +131 -0
  31. package/dist/plan/org-names.d.ts +2 -2
  32. package/dist/plan/org-names.js +16 -1
  33. package/dist/plan/plan-apply.d.ts +77 -0
  34. package/dist/plan/plan-apply.js +267 -5
  35. package/dist/plan/platform-diff.d.ts +9 -0
  36. package/dist/plan/platform-diff.js +24 -1
  37. package/dist/plan/platform-providers.d.ts +15 -1
  38. package/dist/plan/platform-providers.js +90 -5
  39. package/dist/plan/proxy-labels.d.ts +2 -2
  40. package/dist/plan/proxy-labels.js +14 -4
  41. package/dist/plan/release-host.d.ts +59 -0
  42. package/dist/plan/release-host.js +239 -0
  43. package/dist/plan/remote-database.d.ts +124 -0
  44. package/dist/plan/remote-database.js +373 -0
  45. package/dist/plan/worker-app-database.d.ts +148 -0
  46. package/dist/plan/worker-app-database.js +424 -0
  47. package/dist/plan/worker-app.d.ts +320 -0
  48. package/dist/plan/worker-app.js +792 -0
  49. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  50. package/dist/plan/zitadel-machine-user.js +80 -0
  51. package/dist/platform.d.ts +11 -1
  52. package/dist/platform.js +4 -1
  53. package/dist/release/decide.d.ts +95 -0
  54. package/dist/release/decide.js +242 -0
  55. package/dist/release/health.d.ts +48 -0
  56. package/dist/release/health.js +61 -0
  57. package/dist/release/run.d.ts +40 -0
  58. package/dist/release/run.js +56 -0
  59. package/dist/release/state.d.ts +64 -0
  60. package/dist/release/state.js +126 -0
  61. package/dist/release/types.d.ts +54 -0
  62. package/dist/release/types.js +4 -0
  63. package/dist/requested-changes.d.ts +12 -3
  64. package/dist/requested-changes.js +22 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +67 -8
  67. package/dist/store/schema.d.ts +188 -0
  68. package/dist/store/schema.js +15 -0
  69. package/dist/worker-app-config.d.ts +151 -0
  70. package/dist/worker-app-config.js +438 -0
  71. package/package.json +1 -1
  72. package/versions.digests.json +2 -1
  73. package/versions.json +2 -1
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { isFoundryOwnedBrokerEntryName, } from '../org.js';
3
3
  import { entryClientIdScopeKey } from './agora.js';
4
4
  import { entryHostnames } from './hostnames.js';
5
+ import { loginHostnameFor } from './login-host.js';
5
6
  import { PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
6
7
  import { archonOrgConflicts, brokerDeployNeeded, brokerDeployRecordValue, missingBrokerInputs, validateBrokerAppEntry, } from './platform-providers.js';
7
8
  /**
@@ -28,6 +29,31 @@ export function brokerEntryAppliedScopeKey(entryKey) {
28
29
  export function brokerEntryWarningsScopeKey(entryKey) {
29
30
  return `broker_entry_warnings.${entryKey}`;
30
31
  }
32
+ /** The ZITADEL project id that is an MCP entry's token audience, recorded by `zitadel:mcp-project`. */
33
+ export const entryMcpProjectScopeKey = (entryKey) => `mcp_project_id.${entryKey}`;
34
+ /** The marker for the entry's app having JWT access tokens and the MCP callback (`zitadel:mcp-access`). */
35
+ export const entryMcpAccessAppliedScopeKey = (entryKey) => `mcp_access_applied.${entryKey}`;
36
+ /** The instance access-token lifetime the MCP flow wants, in seconds (auth ADR 0002, 4). */
37
+ export const MCP_ACCESS_TOKEN_LIFETIME_SECONDS = 3600;
38
+ /** Records the lifetime foundry last set; the value is {@link MCP_ACCESS_TOKEN_LIFETIME_SECONDS}. */
39
+ export const ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY = 'access_token_lifetime_applied';
40
+ /** The broker's callback on the org's login host. It joins the entry app's redirect URIs. */
41
+ export const mcpCallbackUri = (org) => `https://${loginHostnameFor(org)}/mcp-oauth/callback`;
42
+ /**
43
+ * What an entry's MCP resource is, from the one recorded project id. The
44
+ * broker entry (`wantedBrokerEntry`) and the app's env (`entryAuthEnv`) both
45
+ * read it here, so the audience the broker checks and the audience the app
46
+ * compares are the same value. `null` without `mcp`, or until the project
47
+ * exists.
48
+ */
49
+ export function entryMcp(org, entry, recorded) {
50
+ const mcp = entry.uses?.auth?.mcp;
51
+ const projectId = recorded[entryMcpProjectScopeKey(entry.key)];
52
+ const host = entryHostnames(org, entry)[0];
53
+ if (!mcp || !projectId || !host)
54
+ return null;
55
+ return { resource: `https://${host}${mcp.path}`, projectId, loginHost: loginHostnameFor(org) };
56
+ }
31
57
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
32
58
  export function entryAuthEnv(org, entry, recorded, issuer) {
33
59
  const auth = entry.uses?.auth;
@@ -39,6 +65,15 @@ export function entryAuthEnv(org, entry, recorded, issuer) {
39
65
  AUTH_CLIENT_ID: recorded[entryClientIdScopeKey(entry.key)],
40
66
  AUTH_ISSUER: issuer ?? undefined,
41
67
  };
68
+ const mcp = entryMcp(org, entry, recorded);
69
+ const mcpValues = mcp
70
+ ? {
71
+ MCP_AUDIENCE: mcp.projectId,
72
+ MCP_RESOURCE: mcp.resource,
73
+ MCP_ISSUER: `https://${mcp.loginHost}`,
74
+ }
75
+ : {};
76
+ Object.assign(values, mcpValues);
42
77
  return Object.fromEntries(Object.entries(values).filter(([, value]) => Boolean(value)));
43
78
  }
44
79
  /** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
@@ -60,7 +95,11 @@ export function wantedBrokerEntry(org, entry, recorded) {
60
95
  missing.push({ what: 'its sign-in client id', producedBy: ['zitadel', 'app'] });
61
96
  if (!mailDomain)
62
97
  missing.push({ what: 'the org mail domain', producedBy: ['coolify', 'mail'] });
63
- if (!organizationId || !clientId || !mailDomain)
98
+ const mcp = entryMcp(org, entry, recorded);
99
+ if (auth.mcp && !mcp) {
100
+ missing.push({ what: 'its MCP audience project id', producedBy: ['zitadel', 'mcp-project'] });
101
+ }
102
+ if (!organizationId || !clientId || !mailDomain || (auth.mcp && !mcp))
64
103
  return { missing };
65
104
  return {
66
105
  want: {
@@ -71,6 +110,7 @@ export function wantedBrokerEntry(org, entry, recorded) {
71
110
  from: `${org.mail.noReplyLocalPart || 'noreply'}@${mailDomain}`,
72
111
  registration: auth.registration === 'open',
73
112
  replaces: auth.replacesBrokerEntry ?? null,
113
+ ...(mcp ? { mcp } : {}),
74
114
  },
75
115
  };
76
116
  }
@@ -182,6 +222,11 @@ export async function writeBrokerEntry(broker, input) {
182
222
  from: want.from,
183
223
  registration: want.registration,
184
224
  };
225
+ // The previous write's `mcp` goes when the entry no longer declares it.
226
+ if (want.mcp)
227
+ entry.mcp = want.mcp;
228
+ else
229
+ entry.mcp = undefined;
185
230
  try {
186
231
  validateBrokerAppEntry(entry, brokerAppId, 0);
187
232
  }
@@ -76,3 +76,29 @@ export declare function ensureCloudflaredConnector(input: EnsureCloudflaredConne
76
76
  appId: string;
77
77
  deployed: boolean;
78
78
  }>;
79
+ /** The scope key of a non-primary server's connector: the tunnel's own key plus the server uuid. */
80
+ export declare function serverConnectorScopeKey(baseScopeKey: string, serverUuid: string): string;
81
+ /** A non-primary server's connector app name: the tunnel's own name plus the first eight characters of the uuid. */
82
+ export declare function serverConnectorAppName(baseAppName: string, serverUuid: string): string;
83
+ export interface EnsureConnectorsOnServersInput extends Omit<EnsureCloudflaredConnectorInput, 'resolveServer' | 'recordedConnectorAppId' | 'recordAppId' | 'appName'> {
84
+ /** The primary's connector keeps its name and scope key, so a record made before servers were many is still its record. */
85
+ baseAppName: string;
86
+ baseScopeKey: string;
87
+ primaryUuid: string;
88
+ serverUuids: readonly string[];
89
+ recorded: Readonly<Record<string, string>>;
90
+ /** Records the app id as soon as it exists; the caller persists it. */
91
+ record: (key: string, appId: string) => Promise<void>;
92
+ }
93
+ /**
94
+ * One connector app for the tunnel on each server, all with the tunnel's one token. Each server's connector
95
+ * reaches that server's own containers, so a request lands on a server with the app on it. The primary's
96
+ * connector is the existing one (found by its recorded id, never created again); every other server gets its
97
+ * own name and scope key. A failure on one server stops the loop there: the ones before it are recorded,
98
+ * and the next run repeats from the failed one.
99
+ */
100
+ export declare function ensureConnectorsOnServers(input: EnsureConnectorsOnServersInput): Promise<Array<{
101
+ serverUuid: string;
102
+ appId: string;
103
+ deployed: boolean;
104
+ }>>;
@@ -119,3 +119,40 @@ async function tunnelTokenDiffers(coolify, appId, token) {
119
119
  const live = (await coolify.listEnvs(appId)).find((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
120
120
  return !live || live.value !== token;
121
121
  }
122
+ /** The scope key of a non-primary server's connector: the tunnel's own key plus the server uuid. */
123
+ export function serverConnectorScopeKey(baseScopeKey, serverUuid) {
124
+ return `${baseScopeKey}.${serverUuid}`;
125
+ }
126
+ /** A non-primary server's connector app name: the tunnel's own name plus the first eight characters of the uuid. */
127
+ export function serverConnectorAppName(baseAppName, serverUuid) {
128
+ return `${baseAppName}-${serverUuid.slice(0, 8)}`;
129
+ }
130
+ /**
131
+ * One connector app for the tunnel on each server, all with the tunnel's one token. Each server's connector
132
+ * reaches that server's own containers, so a request lands on a server with the app on it. The primary's
133
+ * connector is the existing one (found by its recorded id, never created again); every other server gets its
134
+ * own name and scope key. A failure on one server stops the loop there: the ones before it are recorded,
135
+ * and the next run repeats from the failed one.
136
+ */
137
+ export async function ensureConnectorsOnServers(input) {
138
+ const results = [];
139
+ for (const serverUuid of input.serverUuids) {
140
+ const primary = serverUuid === input.primaryUuid;
141
+ const scopeKey = primary
142
+ ? input.baseScopeKey
143
+ : serverConnectorScopeKey(input.baseScopeKey, serverUuid);
144
+ const { appId, deployed } = await ensureCloudflaredConnector({
145
+ coolify: input.coolify,
146
+ tunnels: input.tunnels,
147
+ tunnelId: input.tunnelId,
148
+ projectUuid: input.projectUuid,
149
+ environment: input.environment,
150
+ appName: primary ? input.baseAppName : serverConnectorAppName(input.baseAppName, serverUuid),
151
+ resolveServer: async () => ({ uuid: serverUuid }),
152
+ recordedConnectorAppId: input.recorded[scopeKey] ?? null,
153
+ recordAppId: (appId) => input.record(scopeKey, appId),
154
+ });
155
+ results.push({ serverUuid, appId, deployed });
156
+ }
157
+ return results;
158
+ }
@@ -1,8 +1,10 @@
1
1
  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
+ import { type BlueGreenServer } from './blue-green.js';
4
5
  import type { LiveResource } from './live.js';
5
6
  import { type OrgPlatformReadiness } from './platform-org.js';
7
+ import { type RemoteDatabaseFacts } from './remote-database.js';
6
8
  /**
7
9
  * What a {@link StepExecutor} (see `plan-apply.ts`) needs to actually make a
8
10
  * step true, alongside the public {@link PlanStep} an operator reads.
@@ -41,6 +43,8 @@ export interface DraftStep {
41
43
  * New ones go here, not in new positional arguments (rule 7).
42
44
  */
43
45
  export interface PlanOrgOptions {
46
+ /** What the blue/green database route needs (`plan/remote-database.ts`). Absent: nothing is planned. */
47
+ remoteDatabase?: RemoteDatabaseFacts;
44
48
  /** The platform's edge has the `oikos.host` zone: a company without a domain waits on it for its own services. */
45
49
  oikosZoneReady?: boolean;
46
50
  /** #267: the executor was built with a `loginHostBindings` port. */
@@ -49,6 +53,8 @@ export interface PlanOrgOptions {
49
53
  loginHostServesDiscovery?: boolean;
50
54
  /** The executor's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`; without it `planIdTokenUserinfo` plans nothing. */
51
55
  zitadelIdTokenUserinfo?: boolean;
56
+ /** The executor's ZITADEL client has `ensureOidcAppMcpAccess` and `ensureAccessTokenLifetime`; without them `planMcp` is blocked. */
57
+ zitadelMcp?: boolean;
52
58
  /** F5: the executor was built with a `broker` port. */
53
59
  broker?: boolean;
54
60
  /** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
@@ -59,6 +65,14 @@ export interface PlanOrgOptions {
59
65
  archonUrl?: string | null;
60
66
  /** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
61
67
  edgeWorkers?: boolean;
68
+ /** ADR 0002: the executor was built with a `WorkerAppPort`, read by `planWorkerApps`. */
69
+ workerApps?: boolean;
70
+ /** ADR 0002: the keys service address, when the executor has a keys port. */
71
+ workerAppKeysUrl?: string;
72
+ /** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
73
+ workerAppDatabase?: boolean;
74
+ /** ADR 0002: the platform's Cloudflare account id; a container image must live in its registry. */
75
+ workerAppRegistryAccountId?: string;
62
76
  /** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
63
77
  ownerInviteMail?: boolean;
64
78
  /** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
@@ -69,6 +83,10 @@ export interface PlanOrgOptions {
69
83
  ownMailKeyHeld?: boolean;
70
84
  /** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
71
85
  ownMailKeyMintable?: boolean;
86
+ /** C3: the executor can mint a customers directory credential (`ZitadelPort.mintOrgDirectoryReader`). */
87
+ customersDirectory?: boolean;
88
+ /** C3: the executor can mint Boule's Archon credential (`FoundryStepExecutorPorts.mintBouleArchonCredential`). */
89
+ bouleArchonCredential?: boolean;
72
90
  /** The executor can store the sign-in database's CA and certificate (`FoundryStepExecutor.ownSigninDbKeysPuttable`). Absent: true. */
73
91
  ownSigninDbKeysPuttable?: boolean;
74
92
  /** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
@@ -108,6 +126,8 @@ export interface PlanOrgOptions {
108
126
  * but the storage is not registered, so each database is reported blocked.
109
127
  */
110
128
  backupStorageUuid?: string | null;
129
+ /** Every Coolify server the host lists, for blue/green (`plan/blue-green.ts`). Absent: the host cannot list them. */
130
+ blueGreenServers?: readonly BlueGreenServer[];
111
131
  }
112
132
  export interface PlanDetail {
113
133
  orgId: OrgId;
package/dist/plan/diff.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, heldEnvRef, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
3
+ import { entryHasDatabase } from '../worker-app-config.js';
3
4
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
- import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
+ import { planBlueGreen } from './blue-green.js';
6
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, desiredBouleEnv, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
5
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
8
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
7
9
  import { planCompanyBackups } from './company-backups.js';
@@ -15,6 +17,7 @@ import { lockedHostnames } from './locked-hostnames.js';
15
17
  import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
16
18
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
17
19
  import { mailStoreSwitchChange } from './mail-store.js';
20
+ import { planMcp } from './mcp.js';
18
21
  import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
19
22
  import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, OWN_SIGNIN_DB_MOVE_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, planSigninDbMove, } from './own-diff.js';
20
23
  import { ownDnsHostnames } from './own-dns.js';
@@ -23,9 +26,11 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
23
26
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
24
27
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
25
28
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
29
+ import { planRemoteDatabase } from './remote-database.js';
26
30
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
27
31
  import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
28
32
  import { withStepSystem } from './step-system.js';
33
+ import { brokerKeyDelivered, planWorkerApps } from './worker-app.js';
29
34
  import { planWorkers } from './worker.js';
30
35
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
31
36
  export function toPlan(detail) {
@@ -406,6 +411,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
406
411
  if (guarded)
407
412
  plannedSteps.push(guarded);
408
413
  }
414
+ // Auth ADR 0002: the MCP project and the JWT app, after the app they change
415
+ // and before the broker entry and the Worker that read the project id.
416
+ const mcp = planMcp(org, recordedIds, plannedSteps, options.zitadelMcp ?? false);
417
+ plannedSteps.push(...mcp.steps);
409
418
  // The Access gate before the database, app, tunnel and zone (#84): on a
410
419
  // first apply the hostname is gated before anything answers on it.
411
420
  // An Access gate that cannot be planned must not leave the hostname it
@@ -456,6 +465,18 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
456
465
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
457
466
  if (edgeReservedLabels.step)
458
467
  plannedSteps.push(edgeReservedLabels.step);
468
+ // Blue/green (`blue-green.ts`): the apps and balancers before the tunnel routes to them, a second
469
+ // server's connector after the tunnel exists.
470
+ const blueGreen = planBlueGreen({
471
+ org,
472
+ recordedIds,
473
+ servers: options.blueGreenServers,
474
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
475
+ appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
476
+ p.action.kind === 'app' &&
477
+ p.action.key === appStepKey(org, entryKey)),
478
+ });
479
+ plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'before').map(blueGreenDraft()));
459
480
  const tunnel = planTunnel(org, byKey, claimed, recordedIds, platformReadiness.platformApex);
460
481
  // #238: a tunnel that is being provisioned waits for an active zone; parking
461
482
  // one never does.
@@ -482,6 +503,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
482
503
  else if (tunnel) {
483
504
  plannedSteps.push(tunnel);
484
505
  }
506
+ plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'after').map(blueGreenDraft()));
485
507
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
486
508
  // apex domain, so the zone step above must already have run (or the zone
487
509
  // must already exist live) by the time this one's action executes.
@@ -519,7 +541,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
519
541
  const bouleGuarded = guardApp(bouleClient);
520
542
  if (bouleGuarded)
521
543
  plannedSteps.push(bouleGuarded);
522
- const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness);
544
+ const bouleCredentials = planBouleCredentials(org, recordedIds, {
545
+ archon: options.bouleArchonCredential === true,
546
+ directory: options.customersDirectory === true,
547
+ }, platformReadiness.canHoldSecrets === true, (options.now ?? Date.now)());
548
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, bouleCredentials.wants);
523
549
  if (boule.step)
524
550
  plannedSteps.push(boule.step);
525
551
  // After the apps and Boule -- an edge key needs its app recorded or planned,
@@ -549,6 +575,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
549
575
  (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
550
576
  if (jobsRegistration.step)
551
577
  plannedSteps.push(jobsRegistration.step);
578
+ const remoteDatabase = planRemoteDatabase(org, options.remoteDatabase, recordedIds);
579
+ if (remoteDatabase.step)
580
+ plannedSteps.push(remoteDatabase.step);
552
581
  // F8 (#318), after Agora and Boule, not right after the zone: a route
553
582
  // already bound by a hand-made entry, another entry's route, a taken
554
583
  // script name, or no Workers token all throw at apply, and `runSteps`
@@ -574,6 +603,60 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
574
603
  plannedSteps.push(step);
575
604
  }
576
605
  }
606
+ // ADR 0002: Worker apps, after the Workers for the same reason: a route, record
607
+ // or script name that is not ours throws at apply and `runSteps` stops there.
608
+ const workerApps = planWorkerApps(org, recordedIds, {
609
+ available: options.workerApps ?? false,
610
+ ...(options.workerAppKeysUrl !== undefined ? { keysUrl: options.workerAppKeysUrl } : {}),
611
+ ...(options.workerAppRegistryAccountId !== undefined
612
+ ? { registryAccountId: options.workerAppRegistryAccountId }
613
+ : {}),
614
+ canHoldSecrets: platformReadiness.canHoldSecrets === true,
615
+ issuer: authIssuer,
616
+ ...(options.workerAppDatabase
617
+ ? {
618
+ database: {
619
+ databaseReady: Boolean(recordedIds[`coolify:database:${org.id}`]) ||
620
+ plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'database'),
621
+ tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
622
+ plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
623
+ accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
624
+ },
625
+ }
626
+ : {}),
627
+ });
628
+ for (const step of workerApps.steps) {
629
+ const stepEntry = step.action.key.slice(org.id.length + 1);
630
+ const ownZone = org.hosting.usesDomain && Boolean(org.domain);
631
+ const apply = step.action.desired.mode === 'apply';
632
+ // Retire never looks a zone up; a deploy needs the org's zone, or the shared oikos.host zone.
633
+ const hasDatabase = Boolean(org.hosting.entries.find((e) => e.key === stepEntry)?.workerApp?.database);
634
+ const reason = !apply
635
+ ? null
636
+ : hasDatabase
637
+ ? edgeNeeded(platformReadiness, ['zones', 'dns', 'access', 'tunnels'])
638
+ : ownZone
639
+ ? edgeNeeded(platformReadiness, ['zones', 'dns'])
640
+ : edgeNeeded(platformReadiness, ['zones']);
641
+ if (reason) {
642
+ dnsBlocked.push(edgeBlockedItem(org, `${stepEntry} Worker app`, step.action.key, reason));
643
+ }
644
+ else if (apply && ownZone && zoneWaiting) {
645
+ zoneBlocked.push({
646
+ description: `${org.slug}'s ${stepEntry} Worker app is blocked, not planned: the zone for ${org.domain} is not active yet`,
647
+ key: step.action.key,
648
+ });
649
+ }
650
+ else if (apply && !ownZone && !(options.oikosZoneReady ?? false)) {
651
+ zoneBlocked.push({
652
+ description: `${org.slug}'s ${stepEntry} Worker app is blocked, not planned: the shared oikos.host zone is not ready yet`,
653
+ key: step.action.key,
654
+ });
655
+ }
656
+ else {
657
+ plannedSteps.push(step);
658
+ }
659
+ }
577
660
  // Last: a repair that fails must not stop any step after it (`runSteps` stops at the first failure).
578
661
  const idTokenUserinfo = planIdTokenUserinfo(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelIdTokenUserinfo ?? false);
579
662
  if (idTokenUserinfo)
@@ -608,6 +691,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
608
691
  unmanaged.push({ description: agora.issuerNote, key: 'coolify:agora' });
609
692
  if (boule.blocked)
610
693
  unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
694
+ for (const description of bouleCredentials.blocked) {
695
+ unmanaged.push({ description, key: 'coolify:boule:credentials' });
696
+ }
611
697
  if (boule.mailBlocked) {
612
698
  unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
613
699
  }
@@ -615,9 +701,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
615
701
  // orphan a second live key.
616
702
  if (aiKey.blocked)
617
703
  unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
704
+ unmanaged.push(...blueGreen.unmanaged);
618
705
  unmanaged.push(...filesKeys.blocked);
619
706
  unmanaged.push(...entryAiKeys.blocked);
620
707
  unmanaged.push(...edgeWorkers.blocked);
708
+ unmanaged.push(...workerApps.blocked);
709
+ unmanaged.push(...mcp.blocked);
621
710
  unmanaged.push(...entryEdgeKeys.blocked);
622
711
  unmanaged.push(...edgeReservedLabels.blocked);
623
712
  if (ownAuth.blocked)
@@ -644,6 +733,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
644
733
  if (jobsRegistration.blocked) {
645
734
  unmanaged.push({ description: jobsRegistration.blocked, key: 'jobs:registration' });
646
735
  }
736
+ if (remoteDatabase.blocked)
737
+ unmanaged.push(remoteDatabase.blocked);
647
738
  // #177: an org step whose own shared platform service is off or not yet
648
739
  // applied is blocked here, not planned -- the same rule, now driven by
649
740
  // `platformReadiness` instead of a host-supplied port. Each is reported at
@@ -740,6 +831,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
740
831
  orgPostgres,
741
832
  };
742
833
  }
834
+ /** A blue/green plan step as the draft step `computePlanDetail` collects. */
835
+ function blueGreenDraft() {
836
+ return (step) => ({
837
+ action: { system: 'coolify', kind: step.kind, key: step.key, desired: step.desired },
838
+ step: { description: step.description, destroys: false, migrates: false },
839
+ });
840
+ }
743
841
  /** Steps that run against the org's own ZITADEL instance (its projects, apps and broker entries). */
744
842
  const NEEDS_OWN_ZITADEL = new Set([
745
843
  'zitadel:issuer',
@@ -1274,7 +1372,7 @@ function orgServiceSchemaEnabled(org, schema) {
1274
1372
  if (schema.schema.startsWith('calls_'))
1275
1373
  return org.calls.enabled;
1276
1374
  if (schema.schema === 'app')
1277
- return org.hosting.entries.some((entry) => entry.database);
1375
+ return org.hosting.entries.some(entryHasDatabase);
1278
1376
  return true;
1279
1377
  }
1280
1378
  /**
@@ -2567,6 +2665,45 @@ function planBouleClient(org, byKey, claimed) {
2567
2665
  },
2568
2666
  };
2569
2667
  }
2668
+ /**
2669
+ * C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
2670
+ * Boule the host can mint it for; the customers directory credential only while `customers.enabled`. Each
2671
+ * needs a held-key store that can put. A credential minted and never held (platform rule 1) is never minted
2672
+ * again: it is reported blocked.
2673
+ */
2674
+ function planBouleCredentials(org, recordedIds, host, canHoldSecrets, nowMs) {
2675
+ const blocked = [];
2676
+ const at = `${org.slug}'s Boule`;
2677
+ let archonCredential = false;
2678
+ if (host.archon) {
2679
+ if (!canHoldSecrets) {
2680
+ blocked.push(`${at} Archon credential is blocked, not planned: it needs a held-key store that can put, and the host has none`);
2681
+ }
2682
+ else {
2683
+ archonCredential = true;
2684
+ }
2685
+ }
2686
+ let customersDirectory = false;
2687
+ if (org.customers?.enabled) {
2688
+ if (!host.directory) {
2689
+ blocked.push(`${at} customers directory credential is blocked, not planned: the host supplies no ZitadelPort.mintOrgDirectoryReader`);
2690
+ }
2691
+ else if (!canHoldSecrets) {
2692
+ blocked.push(`${at} customers directory credential is blocked, not planned: it needs a held-key store that can put, and the host has none`);
2693
+ }
2694
+ else {
2695
+ customersDirectory = true;
2696
+ }
2697
+ }
2698
+ return {
2699
+ wants: {
2700
+ archonCredential,
2701
+ customersDirectory,
2702
+ archonRenewDue: archonCredential && archonCredentialRenewDue(recordedIds, nowMs),
2703
+ },
2704
+ blocked,
2705
+ };
2706
+ }
2570
2707
  /**
2571
2708
  * #161 (Boule part): the org's own Boule, a per-org singleton keyed by the org
2572
2709
  * id under its own `boule` kind, a separate Coolify application from the org's
@@ -2580,7 +2717,7 @@ function planBouleClient(org, byKey, claimed) {
2580
2717
  * address (the one Agora signs in at); without it the Boule is blocked, not
2581
2718
  * planned, so every other step still applies.
2582
2719
  */
2583
- function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness) {
2720
+ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness, credentials) {
2584
2721
  const key = resourceKey('coolify', 'boule', org.id);
2585
2722
  const live = byKey.get(key);
2586
2723
  if (live)
@@ -2650,6 +2787,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2650
2787
  mailBlocked: mailBlocked !== null,
2651
2788
  // Only the platform's own org's Boule gets the peer password for Archon.
2652
2789
  platformOwner,
2790
+ archonCredential: credentials.archonCredential === true,
2791
+ customersDirectory: credentials.customersDirectory === true,
2653
2792
  },
2654
2793
  };
2655
2794
  if (!live) {
@@ -2663,8 +2802,22 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2663
2802
  };
2664
2803
  }
2665
2804
  const liveEnv = (live.config.env ?? {});
2666
- const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn);
2805
+ const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn, credentials);
2667
2806
  const drift = bouleEnvDrift(env, liveEnv);
2807
+ // Secrets are compared by presence only. A switched-off customers org takes its env away.
2808
+ if (credentials.archonCredential &&
2809
+ (!liveEnv[BOULE_ARCHON_CREDENTIAL_ENV] || credentials.archonRenewDue)) {
2810
+ drift.push(BOULE_ARCHON_CREDENTIAL_ENV);
2811
+ }
2812
+ if (credentials.customersDirectory &&
2813
+ customersOrgIdOf(org, recordedIds) &&
2814
+ !liveEnv[BOULE_CUSTOMERS_CREDENTIAL_ENV]) {
2815
+ drift.push(BOULE_CUSTOMERS_CREDENTIAL_ENV);
2816
+ }
2817
+ for (const key of staleCustomersEnv(org, liveEnv)) {
2818
+ if (!drift.includes(key))
2819
+ drift.push(key);
2820
+ }
2668
2821
  // The token is compared, never planned with its value.
2669
2822
  if (mailOn &&
2670
2823
  bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
@@ -3155,6 +3308,9 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
3155
3308
  if (!resource || (typeof status === 'string' && /^(exited|stopped)/.test(status)))
3156
3309
  continue;
3157
3310
  }
3311
+ // A Worker app has no Coolify app: `planWorkerApps` plans it.
3312
+ if (org.hosting.entries.some((e) => e.key === key && e.workerApp))
3313
+ continue;
3158
3314
  const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
3159
3315
  // The executor pushes the issuer the plan read, the way Agora gets its host values.
3160
3316
  // The same availability the plan's marker used: the executor records its
@@ -3294,7 +3450,7 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
3294
3450
  * {@link planApp}.
3295
3451
  */
3296
3452
  function planDatabase(org, byKey, claimed) {
3297
- return planHostingToggle('coolify', 'database', org.hosting.entries.some((entry) => entry.database), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
3453
+ return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
3298
3454
  }
3299
3455
  function sortedNames(names) {
3300
3456
  return names.filter(Boolean).sort().join(',');
@@ -3336,7 +3492,7 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3336
3492
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
3337
3493
  Boolean(org.domain) ||
3338
3494
  // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
3339
- entryRoutes(org).length > 0;
3495
+ entryRoutes(org, recordedIds).length > 0;
3340
3496
  const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
3341
3497
  // Cloudflare's status, read against what is declared: a park cannot change
3342
3498
  // it, so judging on/off by status alone would never let the plan empty.
@@ -3393,7 +3549,8 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
3393
3549
  // An entry's hostname goes straight to its container, so a changed port, key or name, or an ingress
3394
3550
  // written before entries were routed that way, needs the ingress written again. Nothing recorded
3395
3551
  // equals no routes.
3396
- const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !== entryRoutesMarker(org);
3552
+ const routesStale = (recordedIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY] ?? '[]') !==
3553
+ entryRoutesMarker(org, recordedIds);
3397
3554
  if (!connectorMissing && !dnsStale && !routesStale)
3398
3555
  return null;
3399
3556
  return {
@@ -3606,6 +3763,15 @@ function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
3606
3763
  });
3607
3764
  continue;
3608
3765
  }
3766
+ // A Worker app has no Coolify app to hold the broker key: its generated AUTH_APP_KEY
3767
+ // is delivered to the Worker first and read back from the held-key store.
3768
+ if (entry.workerApp && !brokerKeyDelivered(recordedIds, entry.key)) {
3769
+ blocked.push({
3770
+ description: `${at} is blocked, not planned: the Worker app's AUTH_APP_KEY is not delivered yet`,
3771
+ key,
3772
+ });
3773
+ continue;
3774
+ }
3609
3775
  const wanted = wantedBrokerEntry(org, entry, recordedIds);
3610
3776
  if ('want' in wanted) {
3611
3777
  if (recordedIds[brokerEntryAppliedScopeKey(entry.key)] === JSON.stringify(wanted.want)) {
@@ -3714,23 +3880,44 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
3714
3880
  * listed as unmanaged.
3715
3881
  */
3716
3882
  function planCustomers(org, live, claimed, recordedIds) {
3717
- if (!org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers'))
3718
- return null;
3883
+ const signUp = org.customers?.signUp === true;
3884
+ const wanted = org.customers?.enabled === true ||
3885
+ org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers');
3719
3886
  const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
3720
- if (customersOrgId) {
3721
- for (const r of live) {
3722
- if (r.system === 'zitadel' &&
3723
- r.kind === 'project_grant' &&
3724
- r.config.grantedOrgId === customersOrgId) {
3725
- claimed.add(resourceKey(r.system, r.kind, r.key));
3726
- }
3887
+ if (!customersOrgId) {
3888
+ if (!wanted)
3889
+ return null;
3890
+ return {
3891
+ action: { system: 'zitadel', kind: 'customers', key: org.id, desired: { signUp } },
3892
+ step: {
3893
+ description: `provision a ZITADEL org for ${org.slug}'s customers, with self-registration ${signUp ? 'on' : 'off'}`,
3894
+ destroys: false,
3895
+ migrates: false,
3896
+ },
3897
+ };
3898
+ }
3899
+ for (const r of live) {
3900
+ if (r.system === 'zitadel' &&
3901
+ r.kind === 'project_grant' &&
3902
+ r.config.grantedOrgId === customersOrgId) {
3903
+ claimed.add(resourceKey(r.system, r.kind, r.key));
3727
3904
  }
3728
- return null;
3729
3905
  }
3906
+ // Before the sign-up switch existed foundry forced self-registration on, so a customers org with no
3907
+ // marker is on. Switching customers off closes it: the declaration says no one signs themselves up.
3908
+ const applied = recordedIds[CUSTOMERS_SIGN_UP_SCOPE_KEY] || '1';
3909
+ const wantedSignUp = wanted && signUp ? '1' : '0';
3910
+ if (applied === wantedSignUp)
3911
+ return null;
3730
3912
  return {
3731
- action: { system: 'zitadel', kind: 'customers', key: org.id, desired: {} },
3913
+ action: {
3914
+ system: 'zitadel',
3915
+ kind: 'customers',
3916
+ key: org.id,
3917
+ desired: { signUp: wantedSignUp === '1' },
3918
+ },
3732
3919
  step: {
3733
- description: `provision a self-registration ZITADEL org for ${org.slug}'s customers`,
3920
+ description: `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
3734
3921
  destroys: false,
3735
3922
  migrates: false,
3736
3923
  },