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