@wtfalch/foundry 0.43.0 → 0.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0057_org_customers.sql +29 -0
- package/dist/org.d.ts +42 -0
- package/dist/org.js +52 -26
- package/dist/plan/boule.d.ts +43 -2
- package/dist/plan/boule.js +48 -1
- package/dist/plan/broker-entries.d.ts +28 -0
- package/dist/plan/broker-entries.js +46 -1
- package/dist/plan/diff.d.ts +14 -0
- package/dist/plan/diff.js +176 -18
- package/dist/plan/executor.d.ts +100 -2
- package/dist/plan/executor.js +273 -16
- package/dist/plan/hostnames.d.ts +7 -0
- package/dist/plan/hostnames.js +17 -2
- package/dist/plan/locked-hostnames.d.ts +11 -0
- package/dist/plan/locked-hostnames.js +13 -0
- package/dist/plan/mcp.d.ts +57 -0
- package/dist/plan/mcp.js +131 -0
- package/dist/plan/org-names.d.ts +2 -2
- package/dist/plan/org-names.js +16 -1
- package/dist/plan/plan-apply.d.ts +26 -0
- package/dist/plan/plan-apply.js +45 -5
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +2 -1
- package/dist/plan/proxy-labels.js +2 -0
- package/dist/plan/worker-app-database.d.ts +148 -0
- package/dist/plan/worker-app-database.js +424 -0
- package/dist/plan/worker-app.d.ts +320 -0
- package/dist/plan/worker-app.js +792 -0
- package/dist/plan/zitadel-machine-user.d.ts +13 -1
- package/dist/plan/zitadel-machine-user.js +80 -0
- package/dist/requested-changes.d.ts +12 -3
- package/dist/requested-changes.js +22 -0
- package/dist/store/postgres.js +12 -6
- package/dist/store/schema.d.ts +34 -0
- package/dist/store/schema.js +3 -0
- package/dist/worker-app-config.d.ts +151 -0
- package/dist/worker-app-config.js +438 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
?
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -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
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
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 {};
|