@wtfalch/foundry 0.43.0 → 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 (38) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js +1 -1
  3. package/dist/migrations/0057_org_customers.sql +29 -0
  4. package/dist/org.d.ts +42 -0
  5. package/dist/org.js +52 -26
  6. package/dist/plan/boule.d.ts +43 -2
  7. package/dist/plan/boule.js +48 -1
  8. package/dist/plan/broker-entries.d.ts +28 -0
  9. package/dist/plan/broker-entries.js +46 -1
  10. package/dist/plan/diff.d.ts +14 -0
  11. package/dist/plan/diff.js +176 -18
  12. package/dist/plan/executor.d.ts +100 -2
  13. package/dist/plan/executor.js +273 -16
  14. package/dist/plan/hostnames.d.ts +7 -0
  15. package/dist/plan/hostnames.js +17 -2
  16. package/dist/plan/locked-hostnames.d.ts +11 -0
  17. package/dist/plan/locked-hostnames.js +13 -0
  18. package/dist/plan/mcp.d.ts +57 -0
  19. package/dist/plan/mcp.js +131 -0
  20. package/dist/plan/org-names.d.ts +2 -2
  21. package/dist/plan/org-names.js +16 -1
  22. package/dist/plan/plan-apply.d.ts +26 -0
  23. package/dist/plan/plan-apply.js +45 -5
  24. package/dist/plan/proxy-labels.js +2 -0
  25. package/dist/plan/worker-app-database.d.ts +148 -0
  26. package/dist/plan/worker-app-database.js +424 -0
  27. package/dist/plan/worker-app.d.ts +320 -0
  28. package/dist/plan/worker-app.js +792 -0
  29. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  30. package/dist/plan/zitadel-machine-user.js +80 -0
  31. package/dist/requested-changes.d.ts +12 -3
  32. package/dist/requested-changes.js +22 -0
  33. package/dist/store/postgres.js +12 -6
  34. package/dist/store/schema.d.ts +34 -0
  35. package/dist/store/schema.js +3 -0
  36. package/dist/worker-app-config.d.ts +151 -0
  37. package/dist/worker-app-config.js +438 -0
  38. package/package.json +1 -1
@@ -9,8 +9,8 @@ import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
9
9
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
10
10
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
11
  import { bgKey, colorAppName, entrySubject, healthTokenName, liveAppNameFor, splitServers, } from './blue-green.js';
12
- import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
13
- import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
12
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
13
+ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
14
14
  import { recordBuiltCommit } from './build-record.js';
15
15
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
16
16
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -36,6 +36,7 @@ import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames
36
36
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
37
37
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
38
38
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
39
+ import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
39
40
  import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
40
41
  import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
41
42
  import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
@@ -52,6 +53,7 @@ import { editSignInBindings } from './sign-in-bindings.js';
52
53
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
53
54
  import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
54
55
  import { moveSigninDatabase } from './signin-db-move.js';
56
+ import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
55
57
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
56
58
  /**
57
59
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -102,6 +104,11 @@ export function adaptZitadelListApps(client) {
102
104
  complete: true,
103
105
  });
104
106
  }
107
+ /** An outside call for Boule's credentials that has not answered in this long fails the step; the next apply repairs it. */
108
+ const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
109
+ const BOULE_CREDENTIAL_ATTEMPTS = 3;
110
+ /** The ZITADEL machine user that reads one company's customers organisation. */
111
+ const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
105
112
  // ---------------------------------------------------------------------------
106
113
  // Errors
107
114
  // ---------------------------------------------------------------------------
@@ -978,6 +985,15 @@ export class FoundryStepExecutor {
978
985
  ownMailKeyMintable() {
979
986
  return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
980
987
  }
988
+ /** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
989
+ customersDirectoryAvailable() {
990
+ return (typeof this.ports.zitadel.mintOrgDirectoryReader === 'function' &&
991
+ typeof this.ports.zitadel.revokeOrgDirectoryReader === 'function');
992
+ }
993
+ /** C3: whether the host supplied `mintBouleArchonCredential`. */
994
+ bouleArchonCredentialAvailable() {
995
+ return typeof this.ports.mintBouleArchonCredential === 'function';
996
+ }
981
997
  /** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
982
998
  ownSigninDbKeysPuttable() {
983
999
  return Boolean(this.ports.ownServices?.provision.auth.heldKeys?.put);
@@ -1112,7 +1128,13 @@ export class FoundryStepExecutor {
1112
1128
  case 'zitadel:id-token-userinfo':
1113
1129
  return this.zitadelIdTokenUserinfo(action, org, context);
1114
1130
  case 'zitadel:customers':
1115
- return this.zitadelCustomers(org, context);
1131
+ return this.zitadelCustomers(action, org, context);
1132
+ case 'zitadel:mcp-project':
1133
+ return this.zitadelMcpProject(action, org, context);
1134
+ case 'zitadel:mcp-access':
1135
+ return this.zitadelMcpAccess(action, org, context);
1136
+ case 'zitadel:access-token-lifetime':
1137
+ return this.zitadelAccessTokenLifetime(context);
1116
1138
  case 'coolify:app':
1117
1139
  return this.coolifyApp(action, org, context);
1118
1140
  case 'coolify:network':
@@ -1145,6 +1167,13 @@ export class FoundryStepExecutor {
1145
1167
  return this.cloudflareRateLimit(org, context);
1146
1168
  case 'cloudflare:worker':
1147
1169
  return this.cloudflareWorker(action, org, context);
1170
+ case 'cloudflare:worker-app': {
1171
+ const workers = this.ports.workerApps;
1172
+ if (!workers) {
1173
+ throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
1174
+ }
1175
+ return applyWorkerApp(this.workerAppDeps(workers), org, action, context);
1176
+ }
1148
1177
  case 'files:organisation':
1149
1178
  return this.filesOrganisation(action, org, context);
1150
1179
  case 'files:key':
@@ -1429,6 +1458,35 @@ export class FoundryStepExecutor {
1429
1458
  * "Declared changes apply" nothing is marked done until the
1430
1459
  * live state actually matches it.
1431
1460
  */
1461
+ workerAppDeps(workers) {
1462
+ return {
1463
+ workers,
1464
+ ...(this.ports.workerAppKeys ? { keys: this.ports.workerAppKeys } : {}),
1465
+ ...(this.ports.workerAppDatabase
1466
+ ? {
1467
+ database: {
1468
+ port: this.ports.workerAppDatabase,
1469
+ tunnels: this.tunnelPort(),
1470
+ access: this.accessPort(),
1471
+ dns: this.dnsPort(),
1472
+ zones: this.zonePort(),
1473
+ ownerUrl: async (databaseId) => {
1474
+ const db = await getDatabaseStarted(this.ports.coolify, databaseId);
1475
+ if (!db.internal_db_url) {
1476
+ throw new Error('the database reports no internal connection URL yet');
1477
+ }
1478
+ this.handledSecrets.add(db.internal_db_url);
1479
+ return db.internal_db_url;
1480
+ },
1481
+ secret: (value) => void this.handledSecrets.add(value),
1482
+ },
1483
+ }
1484
+ : {}),
1485
+ zones: this.zonePort(),
1486
+ dns: this.dnsPort(),
1487
+ ...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
1488
+ };
1489
+ }
1432
1490
  async cloudflareWorker(action, org, context) {
1433
1491
  const entryKey = action.key.slice(org.id.length + 1);
1434
1492
  const at = `cloudflare:worker for ${org.slug} entry '${entryKey}'`;
@@ -1766,6 +1824,18 @@ export class FoundryStepExecutor {
1766
1824
  }
1767
1825
  return { resourceId: userId };
1768
1826
  }
1827
+ /** `plan/mcp.ts`: the entry's MCP audience project. */
1828
+ zitadelMcpProject(action, org, context) {
1829
+ return applyMcpProject(this.ports.zitadel, org, action, context);
1830
+ }
1831
+ /** `plan/mcp.ts`: JWT access tokens and the broker's callback on the entry's app. */
1832
+ zitadelMcpAccess(action, org, context) {
1833
+ return applyMcpAccess(this.ports.zitadel, org, action, context);
1834
+ }
1835
+ /** `plan/mcp.ts`: the instance access-token lifetime. */
1836
+ zitadelAccessTokenLifetime(context) {
1837
+ return applyAccessTokenLifetime(this.ports.zitadel, context);
1838
+ }
1769
1839
  /**
1770
1840
  * Switches on "user info inside the ID token" for each live sign-in app
1771
1841
  * the planner named. An app's marker (its ZITADEL app id) is recorded and
@@ -2145,20 +2215,27 @@ export class FoundryStepExecutor {
2145
2215
  /**
2146
2216
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
2147
2217
  * in through a second ZITADEL org, `<slug> customers`, with
2148
- * self-registration on and a grant to the org's app project. Staff stay in
2218
+ * self-registration as `customers.signUp` says (off by default) and a grant to the org's app project. Staff stay in
2149
2219
  * the org's own invite-only ZITADEL org. The space keeps the name apart from
2150
2220
  * every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
2151
2221
  * a re-run after a crash finishes the job.
2152
2222
  */
2153
- async zitadelCustomers(org, context) {
2223
+ async zitadelCustomers(action, org, context) {
2154
2224
  const zitadelOrgId = context.recordedIds().zitadel_org;
2155
2225
  if (!zitadelOrgId) {
2156
2226
  throw new Error(`zitadel:customers for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
2157
2227
  }
2158
2228
  const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
2159
2229
  const { org: customers } = await this.ports.zitadel.findOrCreateOrg(`${org.slug} customers`);
2160
- await this.ports.zitadel.allowSelfRegistration(customers.id);
2230
+ // Self-registration follows `customers.signUp` (off by default). The marker is written last, so a
2231
+ // run that failed before here is planned again.
2232
+ const signUp = action.desired.signUp === true;
2233
+ if (signUp)
2234
+ await this.ports.zitadel.allowSelfRegistration(customers.id);
2235
+ else
2236
+ await this.ports.zitadel.closeSelfRegistration(customers.id);
2161
2237
  await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
2238
+ context.recordScopeId(CUSTOMERS_SIGN_UP_SCOPE_KEY, signUp ? '1' : '0');
2162
2239
  return { resourceId: customers.id };
2163
2240
  }
2164
2241
  async zitadelApp(action, org, context) {
@@ -2316,13 +2393,17 @@ export class FoundryStepExecutor {
2316
2393
  redirectUris: appType === 'native'
2317
2394
  ? []
2318
2395
  : hosts
2319
- ? hosts.map((host) => `https://${host}/auth/callback`)
2396
+ ? [
2397
+ ...hosts.map((host) => `https://${host}/auth/callback`),
2398
+ ...(authEntry?.uses?.auth?.mcp ? [mcpCallbackUri(org)] : []),
2399
+ ]
2320
2400
  : [redirectUri],
2321
2401
  postLogoutRedirectUris: appType === 'native'
2322
2402
  ? []
2323
2403
  : hosts
2324
2404
  ? hosts.map((host) => `https://${host}/`)
2325
2405
  : [`https://${hostname}/`],
2406
+ ...(authEntry?.uses?.auth?.mcp ? { accessTokenType: 'jwt' } : {}),
2326
2407
  });
2327
2408
  if (appType === 'web') {
2328
2409
  // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
@@ -3389,11 +3470,21 @@ export class FoundryStepExecutor {
3389
3470
  if ('missing' in wanted) {
3390
3471
  throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
3391
3472
  }
3473
+ if (entry.workerApp) {
3474
+ // No Coolify app: the key is the held AUTH_APP_KEY the Worker app was given.
3475
+ const heldKey = await heldBrokerKey(this.ports.heldKeys, org, entry.key);
3476
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, heldKey, context);
3477
+ return {};
3478
+ }
3392
3479
  const appId = recorded[`coolify:app:${action.key}`];
3393
3480
  if (!appId) {
3394
3481
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
3395
3482
  }
3396
- await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appId, context);
3483
+ const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
3484
+ if (!appKey || appKey.length < 32) {
3485
+ throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
3486
+ }
3487
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context);
3397
3488
  return {};
3398
3489
  }
3399
3490
  /**
@@ -3415,15 +3506,15 @@ export class FoundryStepExecutor {
3415
3506
  if (!appId) {
3416
3507
  throw new Error(`coolify:boule-broker-entry for ${org.slug}: the Boule app is not recorded yet`);
3417
3508
  }
3418
- await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appId, context);
3419
- return {};
3420
- }
3421
- /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
3422
- async writeAppBrokerEntry(org, broker, entryKey, want, appId, context) {
3423
3509
  const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
3424
3510
  if (!appKey || appKey.length < 32) {
3425
3511
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
3426
3512
  }
3513
+ await this.writeAppBrokerEntry(org, broker, BOULE_BROKER_ENTRY_KEY, wanted.want, appKey, context);
3514
+ return {};
3515
+ }
3516
+ /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
3517
+ async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context) {
3427
3518
  const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
3428
3519
  const result = await writeBrokerEntry(broker, {
3429
3520
  want,
@@ -5713,7 +5804,12 @@ export class FoundryStepExecutor {
5713
5804
  if (mailWanted && !('platformApex' in action.desired)) {
5714
5805
  throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
5715
5806
  }
5716
- const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted);
5807
+ // What the plan decided Boule is given beyond its sign-in env (C3).
5808
+ const credentialWants = {
5809
+ archonCredential: action.desired.archonCredential === true,
5810
+ customersDirectory: action.desired.customersDirectory === true,
5811
+ };
5812
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted, credentialWants);
5717
5813
  if (missing.length > 0) {
5718
5814
  throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
5719
5815
  }
@@ -5750,8 +5846,11 @@ export class FoundryStepExecutor {
5750
5846
  });
5751
5847
  const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
5752
5848
  const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
5753
- const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5849
+ let envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
5754
5850
  await this.dropStaleSignInUpstream(app.appId, env, liveRows);
5851
+ // Customers switched off: revoke the directory credential and take the env away (C3).
5852
+ if (await this.dropCustomersCredential(app.appId, org, context, liveRows))
5853
+ envChanged = true;
5755
5854
  const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
5756
5855
  const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
5757
5856
  if (cookieSecret)
@@ -5775,11 +5874,39 @@ export class FoundryStepExecutor {
5775
5874
  this.handledSecrets.add(bridgeSecret);
5776
5875
  envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
5777
5876
  }
5877
+ // Held, minted once (C3). Compared with the live value so a changed one restarts the app.
5878
+ const credentialEnvs = [];
5879
+ let archonPushedAt = null;
5880
+ if (credentialWants.archonCredential) {
5881
+ const archon = await this.ensureBouleArchonCredential(org, context);
5882
+ archonPushedAt = archon.pushedAt;
5883
+ credentialEnvs.push({ key: BOULE_ARCHON_CREDENTIAL_ENV, value: archon.value });
5884
+ }
5885
+ if (credentialWants.customersDirectory) {
5886
+ const directory = await this.ensureCustomersDirectoryCredential(org, context);
5887
+ if (directory)
5888
+ credentialEnvs.push({ key: BOULE_CUSTOMERS_CREDENTIAL_ENV, value: directory });
5889
+ }
5890
+ for (const credential of credentialEnvs) {
5891
+ this.handledSecrets.add(credential.value);
5892
+ envs.push(credential);
5893
+ if (liveEnv.get(credential.key) !== credential.value)
5894
+ envChanged = true;
5895
+ }
5778
5896
  try {
5779
5897
  await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
5898
+ // Only a push that reached Boule's env counts: a failed one is minted and pushed again next apply.
5899
+ if (archonPushedAt) {
5900
+ context.recordScopeId(BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, archonPushedAt);
5901
+ await context.persistScopeIds();
5902
+ }
5780
5903
  }
5781
5904
  catch (error) {
5782
- const minted = [peerPassword, bridgeSecret].filter((v) => v !== null);
5905
+ const minted = [
5906
+ peerPassword,
5907
+ bridgeSecret,
5908
+ ...credentialEnvs.map((credential) => credential.value),
5909
+ ].filter((v) => v !== null);
5783
5910
  if (minted.length === 0)
5784
5911
  throw error;
5785
5912
  throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
@@ -5829,6 +5956,125 @@ export class FoundryStepExecutor {
5829
5956
  this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
5830
5957
  return { resourceId: app.appId };
5831
5958
  }
5959
+ /**
5960
+ * C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
5961
+ * when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
5962
+ * written again unchanged. The secret is held first; the caller records `pushedAt` only after the env write
5963
+ * succeeded, so a failed hold or a failed push is mended by the next apply minting again. Errors name no secret.
5964
+ */
5965
+ async ensureBouleArchonCredential(org, context) {
5966
+ const at = `coolify:boule for ${org.slug}`;
5967
+ const mint = this.ports.mintBouleArchonCredential;
5968
+ const heldKeys = this.ports.heldKeys;
5969
+ if (!mint)
5970
+ throw new Error(`${at}: this executor was built without mintBouleArchonCredential`);
5971
+ if (!heldKeys?.put) {
5972
+ throw new Error(`${at}: the Archon credential needs a held-key store that can put`);
5973
+ }
5974
+ const put = heldKeys.put;
5975
+ const ref = bouleArchonCredentialRef(org.id);
5976
+ const held = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: read the held Archon credential`, () => heldKeys.resolve(ref));
5977
+ const nowMs = context.now?.() ?? Date.now();
5978
+ if (held && !archonCredentialRenewDue(context.recordedIds(), nowMs)) {
5979
+ return { value: held, pushedAt: null };
5980
+ }
5981
+ const secret = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: mint the Archon credential`, () => mint(org.id));
5982
+ this.handledSecrets.add(secret);
5983
+ if (!(await this.holdWithRetry(ref, secret, put, `${at}: hold the Archon credential`))) {
5984
+ throw new Error(`${at}: the Archon credential could not be held after ${BOULE_CREDENTIAL_ATTEMPTS} attempts; the next apply mints it again`);
5985
+ }
5986
+ return { value: secret, pushedAt: new Date(nowMs).toISOString() };
5987
+ }
5988
+ /**
5989
+ * C3: the read-only credential for the company's customers organisation. Held first. Minting revokes every
5990
+ * earlier token of the machine user, so a hold that failed is repaired by the next apply minting again and
5991
+ * leaves one live token. `null` while the customers organisation is not recorded yet.
5992
+ */
5993
+ async ensureCustomersDirectoryCredential(org, context) {
5994
+ const at = `coolify:boule for ${org.slug}`;
5995
+ const customersOrgId = customersOrgIdOf(org, context.recordedIds());
5996
+ if (!customersOrgId)
5997
+ return null;
5998
+ const zitadel = this.ports.zitadel;
5999
+ const heldKeys = this.ports.heldKeys;
6000
+ if (!zitadel.mintOrgDirectoryReader) {
6001
+ throw new Error(`${at}: this executor's ZITADEL port cannot mint a customers directory credential`);
6002
+ }
6003
+ if (!heldKeys?.put) {
6004
+ throw new Error(`${at}: the customers directory credential needs a held-key store that can put`);
6005
+ }
6006
+ const put = heldKeys.put;
6007
+ const ref = customersDirectoryRef(org.id);
6008
+ const held = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: read the held directory credential`, () => heldKeys.resolve(ref));
6009
+ if (held)
6010
+ return held;
6011
+ const minted = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: mint the directory credential`, () => zitadel.mintOrgDirectoryReader(customersOrgId, {
6012
+ userName: CUSTOMERS_DIRECTORY_USER_NAME,
6013
+ name: 'Foundry customers directory (read-only)',
6014
+ }));
6015
+ this.handledSecrets.add(minted.token);
6016
+ context.recordScopeId(CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, minted.userId);
6017
+ await context.persistScopeIds();
6018
+ if (!(await this.holdWithRetry(ref, minted.token, put, `${at}: hold the directory credential`))) {
6019
+ throw new Error(`${at}: the directory credential of ZITADEL user ${minted.userId} could not be held after ${BOULE_CREDENTIAL_ATTEMPTS} attempts; the next apply mints it again and revokes this one`);
6020
+ }
6021
+ return minted.token;
6022
+ }
6023
+ /** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
6024
+ async holdWithRetry(ref, value, put, label) {
6025
+ for (let attempt = 1; attempt <= BOULE_CREDENTIAL_ATTEMPTS; attempt++) {
6026
+ try {
6027
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, label, () => put(ref, value));
6028
+ return true;
6029
+ }
6030
+ catch {
6031
+ // Retried; the cause may echo the value.
6032
+ }
6033
+ }
6034
+ return false;
6035
+ }
6036
+ /**
6037
+ * C3: customers switched off. Revokes the directory credential's tokens first, then removes the customers
6038
+ * env from Boule, then forgets the held value and the user id. Each step is repeated safely, so a run that
6039
+ * died halfway is finished by the next. Returns true when Boule's env changed.
6040
+ */
6041
+ async dropCustomersCredential(appId, org, context, liveRows) {
6042
+ if (org.customers?.enabled)
6043
+ return false;
6044
+ const at = `coolify:boule for ${org.slug}`;
6045
+ const recorded = context.recordedIds();
6046
+ const userId = recorded[CUSTOMERS_DIRECTORY_USER_SCOPE_KEY];
6047
+ const customersOrgId = customersOrgIdOf(org, recorded);
6048
+ const zitadel = this.ports.zitadel;
6049
+ if (userId && customersOrgId && zitadel.revokeOrgDirectoryReader) {
6050
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: revoke the directory credential`, () => zitadel.revokeOrgDirectoryReader(customersOrgId, userId));
6051
+ }
6052
+ let changed = false;
6053
+ for (const row of liveRows) {
6054
+ if (row.key !== BOULE_CUSTOMERS_ORG_ENV && row.key !== BOULE_CUSTOMERS_CREDENTIAL_ENV)
6055
+ continue;
6056
+ if (!row.uuid)
6057
+ continue;
6058
+ try {
6059
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
6060
+ changed = true;
6061
+ }
6062
+ catch (error) {
6063
+ if (!isNotFoundError(error))
6064
+ throw error;
6065
+ }
6066
+ }
6067
+ if (userId && (!customersOrgId || zitadel.revokeOrgDirectoryReader)) {
6068
+ const heldKeys = this.ports.heldKeys;
6069
+ if (heldKeys?.put) {
6070
+ const put = heldKeys.put;
6071
+ const ref = customersDirectoryRef(org.id);
6072
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: forget the directory credential`, () => put(ref, ''));
6073
+ }
6074
+ context.recordScopeId(CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, '');
6075
+ }
6076
+ return changed;
6077
+ }
5832
6078
  /**
5833
6079
  * Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
5834
6080
  * the platform's own apex, else the org's `<slug>-app` token through the
@@ -8302,6 +8548,17 @@ export class FoundryStepExecutor {
8302
8548
  await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
8303
8549
  return;
8304
8550
  }
8551
+ case 'cloudflare:worker-app': {
8552
+ // ADR 0002: route, record, containers and script of a Worker app.
8553
+ const workers = this.ports.workerApps;
8554
+ if (!workers) {
8555
+ throw new Error(`cloudflare:worker-app '${target.key}' for ${org.slug}: this executor was built without a WorkerAppPort; cannot delete it`);
8556
+ }
8557
+ const entryKey = target.key.slice(org.id.length + 1);
8558
+ const at = `cloudflare:worker-app '${target.key}' for ${org.slug}`;
8559
+ await teardownWorkerApp(this.workerAppDeps(workers), entryKey, target.id || undefined, recordedIds, (label, task) => withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: ${label}`, task), org.id);
8560
+ return;
8561
+ }
8305
8562
  case 'edge:key': {
8306
8563
  // F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
8307
8564
  // No port means no way to revoke it -- thrown, not silently
@@ -6,6 +6,13 @@ import type { DeclaredOrg, HostingEntry } from '../org.js';
6
6
  * acted on elsewhere.
7
7
  */
8
8
  export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
9
+ /**
10
+ * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
11
+ * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
12
+ * under `oikos.host`, so the free Universal certificate covers it), and
13
+ * `<slug>.oikos.host` for the entry keyed `app`.
14
+ */
15
+ export declare function workerAppHostname(org: DeclaredOrg, entry: HostingEntry): string;
9
16
  /**
10
17
  * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
11
18
  * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
@@ -1,4 +1,5 @@
1
1
  import { APP_ENTRY_KEY } from '../org.js';
2
+ import { workerAppHostnameOf } from '../worker-app-config.js';
2
3
  import { agoraHostnameFor } from './agora.js';
3
4
  import { bouleHostnameFor } from './boule.js';
4
5
  /**
@@ -9,11 +10,22 @@ import { bouleHostnameFor } from './boule.js';
9
10
  */
10
11
  export function entryHostnames(org, entry) {
11
12
  const { domain } = org;
13
+ if (entry.workerApp)
14
+ return [workerAppHostname(org, entry)];
12
15
  if (!org.hosting.usesDomain || !domain) {
13
16
  return entry.key === APP_ENTRY_KEY ? [`${org.slug}.oikos.host`] : [];
14
17
  }
15
18
  return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
16
19
  }
20
+ /**
21
+ * The one exact name a Worker app is served at (ADR 0002): `<host>.<domain>` or
22
+ * the apex on the org's own domain, else `<slug>-<key>.oikos.host` (one label
23
+ * under `oikos.host`, so the free Universal certificate covers it), and
24
+ * `<slug>.oikos.host` for the entry keyed `app`.
25
+ */
26
+ export function workerAppHostname(org, entry) {
27
+ return workerAppHostnameOf({ slug: org.slug, domain: org.domain, usesDomain: org.hosting.usesDomain }, entry.key, entry.workerApp?.host ?? '@');
28
+ }
17
29
  /**
18
30
  * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
19
31
  * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
@@ -98,8 +110,11 @@ export function filesShareHostname(org) {
98
110
  export function tunnelIngressHostnames(org, loginHost = null) {
99
111
  const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
100
112
  if (org.hosting.enabled) {
101
- for (const entry of org.hosting.entries)
102
- hostnames.push(...entryHostnames(org, entry));
113
+ // A Worker app is reached through its own Worker route, never the tunnel.
114
+ for (const entry of org.hosting.entries) {
115
+ if (!entry.workerApp)
116
+ hostnames.push(...entryHostnames(org, entry));
117
+ }
103
118
  }
104
119
  else if (hostnames.length === 0) {
105
120
  hostnames.push(legacyAppHostname(org));
@@ -10,3 +10,14 @@ export declare const OWN_SERVICE_LABELS: readonly ["auth", "mail", "files", "sfu
10
10
  * oikos.host service names; any other org with no domain holds none.
11
11
  */
