@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/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateVersionPin, validateSite, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, HeartbeatsProvider, } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  import { type PinnedVersions } from './bootstrap/versions.js';
6
6
  export type { PinnedVersions };
7
7
  export declare const PINNED_VERSIONS: Readonly<PinnedVersions>;
8
- export type { RequestableField, CallsChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
8
+ export type { RequestableField, CallsChange, CustomersChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
9
9
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
10
10
  export type { SlugRejectionReason, SlugRejection } from './slug.js';
11
11
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateSignIn, validateVersionPin, validateSite, } from './org.js';
1
+ export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, SFU_SLOT_MAX, VERSION_PIN_PARTS, appEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, HEARTBEATS_PROVIDERS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  // archon#273: the pinned platform service versions the planner deploys, so a
4
4
  // host can show declared against running. Readonly: it is the planner's own
@@ -0,0 +1,29 @@
1
+ -- @wtfalch/foundry: a company's customers switch (C3). `customers_enabled` gives the company a
2
+ -- `<slug> customers` sign-in organisation and a read-only directory credential for its Boule;
3
+ -- `customers_sign_up` lets people register themselves in it. Both default off.
4
+ -- Mirrors src/store/schema.ts's `declaredOrgs`.
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists customers_enabled boolean not null default false,
8
+ add column if not exists customers_sign_up boolean not null default false;
9
+
10
+ -- A company that already signs a hosting entry's users in as customers keeps working. Until now
11
+ -- foundry forced self-registration on for every customers organisation, so those companies carry
12
+ -- both switches on: the live state does not change. A company declared after this migration
13
+ -- starts with sign-up off.
14
+ update foundry_declared_orgs
15
+ set customers_enabled = true,
16
+ customers_sign_up = true
17
+ where jsonb_typeof(hosting_entries) = 'array'
18
+ and exists (
19
+ select 1
20
+ from jsonb_array_elements(hosting_entries) as entry
21
+ where entry -> 'uses' -> 'auth' ->> 'users' = 'customers'
22
+ );
23
+
24
+ -- Requested changes may now name `customers`.
25
+ alter table foundry_requested_changes
26
+ drop constraint if exists foundry_requested_changes_field_check;
27
+ alter table foundry_requested_changes
28
+ add constraint foundry_requested_changes_field_check
29
+ check (field in ('calls', 'customers', 'storageLimit', 'aiLimit', 'domain', 'mailTier'));
package/dist/org.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type WorkerAppConfig } from './worker-app-config.js';
2
+ export type { WorkerAppConfig } from './worker-app-config.js';
1
3
  /**
2
4
  * Opaque, minted by foundry when an org is created. Keys the declared org,
3
5
  * the applied state, every estate-wide service's id map, and the chat
@@ -355,6 +357,12 @@ export interface HostingEntry {
355
357
  * is the org's own domain.
356
358
  */
357
359
  worker?: WorkerConfig;
360
+ /**
361
+ * A Cloudflare Worker with Durable Objects and Containers, hosted by foundry
362
+ * as an option any org can declare (ADR 0002). Exclusive with `worker` and a
363
+ * Coolify `repo`; `validateEntryWorkerApp` states the rest.
364
+ */
365
+ workerApp?: WorkerAppConfig;
358
366
  /**
359
367
  * Blue/green releases (docs/adr/0001-blue-green-releases.md). Presence turns it on: foundry runs the app as a
360
368
  * blue and a green copy behind a load balancer on every server it manages, and the tunnel routes to the balancer.
@@ -441,7 +449,19 @@ export interface EntryAuth {
441
449
  users: 'staff' | 'customers';
442
450
  registration: 'open' | 'closed';
443
451
  replacesBrokerEntry?: string;
452
+ /**
453
+ * The entry is an OAuth resource server for MCP clients (auth ADR 0002): the
454
+ * broker is the authorization server, ZITADEL signs the person in. `path` is
455
+ * the MCP endpoint on the entry's host; the resource is
456
+ * `https://<the entry's host><path>`, derived and never declared. Needs
457
+ * `users: 'customers'` and `registration: 'open'`.
458
+ */
459
+ mcp?: {
460
+ path: string;
461
+ };
444
462
  }
