@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.
Files changed (40) 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/platform-providers.d.ts +1 -1
  25. package/dist/plan/platform-providers.js +2 -1
  26. package/dist/plan/proxy-labels.js +2 -0
  27. package/dist/plan/worker-app-database.d.ts +148 -0
  28. package/dist/plan/worker-app-database.js +424 -0
  29. package/dist/plan/worker-app.d.ts +320 -0
  30. package/dist/plan/worker-app.js +792 -0
  31. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  32. package/dist/plan/zitadel-machine-user.js +80 -0
  33. package/dist/requested-changes.d.ts +12 -3
  34. package/dist/requested-changes.js +22 -0
  35. package/dist/store/postgres.js +12 -6
  36. package/dist/store/schema.d.ts +34 -0
  37. package/dist/store/schema.js +3 -0
  38. package/dist/worker-app-config.d.ts +151 -0
  39. package/dist/worker-app-config.js +438 -0
  40. 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 boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness);
544
+ const bouleCredentials = planBouleCredentials(org, recordedIds, {
545
+ archon: options.bouleArchonCredential === true,
546
+ directory: options.customersDirectory === true,
547
+ }, platformReadiness.canHoldSecrets === true, (options.now ?? Date.now)());
548
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, bouleCredentials.wants);
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((entry) => entry.database);
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((entry) => entry.database), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
3453
+ return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
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
- if (!org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers'))
3747
- return null;
3883
+ const signUp = org.customers?.signUp === true;
3884
+ const wanted = org.customers?.enabled === true ||
3885
+ org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers');
3748
3886
  const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
3749
- if (customersOrgId) {
3750
- for (const r of live) {
3751
- if (r.system === 'zitadel' &&
3752
- r.kind === 'project_grant' &&
3753
- r.config.grantedOrgId === customersOrgId) {
3754
- claimed.add(resourceKey(r.system, r.kind, r.key));
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: { system: 'zitadel', kind: 'customers', key: org.id, desired: {} },
3913
+ action: {
3914
+ system: 'zitadel',
3915
+ kind: 'customers',
3916
+ key: org.id,
3917
+ desired: { signUp: wantedSignUp === '1' },
3918
+ },
3761
3919
  step: {
3762
- description: `provision a self-registration ZITADEL org for ${org.slug}'s customers`,
3920
+ description: `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
3763
3921
  destroys: false,
3764
3922
  migrates: false,
3765
3923
  },
@@ -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 on and a grant to the org's app project. Staff stay in
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