12
12
  export declare function lockedHostnames(org: DeclaredOrg, platformHostnames: readonly string[]): Set<string>;
13
+ /**
14
+ * What `validateHosting` knows of the org's names: its domain, the names
15
+ * foundry owns, its slug (for the domain-less `<slug>-<key>.oikos.host`) and
16
+ * whether its site is served at the apex.
17
+ */
18
+ export declare function hostingNames(org: DeclaredOrg, platformHostnames: readonly string[]): {
19
+ domain: string | null;
20
+ locked: Set<string>;
21
+ slug: string;
22
+ siteOwnsApex: boolean;
23
+ };
@@ -47,3 +47,16 @@ export function lockedHostnames(org, platformHostnames) {
47
47
  }
48
48
  return locked;
49
49
  }
50
+ /**
51
+ * What `validateHosting` knows of the org's names: its domain, the names
52
+ * foundry owns, its slug (for the domain-less `<slug>-<key>.oikos.host`) and
53
+ * whether its site is served at the apex.
54
+ */
55
+ export function hostingNames(org, platformHostnames) {
56
+ return {
57
+ domain: org.domain,
58
+ locked: lockedHostnames(org, platformHostnames),
59
+ slug: org.slug,
60
+ siteOwnsApex: org.site.enabled && org.site.usesDomain && org.domain !== null,
61
+ };
62
+ }
@@ -0,0 +1,57 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { DraftStep, StepAction } from './diff.js';
3
+ import type { ZitadelPort } from './executor.js';
4
+ import type { StepExecutionContext } from './plan-apply.js';
5
+ /**
6
+ * Auth ADR 0002, "What foundry declares and applies": the ZITADEL side of
7
+ * `uses.auth.mcp`. Three steps, all absent for an org that declares no `mcp`:
8
+ *
9
+ * - `zitadel:mcp-project` (per entry): an empty project in the customers
10
+ * organisation. It holds no app; it exists so its id can be the token's
11
+ * audience. The id is recorded once, and `entryMcp` (`broker-entries.ts`) is
12
+ * the single reader: the broker entry and the app's `MCP_AUDIENCE` both take
13
+ * it from there.
14
+ * - `zitadel:mcp-access` (per entry): the entry's existing sign-in app issues
15
+ * JWT access tokens and accepts `https://<login host>/mcp-oauth/callback`.
16
+ * - `zitadel:access-token-lifetime` (per org): the instance-wide lifetime goes
17
+ * from 12 hours to one. No app of ours keeps an access token, so no other
18
+ * app is affected (auth ADR 0002, 4). It is set once any org declares `mcp`
19
+ * and is not set back.
20
+ *
21
+ * Blocked, not failing (`CLAUDE.md` rule 2), while the executor's ZITADEL
22
+ * client lacks the calls, or an input is neither recorded nor made by a step in
23
+ * this plan.
24
+ */
25
+ type Blocked = Array<{
26
+ description: string;
27
+ key: string;
28
+ }>;
29
+ /** A ZITADEL call of the MCP steps that has not answered in this long fails the step; the next apply repeats it. */
30
+ export declare const MCP_CALL_TIMEOUT_MS = 60000;
31
+ /** The want of the access step, recorded when it is done. */
32
+ export declare const mcpAccessWant: (org: DeclaredOrg) => string;
33
+ export declare function planMcp(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], zitadelMcp: boolean): {
34
+ steps: DraftStep[];
35
+ blocked: Blocked;
36
+ };
37
+ /**
38
+ * The empty project in the customers organisation that is an entry's MCP token
39
+ * audience. Found by name first, so a create whose answer was lost is not made
40
+ * twice. Its id is recorded under {@link entryMcpProjectScopeKey}, the one
41
+ * source `entryMcp` reads.
42
+ */
43
+ export declare function applyMcpProject(zitadel: Pick<ZitadelPort, 'findOrCreateProject'>, org: DeclaredOrg, action: StepAction, context: StepExecutionContext): Promise<{
44
+ resourceId?: string;
45
+ }>;
46
+ /**
47
+ * The entry's sign-in app issues JWT access tokens and holds the broker's
48
+ * callback. Records the want last, so a failed call is planned again.
49
+ */
50
+ export declare function applyMcpAccess(zitadel: Pick<ZitadelPort, 'ensureOidcAppMcpAccess'>, org: DeclaredOrg, action: StepAction, context: StepExecutionContext): Promise<{
51
+ resourceId?: string;
52
+ }>;
53
+ /** The instance access-token lifetime, set to one hour. */
54
+ export declare function applyAccessTokenLifetime(zitadel: Pick<ZitadelPort, 'ensureAccessTokenLifetime'>, context: StepExecutionContext): Promise<{
55
+ resourceId?: string;
56
+ }>;
57
+ export {};