463
+ /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
464
+ export declare const MCP_PATH: RegExp;
445
465
  /**
446
466
  * AI for one entry: a key below the org's own ai key, the `AI_*` env and,
447
467
  * with `webhookPath`, the ai webhook. `offerings` is what the app may call,
@@ -562,6 +582,8 @@ export declare const heldEnvRef: (orgId: string, entryKey: string, name: string)
562
582
  export declare function validateHosting(hosting: HostingConfig, names?: {
563
583
  domain: string | null;
564
584
  locked: ReadonlySet<string>;
585
+ slug?: string;
586
+ siteOwnsApex?: boolean;
565
587
  }): string | null;
566
588
  /** `blueGreen`: steps ascending, each a whole number 1..100, the last 100; a hold of 1..1440 minutes. */
567
589
  export declare function validateBlueGreen(value: unknown, at: string): string | null;
@@ -664,6 +686,24 @@ export interface SignInConfig {
664
686
  * own sign-in stack gets the second login app.
665
687
  */
666
688
  export declare function validateSignIn(org: Pick<DeclaredOrg, 'signIn' | 'domain' | 'issuer' | 'services'>): string | null;
689
+ /**
690
+ * A company's customers: people outside the company who sign in to its apps (C3, campaign
691
+ * 2026-10-15). `enabled` gives the company a `<slug> customers` ZITADEL organisation and a
692
+ * read-only directory credential for its Boule. `signUp` lets people register themselves in
693
+ * that organisation; it needs `enabled` and is off by default. Both are requestable (`customers`).
694
+ */
695
+ export interface CustomersConfig {
696
+ enabled: boolean;
697
+ signUp: boolean;
698
+ }
699
+ /**
700
+ * `null` when `customers` is valid: both switches are booleans, sign-up needs customers on, and
701
+ * an entry with `registration: 'open'` needs both, because open registration signs people up into
702
+ * the customers organisation.
703
+ */
704
+ export declare function validateCustomers(org: Pick<DeclaredOrg, 'customers'> & {
705
+ hosting: Pick<HostingConfig, 'entries'>;
706
+ }): string | null;
667
707
  /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
668
708
  export declare function ownsServices(org: Pick<DeclaredOrg, 'services'>): boolean;
669
709
  /** The pure part of the `services` rules; that no two orgs share an `sfuSlot` is the store's check. */
@@ -946,6 +986,8 @@ export interface DeclaredOrg {
946
986
  services?: ServicesConfig;
947
987
  /** Absent means `{ inApp: false }`. Optional so every existing literal and fixture compiles unchanged. */
948
988
  signIn?: SignInConfig;
989
+ /** Absent means `{ enabled: false, signUp: false }`. Optional so every existing literal and fixture compiles unchanged. */
990
+ customers?: CustomersConfig;
949
991
  /** Parts of this company held on their own version. Absent or null follows the platform. Ignored for the owner company. */
950
992
  versionPin?: VersionPin | null;
951
993
  sso: SsoConfig;
package/dist/org.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
2
+ import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
2
3
  /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
3
4
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
4
5
  /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
@@ -10,6 +11,8 @@ export const DEFAULT_GATE_LIMIT = 3;
10
11
  export const APP_ENTRY_KEY = 'app';
11
12
  /** The most volumes one entry may declare. */
12
13
  export const MAX_ENTRY_VOLUMES = 8;
14
+ /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
15
+ export const MCP_PATH = /^\/[A-Za-z0-9_~-]+(\/[A-Za-z0-9_~-]+)*$|^\/$/;
13
16
  /** What an entry's ai key may hold: the most the org's own key holds. */
14
17
  export const AI_ENTRY_PERMISSIONS = ['ai.offerings:invoke', 'ai.runs:create'];
15
18
  /** The env names `uses.ai` writes; `env` may not name them. */
@@ -159,6 +162,9 @@ export function validateHosting(hosting, names) {
159
162
  const invalidWorker = validateEntryWorker(entry, hosting, names?.domain);
160
163
  if (invalidWorker)
161
164
  return invalidWorker;
165
+ const invalidWorkerApp = validateEntryWorkerApp(entry, hosting, names);
166
+ if (invalidWorkerApp)
167
+ return invalidWorkerApp;
162
168
  const invalidEdgeUse = validateEntryEdge(entry, hosting, names?.domain);
163
169
  if (invalidEdgeUse)
164
170
  return invalidEdgeUse;
@@ -183,7 +189,7 @@ export function validateHosting(hosting, names) {
183
189
  const invalidNames = validateEntryHostnames(hosting, names);
184
190
  if (invalidNames)
185
191
  return invalidNames;
186
- if (hosting.entries.filter((entry) => entry.database).length > 1) {
192
+ if (hosting.entries.filter(entryHasDatabase).length > 1) {
187
193
  return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
188
194
  }
189
195
  const invalidGates = validateGates(hosting, names?.domain, names?.locked);
@@ -308,7 +314,10 @@ function validateEntryHostnames(hosting, names) {
308
314
  return `${at} hostnames need hosting.usesDomain: without it only the '${APP_ENTRY_KEY}' entry is served, at its own address`;
309
315
  }
310
316
  const reads = hosting.usesDomain && names?.domain;
311
- if (reads && entry.key === APP_ENTRY_KEY && !entry.hostnames.some((n) => n !== '*')) {
317
+ if (reads &&
318
+ entry.key === APP_ENTRY_KEY &&
319
+ !entry.workerApp &&
320
+ !entry.hostnames.some((n) => n !== '*')) {
312
321
  return `${at} needs a hostname ('@' or a label) while hosting.usesDomain is on: '*' alone names nothing, and the app would fall back to the bare domain`;
313
322
  }
314
323
  for (const name of entry.hostnames) {
@@ -538,6 +547,16 @@ function validateEntryAuth(entry, hosting, domain) {
538
547
  if (!hasExactHostname(entry, hosting, domain)) {
539
548
  return `${at} needs an exact hostname to sign in at`;
540
549
  }
550
+ if (auth.mcp !== undefined) {
551
+ if (auth.users !== 'customers' || auth.registration !== 'open') {
552
+ return `${at} mcp needs users 'customers' and registration 'open': an MCP client signs the customer in, and only customers register themselves`;
553
+ }
554
+ if (typeof auth.mcp?.path !== 'string' ||
555
+ !MCP_PATH.test(auth.mcp.path) ||
556
+ auth.mcp.path.length > 200) {
557
+ return `${at} mcp.path must be an absolute path of letters, digits, '_', '~' and '-' segments, such as '/mcp'`;
558
+ }
559
+ }
541
560
  if (entry.key === 'agora' ||
542
561
  entry.key === 'boule' ||
543
562
  entry.key === 'cms' ||
@@ -548,6 +567,8 @@ function validateEntryAuth(entry, hosting, domain) {
548
567
  }
549
568
  /** With no domain the hostnames are not read (`entryHostnames`). */
550
569
  function hasExactHostname(entry, hosting, domain) {
570
+ if (entry.workerApp)
571
+ return true; // Its host is workerApp.host, or <slug>[-<key>].oikos.host.
551
572
  return hosting.usesDomain && domain !== null
552
573
  ? entry.hostnames.some((name) => name !== '*')
553
574
  : entry.key === APP_ENTRY_KEY;
@@ -638,7 +659,6 @@ function workerRouteZone(route) {
638
659
  return host.startsWith('*.') ? host.slice(2) : host;
639
660
  }
640
661
  const WORKER_ROUTE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+\/.*$/;
641
- const WORKER_SHA256 = /^[0-9a-f]{64}$/;
642
662
  /**
643
663
  * Restated, not shared, from `plan/worker.ts`'s `WORKER_ORG_HASH_LENGTH`/
644
664
  * `WORKER_NAME_MAX_LENGTH` -- importing them here would import that
@@ -672,29 +692,9 @@ function validateEntryWorker(entry, hosting, domain) {
672
692
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
673
693
  return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "-edge") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
674
694
  }
675
- let artifactUrl;
676
- try {
677
- artifactUrl = new URL(worker.artifactUrl);
678
- }
679
- catch {
680
- return `${at} artifactUrl must be an absolute URL, not a bare path or relative file`;
681
- }
682
- if (artifactUrl.protocol !== 'https:') {
683
- return `${at} artifactUrl must be an https:// URL`;
684
- }
685
- if (artifactUrl.username || artifactUrl.password) {
686
- return `${at} artifactUrl must not carry userinfo (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
687
- }
688
- if (artifactUrl.search) {
689
- return `${at} artifactUrl must not carry a query string (a signed or credentialed URL ends up stored verbatim in recorded scope ids)`;
690
- }
691
- // `URL.hash` is '' for a bare trailing `#`, so the raw string is checked.
692
- if (worker.artifactUrl.includes('#')) {
693
- return `${at} artifactUrl must not carry a fragment (refused for the same reason as a query string)`;
694
- }
695
- if (!WORKER_SHA256.test(worker.artifactSha256)) {
696
- return `${at} artifactSha256 must be 64 lowercase hex characters`;
697
- }
695
+ const invalidArtifact = validateArtifact(at, worker.artifactUrl, worker.artifactSha256, entry.repo);
696
+ if (invalidArtifact)
697
+ return invalidArtifact;
698
698
  if (!WORKER_ROUTE.test(worker.route)) {
699
699
  return `${at} route must be a hostname (an optional leading '*.' label, then at least two more labels) and a path, joined by '/'`;
700
700
  }
@@ -955,6 +955,32 @@ export function validateSignIn(org) {
955
955
  }
956
956
  return null;
957
957
  }
958
+ /**
959
+ * `null` when `customers` is valid: both switches are booleans, sign-up needs customers on, and
960
+ * an entry with `registration: 'open'` needs both, because open registration signs people up into
961
+ * the customers organisation.
962
+ */
963
+ export function validateCustomers(org) {
964
+ const customers = org.customers;
965
+ if (customers !== undefined) {
966
+ const value = customers;
967
+ if (typeof customers !== 'object' ||
968
+ customers === null ||
969
+ Array.isArray(customers) ||
970
+ typeof value?.enabled !== 'boolean' ||
971
+ typeof value?.signUp !== 'boolean' ||
972
+ Object.keys(customers).some((key) => key !== 'enabled' && key !== 'signUp')) {
973
+ return 'customers must be { enabled: boolean, signUp: boolean }';
974
+ }
975
+ if (value.signUp && !value.enabled)
976
+ return 'customers.signUp needs customers.enabled';
977
+ }
978
+ const open = org.hosting.entries.find((entry) => entry.uses?.auth?.registration === 'open');
979
+ if (open && !(org.customers?.enabled && org.customers.signUp)) {
980
+ return `hosting.entries '${open.key}' uses.auth registration 'open' needs customers.enabled and customers.signUp`;
981
+ }
982
+ return null;
983
+ }
958
984
  /** The only reader of `services.own`. Every company runs its own services, the platform's owner company too; a company without a domain runs them on oikos.host. */
959
985
  export function ownsServices(org) {
960
986
  return org.services?.own === true;
@@ -79,8 +79,44 @@ export declare const BOULE_PEER_PASSWORD_ENV = "AUTHZ_PEER_PASSWORD_OPERATOR";
79
79
  export declare const BOULE_BRIDGE_SECRET_ENV = "ARCHON_BRIDGE_SECRET";
80
80
  /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
81
81
  export declare const BOULE_PEER_ROLE = "authz_operator_rt";
82
+ /** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
83
+ export declare const BOULE_ARCHON_ORG_ID_ENV = "ARCHON_ORG_ID";
84
+ export declare const BOULE_ARCHON_CREDENTIAL_ENV = "ARCHON_CREDENTIAL";
85
+ /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
86
+ export declare const BOULE_CUSTOMERS_ORG_ENV = "CUSTOMERS_ORGANISATION_ID";
87
+ export declare const BOULE_CUSTOMERS_CREDENTIAL_ENV = "CUSTOMERS_DIRECTORY_CREDENTIAL";
88
+ /**
89
+ * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
90
+ * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
91
+ * push is {@link ARCHON_CREDENTIAL_RENEW_AFTER_MS} old.
92
+ */
93
+ export declare const BOULE_ARCHON_PUSHED_AT_SCOPE_KEY = "boule_archon_pushed_at";
94
+ export declare const ARCHON_CREDENTIAL_RENEW_AFTER_MS: number;
95
+ /** True when no push is recorded, it is unreadable, or it is at least 335 days old. */
96
+ export declare function archonCredentialRenewDue(recorded: Readonly<Record<string, string>>, nowMs: number): boolean;
97
+ /** The ZITADEL machine user that reads the customers organisation. No secret. */
98
+ export declare const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = "customers_directory_user";
99
+ /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
100
+ export declare const CUSTOMERS_SIGN_UP_SCOPE_KEY = "zitadel_customers_signup";
101
+ /** Where the held-key store keeps the Archon credential of an org's Boule. */
102
+ export declare const bouleArchonCredentialRef: (orgId: string) => string;
103
+ /** Where the held-key store keeps the directory credential of an org's customers. */
104
+ export declare const customersDirectoryRef: (orgId: string) => string;
105
+ /** The customers organisation id `zitadel:customers` recorded. */
106
+ export declare const customersOrgIdOf: (org: Pick<DeclaredOrg, "id">, recorded: Readonly<Record<string, string>>) => string | undefined;
107
+ /**
108
+ * What the host can deliver to an org's Boule beyond its sign-in env: the Archon credential
109
+ * (`mintBouleArchonCredential`) and the customers directory credential (`mintOrgDirectoryReader`).
110
+ * Each needs a held-key store that can put. The plan decides once and carries the answer in the step.
111
+ */
112
+ export interface BouleCredentialWants {
113
+ archonCredential?: boolean;
114
+ customersDirectory?: boolean;
115
+ /** The recorded Archon credential is missing or 335 days old: the step mints it again. */
116
+ archonRenewDue?: boolean;
117
+ }
82
118
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
83
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "SIGNIN_UPSTREAM"];
119
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "SIGNIN_UPSTREAM"];
84
120
  /**
85
121
  * The env vars an org's Boule app should carry, and every input still missing
86
122
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -106,7 +142,7 @@ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER",
106
142
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
107
143
  * (generated once, never rotated).
108
144
  */
109
- export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null, mailWanted?: boolean): {
145
+ export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null, mailWanted?: boolean, wants?: BouleCredentialWants): {
110
146
  env: Record<string, string>;
111
147
  missing: string[];
112
148
  };
@@ -129,3 +165,8 @@ export declare function wantedBouleBrokerEntry(org: DeclaredOrg, recorded: Reado
129
165
  * except `SIGNIN_UPSTREAM`: the switch turned off must take it away, so a live one that is not desired is drift.
130
166
  */
131
167
  export declare function bouleEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
168
+ /**
169
+ * The customers env a live Boule still holds after the company switched customers off. A host that merely
170
+ * cannot deliver the credential keeps what it has: only an explicit off removes it.
171
+ */
172
+ export declare function staleCustomersEnv(org: Pick<DeclaredOrg, 'customers'>, live: Readonly<Record<string, string>>): string[];
@@ -90,6 +90,34 @@ export const BOULE_PEER_PASSWORD_ENV = 'AUTHZ_PEER_PASSWORD_OPERATOR';
90
90
  export const BOULE_BRIDGE_SECRET_ENV = 'ARCHON_BRIDGE_SECRET';
91
91
  /** The role Boule creates from {@link BOULE_PEER_PASSWORD_ENV}, with grants on schema `authz` only. */
92
92
  export const BOULE_PEER_ROLE = 'authz_operator_rt';
93
+ /** The org's Archon id and the credential Boule reaches Archon with (C3). The id is the org's own; the credential is a secret. */
94
+ export const BOULE_ARCHON_ORG_ID_ENV = 'ARCHON_ORG_ID';
95
+ export const BOULE_ARCHON_CREDENTIAL_ENV = 'ARCHON_CREDENTIAL';
96
+ /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
97
+ export const BOULE_CUSTOMERS_ORG_ENV = 'CUSTOMERS_ORGANISATION_ID';
98
+ export const BOULE_CUSTOMERS_CREDENTIAL_ENV = 'CUSTOMERS_DIRECTORY_CREDENTIAL';
99
+ /**
100
+ * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
101
+ * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
102
+ * push is {@link ARCHON_CREDENTIAL_RENEW_AFTER_MS} old.
103
+ */
104
+ export const BOULE_ARCHON_PUSHED_AT_SCOPE_KEY = 'boule_archon_pushed_at';
105
+ export const ARCHON_CREDENTIAL_RENEW_AFTER_MS = 335 * 24 * 60 * 60 * 1000;
106
+ /** True when no push is recorded, it is unreadable, or it is at least 335 days old. */
107
+ export function archonCredentialRenewDue(recorded, nowMs) {
108
+ const at = Date.parse(recorded[BOULE_ARCHON_PUSHED_AT_SCOPE_KEY] ?? '');
109
+ return Number.isNaN(at) || nowMs - at >= ARCHON_CREDENTIAL_RENEW_AFTER_MS;
110
+ }
111
+ /** The ZITADEL machine user that reads the customers organisation. No secret. */
112
+ export const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = 'customers_directory_user';
113
+ /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
114
+ export const CUSTOMERS_SIGN_UP_SCOPE_KEY = 'zitadel_customers_signup';
115
+ /** Where the held-key store keeps the Archon credential of an org's Boule. */
116
+ export const bouleArchonCredentialRef = (orgId) => `foundry:boule-archon:${orgId}`;
117
+ /** Where the held-key store keeps the directory credential of an org's customers. */
118
+ export const customersDirectoryRef = (orgId) => `foundry:customers-directory:${orgId}`;
119
+ /** The customers organisation id `zitadel:customers` recorded. */
120
+ export const customersOrgIdOf = (org, recorded) => recorded[`zitadel:customers:${org.id}`] || undefined;
93
121
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
94
122
  export const BOULE_MANAGED_ENV_KEYS = [
95
123
  'APP_URL',
@@ -104,6 +132,10 @@ export const BOULE_MANAGED_ENV_KEYS = [
104
132
  BOULE_MAIL_TOKEN_ENV,
105
133
  BOULE_PEER_PASSWORD_ENV,
106
134
  BOULE_BRIDGE_SECRET_ENV,
135
+ BOULE_ARCHON_ORG_ID_ENV,
136
+ BOULE_ARCHON_CREDENTIAL_ENV,
137
+ BOULE_CUSTOMERS_ORG_ENV,
138
+ BOULE_CUSTOMERS_CREDENTIAL_ENV,
107
139
  SIGN_IN_UPSTREAM_ENV,
108
140
  ];
109
141
  /**
@@ -131,7 +163,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
131
163
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
132
164
  * (generated once, never rotated).
133
165
  */
134
- export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = false) {
166
+ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = false, wants = {}) {
135
167
  const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
136
168
  const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
137
169
  const missing = [];
@@ -153,6 +185,12 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
153
185
  env.AUTH_CLIENT_ID = clientId;
154
186
  if (archonUrl)
155
187
  env.ARCHON_URL = archonUrl;
188
+ if (wants.archonCredential)
189
+ env[BOULE_ARCHON_ORG_ID_ENV] = org.id;
190
+ // The id is not missing while unrecorded: the customers step records it, and the plan plans Boule again then.
191
+ const customersOrgId = wants.customersDirectory ? customersOrgIdOf(org, recorded) : undefined;
192
+ if (customersOrgId)
193
+ env[BOULE_CUSTOMERS_ORG_ENV] = customersOrgId;
156
194
  const owner = org.owners[0];
157
195
  const ownerUserId = owner
158
196
  ? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
@@ -219,3 +257,12 @@ export function bouleEnvDrift(desired, live) {
219
257
  drift.push(SIGN_IN_UPSTREAM_ENV);
220
258
  return drift;
221
259
  }
260
+ /**
261
+ * The customers env a live Boule still holds after the company switched customers off. A host that merely
262
+ * cannot deliver the credential keeps what it has: only an explicit off removes it.
263
+ */
264
+ export function staleCustomersEnv(org, live) {
265
+ if (org.customers?.enabled)
266
+ return [];
267
+ return [BOULE_CUSTOMERS_ORG_ENV, BOULE_CUSTOMERS_CREDENTIAL_ENV].filter((key) => live[key] !== undefined);
268
+ }
@@ -19,6 +19,28 @@ export declare function brokerEntryName(org: DeclaredOrg, entry: HostingEntry):
19
19
  export declare function brokerEntryAppliedScopeKey(entryKey: string): string;
20
20
  /** The foreign entries the last write found invalid, as a JSON array of names. */
21
21
  export declare function brokerEntryWarningsScopeKey(entryKey: string): string;
22
+ /** The ZITADEL project id that is an MCP entry's token audience, recorded by `zitadel:mcp-project`. */
23
+ export declare const entryMcpProjectScopeKey: (entryKey: string) => string;
24
+ /** The marker for the entry's app having JWT access tokens and the MCP callback (`zitadel:mcp-access`). */
25
+ export declare const entryMcpAccessAppliedScopeKey: (entryKey: string) => string;
26
+ /** The instance access-token lifetime the MCP flow wants, in seconds (auth ADR 0002, 4). */
27
+ export declare const MCP_ACCESS_TOKEN_LIFETIME_SECONDS = 3600;
28
+ /** Records the lifetime foundry last set; the value is {@link MCP_ACCESS_TOKEN_LIFETIME_SECONDS}. */
29
+ export declare const ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY = "access_token_lifetime_applied";
30
+ /** The broker's callback on the org's login host. It joins the entry app's redirect URIs. */
31
+ export declare const mcpCallbackUri: (org: DeclaredOrg) => string;
32
+ /**
33
+ * What an entry's MCP resource is, from the one recorded project id. The
34
+ * broker entry (`wantedBrokerEntry`) and the app's env (`entryAuthEnv`) both
35
+ * read it here, so the audience the broker checks and the audience the app
36
+ * compares are the same value. `null` without `mcp`, or until the project
37
+ * exists.
38
+ */
39
+ export declare function entryMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
40
+ resource: string;
41
+ projectId: string;
42
+ loginHost: string;
43
+ } | null;
22
44
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
23
45
  export declare function entryAuthEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
24
46
  /** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
@@ -32,6 +54,12 @@ export interface BrokerEntryWant {
32
54
  from: string;
33
55
  registration: boolean;
34
56
  replaces: string | null;
57
+ /** Present only for an entry with `uses.auth.mcp`: what the broker needs to be its authorization server. */
58
+ mcp?: {
59
+ resource: string;
60
+ projectId: string;
61
+ loginHost: string;
62
+ };
35
63
  }
36
64
  /**
37
65
  * Each value a broker entry needs and the step that records it, so the
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { isFoundryOwnedBrokerEntryName, } from '../org.js';
3
3
  import { entryClientIdScopeKey } from './agora.js';
4
4
  import { entryHostnames } from './hostnames.js';
5
+ import { loginHostnameFor } from './login-host.js';
5
6
  import { PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
6
7
  import { archonOrgConflicts, brokerDeployNeeded, brokerDeployRecordValue, missingBrokerInputs, validateBrokerAppEntry, } from './platform-providers.js';
7
8
  /**
@@ -28,6 +29,31 @@ export function brokerEntryAppliedScopeKey(entryKey) {
28
29
  export function brokerEntryWarningsScopeKey(entryKey) {
29
30
  return `broker_entry_warnings.${entryKey}`;
30
31
  }
32
+ /** The ZITADEL project id that is an MCP entry's token audience, recorded by `zitadel:mcp-project`. */
33
+ export const entryMcpProjectScopeKey = (entryKey) => `mcp_project_id.${entryKey}`;
34
+ /** The marker for the entry's app having JWT access tokens and the MCP callback (`zitadel:mcp-access`). */
35
+ export const entryMcpAccessAppliedScopeKey = (entryKey) => `mcp_access_applied.${entryKey}`;
36
+ /** The instance access-token lifetime the MCP flow wants, in seconds (auth ADR 0002, 4). */
37
+ export const MCP_ACCESS_TOKEN_LIFETIME_SECONDS = 3600;
38
+ /** Records the lifetime foundry last set; the value is {@link MCP_ACCESS_TOKEN_LIFETIME_SECONDS}. */
39
+ export const ACCESS_TOKEN_LIFETIME_APPLIED_SCOPE_KEY = 'access_token_lifetime_applied';
40
+ /** The broker's callback on the org's login host. It joins the entry app's redirect URIs. */
41
+ export const mcpCallbackUri = (org) => `https://${loginHostnameFor(org)}/mcp-oauth/callback`;
42
+ /**
43
+ * What an entry's MCP resource is, from the one recorded project id. The
44
+ * broker entry (`wantedBrokerEntry`) and the app's env (`entryAuthEnv`) both
45
+ * read it here, so the audience the broker checks and the audience the app
46
+ * compares are the same value. `null` without `mcp`, or until the project
47
+ * exists.
48
+ */
49
+ export function entryMcp(org, entry, recorded) {
50
+ const mcp = entry.uses?.auth?.mcp;
51
+ const projectId = recorded[entryMcpProjectScopeKey(entry.key)];
52
+ const host = entryHostnames(org, entry)[0];
53
+ if (!mcp || !projectId || !host)
54
+ return null;
55
+ return { resource: `https://${host}${mcp.path}`, projectId, loginHost: loginHostnameFor(org) };
56
+ }
31
57
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
32
58
  export function entryAuthEnv(org, entry, recorded, issuer) {
33
59
  const auth = entry.uses?.auth;
@@ -39,6 +65,15 @@ export function entryAuthEnv(org, entry, recorded, issuer) {
39
65
  AUTH_CLIENT_ID: recorded[entryClientIdScopeKey(entry.key)],
40
66
  AUTH_ISSUER: issuer ?? undefined,
41
67
  };
68
+ const mcp = entryMcp(org, entry, recorded);
69
+ const mcpValues = mcp
70
+ ? {
71
+ MCP_AUDIENCE: mcp.projectId,
72
+ MCP_RESOURCE: mcp.resource,
73
+ MCP_ISSUER: `https://${mcp.loginHost}`,
74
+ }
75
+ : {};
76
+ Object.assign(values, mcpValues);
42
77
  return Object.fromEntries(Object.entries(values).filter(([, value]) => Boolean(value)));
43
78
  }
44
79
  /** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
@@ -60,7 +95,11 @@ export function wantedBrokerEntry(org, entry, recorded) {
60
95
  missing.push({ what: 'its sign-in client id', producedBy: ['zitadel', 'app'] });
61
96
  if (!mailDomain)
62
97
  missing.push({ what: 'the org mail domain', producedBy: ['coolify', 'mail'] });
63
- if (!organizationId || !clientId || !mailDomain)
98
+ const mcp = entryMcp(org, entry, recorded);
99
+ if (auth.mcp && !mcp) {
100
+ missing.push({ what: 'its MCP audience project id', producedBy: ['zitadel', 'mcp-project'] });
101
+ }
102
+ if (!organizationId || !clientId || !mailDomain || (auth.mcp && !mcp))
64
103
  return { missing };
65
104
  return {
66
105
  want: {
@@ -71,6 +110,7 @@ export function wantedBrokerEntry(org, entry, recorded) {
71
110
  from: `${org.mail.noReplyLocalPart || 'noreply'}@${mailDomain}`,
72
111
  registration: auth.registration === 'open',
73
112
  replaces: auth.replacesBrokerEntry ?? null,
113
+ ...(mcp ? { mcp } : {}),
74
114
  },
75
115
  };
76
116
  }
@@ -182,6 +222,11 @@ export async function writeBrokerEntry(broker, input) {
182
222
  from: want.from,
183
223
  registration: want.registration,
184
224
  };
225
+ // The previous write's `mcp` goes when the entry no longer declares it.
226
+ if (want.mcp)
227
+ entry.mcp = want.mcp;
228
+ else
229
+ entry.mcp = undefined;
185
230
  try {
186
231
  validateBrokerAppEntry(entry, brokerAppId, 0);
187
232
  }
@@ -53,6 +53,8 @@ export interface PlanOrgOptions {
53
53
  loginHostServesDiscovery?: boolean;
54
54
  /** The executor's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`; without it `planIdTokenUserinfo` plans nothing. */
55
55
  zitadelIdTokenUserinfo?: boolean;
56
+ /** The executor's ZITADEL client has `ensureOidcAppMcpAccess` and `ensureAccessTokenLifetime`; without them `planMcp` is blocked. */
57
+ zitadelMcp?: boolean;
56
58
  /** F5: the executor was built with a `broker` port. */
57
59
  broker?: boolean;
58
60
  /** #161: the slugs of the other declared orgs, so a Boule whose hostname another org's slug would take is blocked. */
@@ -63,6 +65,14 @@ export interface PlanOrgOptions {
63
65
  archonUrl?: string | null;
64
66
  /** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
65
67
  edgeWorkers?: boolean;
68
+ /** ADR 0002: the executor was built with a `WorkerAppPort`, read by `planWorkerApps`. */
69
+ workerApps?: boolean;
70
+ /** ADR 0002: the keys service address, when the executor has a keys port. */
71
+ workerAppKeysUrl?: string;
72
+ /** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
73
+ workerAppDatabase?: boolean;
74
+ /** ADR 0002: the platform's Cloudflare account id; a container image must live in its registry. */
75
+ workerAppRegistryAccountId?: string;
66
76
  /** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
67
77
  ownerInviteMail?: boolean;
68
78
  /** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
@@ -73,6 +83,10 @@ export interface PlanOrgOptions {
73
83
  ownMailKeyHeld?: boolean;
74
84
  /** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
75
85
  ownMailKeyMintable?: boolean;
86
+ /** C3: the executor can mint a customers directory credential (`ZitadelPort.mintOrgDirectoryReader`). */
87
+ customersDirectory?: boolean;
88
+ /** C3: the executor can mint Boule's Archon credential (`FoundryStepExecutorPorts.mintBouleArchonCredential`). */
89
+ bouleArchonCredential?: boolean;
76
90
  /** The executor can store the sign-in database's CA and certificate (`FoundryStepExecutor.ownSigninDbKeysPuttable`). Absent: true. */
77
91
  ownSigninDbKeysPuttable?: boolean;
78
92
  /** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */