@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.
- 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/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/diff.js
CHANGED
|
@@ -1,8 +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
5
|
import { planBlueGreen } from './blue-green.js';
|
|
5
|
-
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';
|
|
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';
|
|
6
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
7
8
|
import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
|
|
8
9
|
import { planCompanyBackups } from './company-backups.js';
|
|
@@ -16,6 +17,7 @@ import { lockedHostnames } from './locked-hostnames.js';
|
|
|
16
17
|
import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
|
|
17
18
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
18
19
|
import { mailStoreSwitchChange } from './mail-store.js';
|
|
20
|
+
import { planMcp } from './mcp.js';
|
|
19
21
|
import { hasAdoptedResources, networkMoveNeeded, planNetworkMove } from './network-move.js';
|
|
20
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';
|
|
21
23
|
import { ownDnsHostnames } from './own-dns.js';
|
|
@@ -28,6 +30,7 @@ import { planRemoteDatabase } from './remote-database.js';
|
|
|
28
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';
|
|
29
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';
|
|
30
32
|
import { withStepSystem } from './step-system.js';
|
|
33
|
+
import { brokerKeyDelivered, planWorkerApps } from './worker-app.js';
|
|
31
34
|
import { planWorkers } from './worker.js';
|
|
32
35
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
33
36
|
export function toPlan(detail) {
|
|
@@ -408,6 +411,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
408
411
|
if (guarded)
|
|
409
412
|
plannedSteps.push(guarded);
|
|
410
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);
|
|
411
418
|
// The Access gate before the database, app, tunnel and zone (#84): on a
|
|
412
419
|
// first apply the hostname is gated before anything answers on it.
|
|
413
420
|
// An Access gate that cannot be planned must not leave the hostname it
|
|
@@ -534,7 +541,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
534
541
|
const bouleGuarded = guardApp(bouleClient);
|
|
535
542
|
if (bouleGuarded)
|
|
536
543
|
plannedSteps.push(bouleGuarded);
|
|
537
|
-
const
|
|
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);
|
|
538
549
|
if (boule.step)
|
|
539
550
|
plannedSteps.push(boule.step);
|
|
540
551
|
// After the apps and Boule -- an edge key needs its app recorded or planned,
|
|
@@ -592,6 +603,60 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
592
603
|
plannedSteps.push(step);
|
|
593
604
|
}
|
|
594
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
|
+
}
|
|
595
660
|
// Last: a repair that fails must not stop any step after it (`runSteps` stops at the first failure).
|
|
596
661
|
const idTokenUserinfo = planIdTokenUserinfo(org, live, recordedIds, plannedSteps, platformReadiness.authReady, options.zitadelIdTokenUserinfo ?? false);
|
|
597
662
|
if (idTokenUserinfo)
|
|
@@ -626,6 +691,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
626
691
|
unmanaged.push({ description: agora.issuerNote, key: 'coolify:agora' });
|
|
627
692
|
if (boule.blocked)
|
|
628
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
|
+
}
|
|
629
697
|
if (boule.mailBlocked) {
|
|
630
698
|
unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
|
|
631
699
|
}
|
|
@@ -637,6 +705,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
637
705
|
unmanaged.push(...filesKeys.blocked);
|
|
638
706
|
unmanaged.push(...entryAiKeys.blocked);
|
|
639
707
|
unmanaged.push(...edgeWorkers.blocked);
|
|
708
|
+
unmanaged.push(...workerApps.blocked);
|
|
709
|
+
unmanaged.push(...mcp.blocked);
|
|
640
710
|
unmanaged.push(...entryEdgeKeys.blocked);
|
|
641
711
|
unmanaged.push(...edgeReservedLabels.blocked);
|
|
642
712
|
if (ownAuth.blocked)
|
|
@@ -1302,7 +1372,7 @@ function orgServiceSchemaEnabled(org, schema) {
|
|
|
1302
1372
|
if (schema.schema.startsWith('calls_'))
|
|
1303
1373
|
return org.calls.enabled;
|
|
1304
1374
|
if (schema.schema === 'app')
|
|
1305
|
-
return org.hosting.entries.some(
|
|
1375
|
+
return org.hosting.entries.some(entryHasDatabase);
|
|
1306
1376
|
return true;
|
|
1307
1377
|
}
|
|
1308
1378
|
/**
|
|
@@ -2595,6 +2665,45 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
2595
2665
|
},
|
|
2596
2666
|
};
|
|
2597
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
|
+
}
|
|
2598
2707
|
/**
|
|
2599
2708
|
* #161 (Boule part): the org's own Boule, a per-org singleton keyed by the org
|
|
2600
2709
|
* id under its own `boule` kind, a separate Coolify application from the org's
|
|
@@ -2608,7 +2717,7 @@ function planBouleClient(org, byKey, claimed) {
|
|
|
2608
2717
|
* address (the one Agora signs in at); without it the Boule is blocked, not
|
|
2609
2718
|
* planned, so every other step still applies.
|
|
2610
2719
|
*/
|
|
2611
|
-
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness) {
|
|
2720
|
+
function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness, credentials) {
|
|
2612
2721
|
const key = resourceKey('coolify', 'boule', org.id);
|
|
2613
2722
|
const live = byKey.get(key);
|
|
2614
2723
|
if (live)
|
|
@@ -2678,6 +2787,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2678
2787
|
mailBlocked: mailBlocked !== null,
|
|
2679
2788
|
// Only the platform's own org's Boule gets the peer password for Archon.
|
|
2680
2789
|
platformOwner,
|
|
2790
|
+
archonCredential: credentials.archonCredential === true,
|
|
2791
|
+
customersDirectory: credentials.customersDirectory === true,
|
|
2681
2792
|
},
|
|
2682
2793
|
};
|
|
2683
2794
|
if (!live) {
|
|
@@ -2691,8 +2802,22 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2691
2802
|
};
|
|
2692
2803
|
}
|
|
2693
2804
|
const liveEnv = (live.config.env ?? {});
|
|
2694
|
-
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn);
|
|
2805
|
+
const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn, credentials);
|
|
2695
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
|
+
}
|
|
2696
2821
|
// The token is compared, never planned with its value.
|
|
2697
2822
|
if (mailOn &&
|
|
2698
2823
|
bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
|
|
@@ -3183,6 +3308,9 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
3183
3308
|
if (!resource || (typeof status === 'string' && /^(exited|stopped)/.test(status)))
|
|
3184
3309
|
continue;
|
|
3185
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;
|
|
3186
3314
|
const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
|
|
3187
3315
|
// The executor pushes the issuer the plan read, the way Agora gets its host values.
|
|
3188
3316
|
// The same availability the plan's marker used: the executor records its
|
|
@@ -3322,7 +3450,7 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
3322
3450
|
* {@link planApp}.
|
|
3323
3451
|
*/
|
|
3324
3452
|
function planDatabase(org, byKey, claimed) {
|
|
3325
|
-
return planHostingToggle('coolify', 'database', org.hosting.entries.some(
|
|
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)`);
|
|
3326
3454
|
}
|
|
3327
3455
|
function sortedNames(names) {
|
|
3328
3456
|
return names.filter(Boolean).sort().join(',');
|
|
@@ -3635,6 +3763,15 @@ function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
|
|
|
3635
3763
|
});
|
|
3636
3764
|
continue;
|
|
3637
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
|
+
}
|
|
3638
3775
|
const wanted = wantedBrokerEntry(org, entry, recordedIds);
|
|
3639
3776
|
if ('want' in wanted) {
|
|
3640
3777
|
if (recordedIds[brokerEntryAppliedScopeKey(entry.key)] === JSON.stringify(wanted.want)) {
|
|
@@ -3743,23 +3880,44 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
|
|
|
3743
3880
|
* listed as unmanaged.
|
|
3744
3881
|
*/
|
|
3745
3882
|
function planCustomers(org, live, claimed, recordedIds) {
|
|
3746
|
-
|
|
3747
|
-
|
|
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');
|
|
3748
3886
|
const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
|
|
3749
|
-
if (customersOrgId) {
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
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));
|
|
3756
3904
|
}
|
|
3757
|
-
return null;
|
|
3758
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;
|
|
3759
3912
|
return {
|
|
3760
|
-
action: {
|
|
3913
|
+
action: {
|
|
3914
|
+
system: 'zitadel',
|
|
3915
|
+
kind: 'customers',
|
|
3916
|
+
key: org.id,
|
|
3917
|
+
desired: { signUp: wantedSignUp === '1' },
|
|
3918
|
+
},
|
|
3761
3919
|
step: {
|
|
3762
|
-
description: `
|
|
3920
|
+
description: `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
|
|
3763
3921
|
destroys: false,
|
|
3764
3922
|
migrates: false,
|
|
3765
3923
|
},
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
|
|
2
2
|
import { type CallsSfuUrls } from '../calls-sfu.js';
|
|
3
|
-
import type { DeclaredOrg, HostingBuildPack } from '../org.js';
|
|
3
|
+
import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
|
|
4
4
|
import type { DeclaredOrgStore } from '../store.js';
|
|
5
5
|
import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
|
|
6
6
|
import { type BrokerPort } from './broker-entries.js';
|
|
@@ -18,6 +18,8 @@ import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor }
|
|
|
18
18
|
import type { EdgePortName } from './platform-org.js';
|
|
19
19
|
import type { RequestHeaderPort } from './platform-providers.js';
|
|
20
20
|
import { type ReleaseContext, type ReleaseHost } from './release-host.js';
|
|
21
|
+
import type { WorkerAppDatabasePort } from './worker-app-database.js';
|
|
22
|
+
import { type KeysCustodyPort, type WorkerAppPort } from './worker-app.js';
|
|
21
23
|
export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
|
|
22
24
|
/**
|
|
23
25
|
* The Coolify calls this executor makes, shaped to match
|
|
@@ -835,6 +837,8 @@ export interface ZitadelPort {
|
|
|
835
837
|
appType: 'web' | 'native';
|
|
836
838
|
redirectUris: string[];
|
|
837
839
|
postLogoutRedirectUris: string[];
|
|
840
|
+
/** `jwt` for an entry with `uses.auth.mcp` (providers 0.11.0). Omitted: opaque. */
|
|
841
|
+
accessTokenType?: 'jwt' | 'opaque';
|
|
838
842
|
}): Promise<{
|
|
839
843
|
appId: string;
|
|
840
844
|
clientId: string;
|
|
@@ -1120,6 +1124,25 @@ export interface ZitadelPort {
|
|
|
1120
1124
|
* reports no longer live is never attempted at all.
|
|
1121
1125
|
*/
|
|
1122
1126
|
listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
|
|
1127
|
+
/**
|
|
1128
|
+
* C3: mints a read-only directory credential for one ZITADEL organisation (the company's
|
|
1129
|
+
* customers): a machine user with only the read-only organisation role, and a fresh PAT.
|
|
1130
|
+
* Idempotent on the user by `userName`; every PAT the user already holds is revoked before the
|
|
1131
|
+
* new one is minted, so a rerun leaves exactly one live token (like
|
|
1132
|
+
* {@link mintLoginClientMachineUser}). The caller holds the token and writes it to Boule's env;
|
|
1133
|
+
* it never reaches an error, a log or a recorded id. Optional: a host without it gets the
|
|
1134
|
+
* customers directory reported blocked. `zitadel-machine-user.ts` is the real implementation.
|
|
1135
|
+
*/
|
|
1136
|
+
mintOrgDirectoryReader?(orgId: string, input: {
|
|
1137
|
+
userName: string;
|
|
1138
|
+
name: string;
|
|
1139
|
+
}): Promise<{
|
|
1140
|
+
userId: string;
|
|
1141
|
+
token: string;
|
|
1142
|
+
patId: string;
|
|
1143
|
+
}>;
|
|
1144
|
+
/** C3: revokes every PAT of the directory reader, so a company that turns customers off leaves no live credential. A PAT already gone is not an error. */
|
|
1145
|
+
revokeOrgDirectoryReader?(orgId: string, userId: string): Promise<void>;
|
|
1123
1146
|
/**
|
|
1124
1147
|
* #267: adds the org's own company login host to the ZITADEL instance's
|
|
1125
1148
|
* trusted domains (`@wtfalch/providers`' `ZitadelClient.ensureTrustedDomain`)
|
|
@@ -1177,6 +1200,21 @@ export interface ZitadelPort {
|
|
|
1177
1200
|
* `createOidcApp` and `createOidcAppWithSecret` send it on themselves.
|
|
1178
1201
|
*/
|
|
1179
1202
|
updateOidcAppIdTokenUserinfoAssertion?(orgId: string, projectId: string, appId: string): Promise<boolean>;
|
|
1203
|
+
/**
|
|
1204
|
+
* Auth ADR 0002: an existing sign-in app issues JWT access tokens and holds
|
|
1205
|
+
* every `redirectUris` entry (`@wtfalch/providers` 0.11.0). Resolves `true`
|
|
1206
|
+
* when it wrote, `false` when nothing differed. Optional, with
|
|
1207
|
+
* {@link ZitadelPort.ensureAccessTokenLifetime}: a host whose client lacks
|
|
1208
|
+
* either gets every `uses.auth.mcp` reported blocked
|
|
1209
|
+
* (`FoundryPlanApplyOptions.zitadelMcp`).
|
|
1210
|
+
*/
|
|
1211
|
+
ensureOidcAppMcpAccess?(orgId: string, projectId: string, appId: string, want: {
|
|
1212
|
+
redirectUris: string[];
|
|
1213
|
+
}): Promise<boolean>;
|
|
1214
|
+
/** The instance-wide access-token lifetime, in seconds. Resolves `{ changed }`. */
|
|
1215
|
+
ensureAccessTokenLifetime?(seconds: number): Promise<{
|
|
1216
|
+
changed: boolean;
|
|
1217
|
+
}>;
|
|
1180
1218
|
}
|
|
1181
1219
|
/**
|
|
1182
1220
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
@@ -1540,6 +1578,11 @@ export interface PlatformOrgPortSource {
|
|
|
1540
1578
|
/** The platform's shared sign-in host, or `null` when none is declared. */
|
|
1541
1579
|
authHostname?(): Promise<string | null>;
|
|
1542
1580
|
}
|
|
1581
|
+
/**
|
|
1582
|
+
* C3: mints the Archon credential for one org's Boule and returns the secret. Every call rotates the
|
|
1583
|
+
* org's credential (the old secret stays valid one more hour) or issues the first.
|
|
1584
|
+
*/
|
|
1585
|
+
export type BouleArchonCredentialMinter = (orgId: OrgId) => Promise<string>;
|
|
1543
1586
|
export interface FoundryStepExecutorPorts {
|
|
1544
1587
|
coolify: CoolifyPort;
|
|
1545
1588
|
/**
|
|
@@ -1666,6 +1709,29 @@ export interface FoundryStepExecutorPorts {
|
|
|
1666
1709
|
* sets `FoundryPlanApplyOptions.edgeWorkers`).
|
|
1667
1710
|
*/
|
|
1668
1711
|
workers?: WorkersPort;
|
|
1712
|
+
/**
|
|
1713
|
+
* ADR 0002: deploys and routes an entry's `workerApp` -- see
|
|
1714
|
+
* {@link WorkerAppPort}. Optional the same way `workers` is: without it the
|
|
1715
|
+
* plan reports each Worker app blocked (the host also sets
|
|
1716
|
+
* `FoundryPlanApplyOptions.workerApps`).
|
|
1717
|
+
*/
|
|
1718
|
+
workerApps?: WorkerAppPort;
|
|
1719
|
+
/** ADR 0002: the keys custody Worker, for a `keysCustody` secret of a Worker app. */
|
|
1720
|
+
workerAppKeys?: KeysCustodyPort;
|
|
1721
|
+
/**
|
|
1722
|
+
* C3: mints the credential an org's Boule reaches Archon with (org-scoped; `orgs:request` and
|
|
1723
|
+
* `orgs:read` for that org). Archon implements it. Every call rotates the credential (the old secret
|
|
1724
|
+
* stays valid one more hour) and credentials expire after 365 days, so foundry calls it only when none
|
|
1725
|
+
* was pushed or the recorded push time is 335 days old. It reaches Boule's env as `ARCHON_CREDENTIAL` and nowhere else. Without it the host
|
|
1726
|
+
* writes no Archon credential and the plan says so.
|
|
1727
|
+
*/
|
|
1728
|
+
mintBouleArchonCredential?: BouleArchonCredentialMinter;
|
|
1729
|
+
/**
|
|
1730
|
+
* C2: the Hyperdrive config, the release's `migrations.json` and the SQL runner behind the tunnel,
|
|
1731
|
+
* for a Worker app with `workerApp.database`. Without it the plan reports that entry blocked
|
|
1732
|
+
* (the host also sets `FoundryPlanApplyOptions.workerAppDatabase`).
|
|
1733
|
+
*/
|
|
1734
|
+
workerAppDatabase?: WorkerAppDatabasePort;
|
|
1669
1735
|
/**
|
|
1670
1736
|
* Hands a just-minted, one-time secret (today: an API app's
|
|
1671
1737
|
* `clientSecret`) to the host, immediately after the provider returns it
|
|
@@ -1847,6 +1913,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1847
1913
|
private executeScoped;
|
|
1848
1914
|
/** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
|
|
1849
1915
|
ownMailKeyMintable(): boolean;
|
|
1916
|
+
/** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
|
|
1917
|
+
customersDirectoryAvailable(): boolean;
|
|
1918
|
+
/** C3: whether the host supplied `mintBouleArchonCredential`. */
|
|
1919
|
+
bouleArchonCredentialAvailable(): boolean;
|
|
1850
1920
|
/** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
|
|
1851
1921
|
ownSigninDbKeysPuttable(): boolean;
|
|
1852
1922
|
/** Whether the host supplies the backup bucket the sign-in database dumps into (#746). */
|
|
@@ -1948,6 +2018,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1948
2018
|
* "Declared changes apply" nothing is marked done until the
|
|
1949
2019
|
* live state actually matches it.
|
|
1950
2020
|
*/
|
|
2021
|
+
private workerAppDeps;
|
|
1951
2022
|
private cloudflareWorker;
|
|
1952
2023
|
/**
|
|
1953
2024
|
* Makes the no-Worker routes recorded for an entry match `patterns`: deletes
|
|
@@ -2007,6 +2078,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2007
2078
|
* nothing: the next apply finds the account by username and mints a new code.
|
|
2008
2079
|
*/
|
|
2009
2080
|
private zitadelOwner;
|
|
2081
|
+
/** `plan/mcp.ts`: the entry's MCP audience project. */
|
|
2082
|
+
private zitadelMcpProject;
|
|
2083
|
+
/** `plan/mcp.ts`: JWT access tokens and the broker's callback on the entry's app. */
|
|
2084
|
+
private zitadelMcpAccess;
|
|
2085
|
+
/** `plan/mcp.ts`: the instance access-token lifetime. */
|
|
2086
|
+
private zitadelAccessTokenLifetime;
|
|
2010
2087
|
/**
|
|
2011
2088
|
* Switches on "user info inside the ID token" for each live sign-in app
|
|
2012
2089
|
* the planner named. An app's marker (its ZITADEL app id) is recorded and
|
|
@@ -2100,7 +2177,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2100
2177
|
/**
|
|
2101
2178
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
2102
2179
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
2103
|
-
* self-registration
|
|
2180
|
+
* self-registration as `customers.signUp` says (off by default) and a grant to the org's app project. Staff stay in
|
|
2104
2181
|
* the org's own invite-only ZITADEL org. The space keeps the name apart from
|
|
2105
2182
|
* every issuer org, which is named by a slug and a slug has no spaces. Every call here is idempotent, so
|
|
2106
2183
|
* a re-run after a crash finishes the job.
|
|
@@ -2923,6 +3000,27 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2923
3000
|
* shape {@link coolifyAgora}'s own doc comment explains.
|
|
2924
3001
|
*/
|
|
2925
3002
|
private coolifyBoule;
|
|
3003
|
+
/**
|
|
3004
|
+
* C3: the Archon credential of an org's Boule. Archon's mint rotates on every call, so it is called only
|
|
3005
|
+
* when none was pushed or the recorded push time (no secret) is 335 days old. Otherwise the held value is
|
|
3006
|
+
* written again unchanged. The secret is held first; the caller records `pushedAt` only after the env write
|
|
3007
|
+
* succeeded, so a failed hold or a failed push is mended by the next apply minting again. Errors name no secret.
|
|
3008
|
+
*/
|
|
3009
|
+
private ensureBouleArchonCredential;
|
|
3010
|
+
/**
|
|
3011
|
+
* C3: the read-only credential for the company's customers organisation. Held first. Minting revokes every
|
|
3012
|
+
* earlier token of the machine user, so a hold that failed is repaired by the next apply minting again and
|
|
3013
|
+
* leaves one live token. `null` while the customers organisation is not recorded yet.
|
|
3014
|
+
*/
|
|
3015
|
+
private ensureCustomersDirectoryCredential;
|
|
3016
|
+
/** `put`, tried up to {@link BOULE_CREDENTIAL_ATTEMPTS} times; true once one attempt succeeds. The cause is dropped. */
|
|
3017
|
+
private holdWithRetry;
|
|
3018
|
+
/**
|
|
3019
|
+
* C3: customers switched off. Revokes the directory credential's tokens first, then removes the customers
|
|
3020
|
+
* env from Boule, then forgets the held value and the user id. Each step is repeated safely, so a run that
|
|
3021
|
+
* died halfway is finished by the next. Returns true when Boule's env changed.
|
|
3022
|
+
*/
|
|
3023
|
+
private dropCustomersCredential;
|
|
2926
3024
|
/**
|
|
2927
3025
|
* Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
|
|
2928
3026
|
* the platform's own apex, else the org's `<slug>-app` token through the
|