@wtfalch/foundry 0.52.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, BlueGreenConfig, OrgBuiltinApp, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
1
+ export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, BlueGreenConfig, OrgBuiltinApp, OrgRefusal, OrgRefusalCode, WorkerAppConfig, EntryGate, EntryVolume, HostingEntryUses, EntryAuth, EntryAuthRoom, EntryFiles, EntryAi, EnvValue, EnvHeldSecret, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, OrgServiceDatabase, OrgServiceSchema, ServicesConfig, SignInConfig, VersionPin, VersionPinPart, } from './org.js';
2
2
  export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, ORG_SERVICE_SCHEMAS, orgServiceDatabase, orgServiceDatabases, SFU_SLOT_MAX, VERSION_PIN_PARTS, isMainEntry, mainEntry, heldEnvRef, ownerMailboxes, ownsServices, validateBuyerIdentity, validateHosting, normalizeHostingRepos, validateMail, validateOwners, validateServices, validateCustomers, validateSignIn, validateVersionPin, validateSite, } from './org.js';
3
3
  export { refuseOrg } from './refuse-org.js';
4
4
  export { workerAppHasBuild } from './worker-app-config.js';
package/dist/org.d.ts CHANGED
@@ -289,7 +289,7 @@ export type AccessRule = {
289
289
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
290
290
  export declare const APP_ENTRY_KEY = "app";
291
291
  /** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
292
- export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'client_name_taken' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
292
+ export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
293
293
  /**
294
294
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
295
295
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -498,7 +498,32 @@ export interface EntryAuth {
498
498
  * `users: 'customers'`.
499
499
  */
500
500
  signIn?: 'password' | 'email-code';
501
+ /**
502
+ * The left-half text of the customers' sign-in window. Written on the
503
+ * customers organisation as metadata `wtfalch.room` (JSON, keys in the
504
+ * order below). Needs `users: 'customers'`; at most one entry per org
505
+ * declares it.
506
+ */
507
+ room?: EntryAuthRoom;
501
508
  }
509
+ export interface EntryAuthRoom {
510
+ name: string;
511
+ headline: string;
512
+ line: string;
513
+ title?: string;
514
+ description?: string;
515
+ steps?: number;
516
+ }
517
+ /** Longest value of each `uses.auth.room` text field. */
518
+ export declare const ROOM_TEXT_MAX: {
519
+ readonly name: 40;
520
+ readonly headline: 120;
521
+ readonly line: 200;
522
+ readonly title: 60;
523
+ readonly description: 200;
524
+ };
525
+ export declare const ROOM_STEPS_MIN = 2;
526
+ export declare const ROOM_STEPS_MAX = 9;
502
527
  /** `uses.auth.mcp.onboardingPath`: absolute, no query, no fragment, no dot segments. */
503
528
  export declare const ONBOARDING_PATH_MAX_LENGTH = 200;
504
529
  /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
package/dist/org.js CHANGED
@@ -15,6 +15,16 @@ function refusal(code, path, message) {
15
15
  }
16
16
  /** The most volumes one entry may declare. */
17
17
  export const MAX_ENTRY_VOLUMES = 8;
18
+ /** Longest value of each `uses.auth.room` text field. */
19
+ export const ROOM_TEXT_MAX = {
20
+ name: 40,
21
+ headline: 120,
22
+ line: 200,
23
+ title: 60,
24
+ description: 200,
25
+ };
26
+ export const ROOM_STEPS_MIN = 2;
27
+ export const ROOM_STEPS_MAX = 9;
18
28
  /** `uses.auth.mcp.onboardingPath`: absolute, no query, no fragment, no dot segments. */
19
29
  export const ONBOARDING_PATH_MAX_LENGTH = 200;
20
30
  /** The MCP endpoint path: absolute, plain characters, no dot segments, no query. */
@@ -216,6 +226,10 @@ export function refuseHosting(hosting, names) {
216
226
  if (mains.length > 1) {
217
227
  return refusal('hosting_main_count', 'hosting.entries', `at most one hosting entry may be the main app: ${mains.map((entry) => `'${entry.key}'`).join(' and ')} are`);
218
228
  }
229
+ const roomEntries = hosting.entries.filter((entry) => entry.uses?.auth?.room !== undefined);
230
+ if (roomEntries.length > 1) {
231
+ return refusal('auth_room_not_unique', 'hosting.entries', `at most one hosting entry may set uses.auth.room: ${roomEntries.map((entry) => `'${entry.key}'`).join(' and ')} do`);
232
+ }
219
233
  if (hosting.entries.filter((entry) => entry.uses?.ai?.webhookPath !== undefined).length > 1) {
220
234
  return refusal('ai_webhook_path_not_unique', 'hosting.entries', 'at most one hosting entry may set uses.ai webhookPath: ai keeps one webhook per organisation');
221
235
  }
@@ -649,6 +663,15 @@ function validateEntryAuth(entry, hosting, domain) {
649
663
  if (signIn === 'email-code' && auth.users !== 'customers') {
650
664
  return refusal('email_code_needs_customers', `hosting.entries.${entry.key}.uses.auth.signIn`, `${at} signIn 'email-code' needs users 'customers': only customers sign in with an emailed code`);
651
665
  }
666
+ if (auth.room !== undefined) {
667
+ if (auth.users !== 'customers') {
668
+ return refusal('auth_room_needs_customers', `hosting.entries.${entry.key}.uses.auth.room`, `${at} room needs users 'customers': only the customers' sign-in window shows it`);
669
+ }
670
+ const problem = roomProblem(auth.room);
671
+ if (problem) {
672
+ return refusal('auth_room_invalid', `hosting.entries.${entry.key}.uses.auth.room`, `${at} room ${problem}`);
673
+ }
674
+ }
652
675
  if (entry.key === 'agora' ||
653
676
  entry.key === 'boule' ||
654
677
  entry.key === 'cms' ||
@@ -657,6 +680,39 @@ function validateEntryAuth(entry, hosting, domain) {
657
680
  }
658
681
  return null;
659
682
  }
683
+ const ROOM_KEYS = [...Object.keys(ROOM_TEXT_MAX), 'steps'];
684
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the point is to refuse them
685
+ const ROOM_UNSAFE_TEXT = /[\u0000-\u001f\u007f<>]/;
686
+ /** A fixed sentence when `uses.auth.room` is not usable, `null` when it is. The value is never in it. */
687
+ function roomProblem(room) {
688
+ if (typeof room !== 'object' || room === null || Array.isArray(room)) {
689
+ return 'must be an object with name, headline and line';
690
+ }
691
+ const fields = room;
692
+ const unknown = Object.keys(fields).find((key) => !ROOM_KEYS.includes(key));
693
+ if (unknown !== undefined)
694
+ return `has an unknown key '${unknown}'`;
695
+ for (const [key, max] of Object.entries(ROOM_TEXT_MAX)) {
696
+ const value = fields[key];
697
+ if (value === undefined && (key === 'title' || key === 'description'))
698
+ continue;
699
+ if (typeof value !== 'string' ||
700
+ value.trim() === '' ||
701
+ value.length > max ||
702
+ ROOM_UNSAFE_TEXT.test(value)) {
703
+ return `${key} must be 1 to ${max} characters of plain text with no control character, '<' or '>'`;
704
+ }
705
+ }
706
+ const steps = fields.steps;
707
+ if (steps !== undefined &&
708
+ (typeof steps !== 'number' ||
709
+ !Number.isInteger(steps) ||
710
+ steps < ROOM_STEPS_MIN ||
711
+ steps > ROOM_STEPS_MAX)) {
712
+ return `steps must be a whole number from ${ROOM_STEPS_MIN} to ${ROOM_STEPS_MAX}`;
713
+ }
714
+ return null;
715
+ }
660
716
  /** With no domain the hostnames are not read (`entryHostnames`). */
661
717
  function hasExactHostname(entry, hosting, domain) {
662
718
  if (entry.workerApp)
@@ -1,4 +1,4 @@
1
- import { type DeclaredOrg, type OwnerIdentity } from '../org.js';
1
+ import { type DeclaredOrg, type EntryAuthRoom, type OwnerIdentity } from '../org.js';
2
2
  import type { BrokerEntryInput, BrokerEntryWant } from './broker-entries.js';
3
3
  /**
4
4
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
@@ -106,6 +106,12 @@ export declare const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = "customers_directory_u
106
106
  export declare const CUSTOMERS_SIGN_IN_METADATA_KEY = "wtfalch.signIn";
107
107
  /** `email-code` or `password`: the sign-in metadata last written to the customers organisation. Absent: never written. */
108
108
  export declare const CUSTOMERS_SIGN_IN_SCOPE_KEY = "zitadel_customers_signin";
109
+ /** The ZITADEL org metadata key that holds the left-half text of the customers' sign-in window. */
110
+ export declare const CUSTOMERS_ROOM_METADATA_KEY = "wtfalch.room";
111
+ /** The metadata value last written to the customers organisation, or empty after a removal. Absent: never written. */
112
+ export declare const CUSTOMERS_ROOM_SCOPE_KEY = "zitadel_customers_room";
113
+ /** The `wtfalch.room` value: JSON, keys in the order name, headline, line, title, description, steps. */
114
+ export declare function customersRoomValue(room: EntryAuthRoom): string;
109
115
  /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
110
116
  export declare const CUSTOMERS_SIGN_UP_SCOPE_KEY = "zitadel_customers_signup";
111
117
  /** Where the held-key store keeps the Archon credential of an org's Boule. */
@@ -120,6 +120,22 @@ export const CUSTOMERS_DIRECTORY_USER_SCOPE_KEY = 'customers_directory_user';
120
120
  export const CUSTOMERS_SIGN_IN_METADATA_KEY = 'wtfalch.signIn';
121
121
  /** `email-code` or `password`: the sign-in metadata last written to the customers organisation. Absent: never written. */
122
122
  export const CUSTOMERS_SIGN_IN_SCOPE_KEY = 'zitadel_customers_signin';
123
+ /** The ZITADEL org metadata key that holds the left-half text of the customers' sign-in window. */
124
+ export const CUSTOMERS_ROOM_METADATA_KEY = 'wtfalch.room';
125
+ /** The metadata value last written to the customers organisation, or empty after a removal. Absent: never written. */
126
+ export const CUSTOMERS_ROOM_SCOPE_KEY = 'zitadel_customers_room';
127
+ /** The `wtfalch.room` value: JSON, keys in the order name, headline, line, title, description, steps. */
128
+ export function customersRoomValue(room) {
129
+ const { name, headline, line, title, description, steps } = room;
130
+ return JSON.stringify({
131
+ name,
132
+ headline,
133
+ line,
134
+ ...(title !== undefined ? { title } : {}),
135
+ ...(description !== undefined ? { description } : {}),
136
+ ...(steps !== undefined ? { steps } : {}),
137
+ });
138
+ }
123
139
  /** `1` or `0`: the sign-up switch last written to the customers organisation's login policy. */
124
140
  export const CUSTOMERS_SIGN_UP_SCOPE_KEY = 'zitadel_customers_signup';
125
141
  /** Where the held-key store keeps the Archon credential of an org's Boule. */
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isFoundryOwnedBrokerEntryName, } from '../org.js';
3
3
  import { entryClientIdScopeKey } from './agora.js';
4
+ import { MCP_DATABASE_URL_ENV, MCP_OAUTH_SECRET_ENV } from './broker-mcp.js';
4
5
  import { entryHostnames } from './hostnames.js';
5
6
  import { loginHostnameFor } from './login-host.js';
6
7
  import { PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
@@ -185,7 +186,8 @@ export async function writeBrokerEntry(broker, input) {
185
186
  if (archonOrgConflicts([want], archonOrgId).length > 0) {
186
187
  throw new Error(`${label} would sign in Archon's own organisation, which no other entry may`);
187
188
  }
188
- const env = (await broker.listEnvs()).find((e) => e.key === 'AUTH_APPS' && !e.is_preview);
189
+ const brokerEnvs = await broker.listEnvs();
190
+ const env = brokerEnvs.find((e) => e.key === 'AUTH_APPS' && !e.is_preview);
189
191
  // A value that reads back empty is not an empty list: writing over it
190
192
  // would drop every hand-written entry.
191
193
  if (env && !env.value) {
@@ -275,8 +277,15 @@ export async function writeBrokerEntry(broker, input) {
275
277
  place();
276
278
  const nextRaw = JSON.stringify(next);
277
279
  const changed = JSON.stringify(current) !== nextRaw;
278
- if (changed)
280
+ if (changed) {
281
+ // Auth ADR 0002: a broker given an `mcp` entry without its database and secret exits at boot. The
282
+ // sign-in step sets both first; until it has, nothing is written and the next apply tries again.
283
+ const held = (key) => brokerEnvs.some((e) => e.key === key && !e.is_preview && e.value != null && e.value !== '');
284
+ if (want.mcp && !(held(MCP_DATABASE_URL_ENV) && held(MCP_OAUTH_SECRET_ENV))) {
285
+ throw new Error(`${label}: the broker has no ${MCP_DATABASE_URL_ENV} and ${MCP_OAUTH_SECRET_ENV} yet (the sign-in step sets them), so nothing was written; apply again`);
286
+ }
279
287
  await broker.setAuthApps(nextRaw);
288
+ }
280
289
  // The platform's own check of the broker's inputs also refuses a broker
281
290
  // whose AUTH_APPS holds an invalid entry; the broker itself only drops it,
282
291
  // so that one does not hold back the deploy.
@@ -0,0 +1,63 @@
1
+ import type { CoolifyPort } from './executor.js';
2
+ import { type JobRuntime } from './job-app.js';
3
+ /**
4
+ * Auth ADR 0002, sections 2 and 5: a broker whose `AUTH_APPS` holds an entry with `mcp` exits at boot
5
+ * without `MCP_DATABASE_URL` (a `broker` database on the sign-in Postgres) and `MCP_OAUTH_SECRET`
6
+ * (`deriveKeys` in `wtfalch/auth` broker/src/mcp-crypto.ts wants at least 32 characters). This file
7
+ * gives the broker both, from `provisionAuthStack`, before an entry with `mcp` can reach it.
8
+ */
9
+ export declare const MCP_DATABASE_URL_ENV = "MCP_DATABASE_URL";
10
+ export declare const MCP_OAUTH_SECRET_ENV = "MCP_OAUTH_SECRET";
11
+ /** The broker's own role and database on the sign-in Postgres. */
12
+ export declare const BROKER_DB_NAME = "broker";
13
+ export declare const BROKER_DB_USER = "broker";
14
+ /** 32 random bytes as 64 hex characters: twice the 32 characters `deriveKeys` asks for. */
15
+ export declare const MCP_OAUTH_SECRET_MIN_LENGTH = 32;
16
+ type Coolify = Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'findApplicationByName' | 'createDockerfileApplication' | 'updateApplication' | 'deploy' | 'getApplication' | 'listServers' | 'raw'>;
17
+ /**
18
+ * The job's script. Idempotent: a role or database that exists is kept, and the role's password is set
19
+ * to the one given. The password reaches SQL through `\getenv` and `format('%L')`, never through the
20
+ * shell, and psql's output is discarded so no line carries a URL or a password. `ADMIN_DATABASE_URL`
21
+ * is the sign-in database's own DSN; for the TLS database the CA is written to the path its
22
+ * `sslrootcert` names.
23
+ */
24
+ export declare function brokerDbJobScript(): string[];
25
+ /** The job: the sign-in database's pinned image, so `psql` matches the server's major version. */
26
+ export declare function brokerDbJobDockerfile(): string;
27
+ export interface BrokerMcpInput {
28
+ coolify: Coolify;
29
+ brokerAppId: string;
30
+ /** The job app's Coolify name. */
31
+ jobName: string;
32
+ project: {
33
+ uuid: string;
34
+ environment: {
35
+ id: number;
36
+ name: string;
37
+ uuid: string;
38
+ };
39
+ };
40
+ /** The sign-in database's own (superuser) DSN. A secret. */
41
+ adminUrl: string;
42
+ /** The CA PEM when the database is the TLS image; public. */
43
+ caPem: string | undefined;
44
+ /** The broker role's connection string for `password`. A secret. */
45
+ brokerUrl(password: string): string;
46
+ runtime: JobRuntime & {
47
+ onSecret?: (value: string) => void;
48
+ };
49
+ label: string;
50
+ }
51
+ /**
52
+ * The two env entries a broker with `mcp` entries needs, for the caller to write with the rest of the
53
+ * broker's env. A live value of either is kept (a hand-set one too), so a rerun makes nothing and the
54
+ * `broker` database job runs only while `MCP_DATABASE_URL` is unset. The secret is minted with
55
+ * `ensureGeneratedEnv`. The role's password is minted only when the URL is unset; a run that fails after
56
+ * the job and before the URL is written mints another and the job sets it, since nothing else uses the
57
+ * old one. Secrets go only to the job's and the broker's env and to `runtime.onSecret`.
58
+ */
59
+ export declare function ensureBrokerMcpEnv(input: BrokerMcpInput): Promise<Array<{
60
+ key: string;
61
+ value: string;
62
+ }>>;
63
+ export {};
@@ -0,0 +1,158 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { pinnedRef } from '../bootstrap/versions.js';
3
+ import { JobFailure, waitForJob } from './job-app.js';
4
+ import { currentEnvValue, ensureGeneratedEnv, resolvePlatformServer, } from './platform-providers.js';
5
+ import { SIGNIN_DB_CA_MOUNT, boundedCall } from './signin-db.js';
6
+ /**
7
+ * Auth ADR 0002, sections 2 and 5: a broker whose `AUTH_APPS` holds an entry with `mcp` exits at boot
8
+ * without `MCP_DATABASE_URL` (a `broker` database on the sign-in Postgres) and `MCP_OAUTH_SECRET`
9
+ * (`deriveKeys` in `wtfalch/auth` broker/src/mcp-crypto.ts wants at least 32 characters). This file
10
+ * gives the broker both, from `provisionAuthStack`, before an entry with `mcp` can reach it.
11
+ */
12
+ export const MCP_DATABASE_URL_ENV = 'MCP_DATABASE_URL';
13
+ export const MCP_OAUTH_SECRET_ENV = 'MCP_OAUTH_SECRET';
14
+ /** The broker's own role and database on the sign-in Postgres. */
15
+ export const BROKER_DB_NAME = 'broker';
16
+ export const BROKER_DB_USER = 'broker';
17
+ /** 32 random bytes as 64 hex characters: twice the 32 characters `deriveKeys` asks for. */
18
+ export const MCP_OAUTH_SECRET_MIN_LENGTH = 32;
19
+ const JOB_ADMIN_ENV = 'ADMIN_DATABASE_URL';
20
+ const JOB_PASSWORD_ENV = 'BROKER_DB_PASSWORD';
21
+ const JOB_CA_ENV = 'SIGNIN_DB_CA_B64';
22
+ const JOB_DONE_FILE = '/tmp/done';
23
+ const JOB_FAILED_FILE = '/tmp/failed';
24
+ /**
25
+ * The job's script. Idempotent: a role or database that exists is kept, and the role's password is set
26
+ * to the one given. The password reaches SQL through `\getenv` and `format('%L')`, never through the
27
+ * shell, and psql's output is discarded so no line carries a URL or a password. `ADMIN_DATABASE_URL`
28
+ * is the sign-in database's own DSN; for the TLS database the CA is written to the path its
29
+ * `sslrootcert` names.
30
+ */
31
+ export function brokerDbJobScript() {
32
+ return [
33
+ '#!/bin/sh',
34
+ 'set -eu',
35
+ `trap '[ -f ${JOB_DONE_FILE} ] || touch ${JOB_FAILED_FILE}' EXIT`,
36
+ "trap 'exit 1' HUP INT TERM",
37
+ `[ ! -f ${JOB_FAILED_FILE} ] || exit 1`,
38
+ `rm -f ${JOB_DONE_FILE}`,
39
+ 'fail() { echo "broker-db: failed"; exit 1; }',
40
+ `[ -n "\${${JOB_PASSWORD_ENV}:-}" ] || fail`,
41
+ `if [ -n "\${${JOB_CA_ENV}:-}" ]; then mkdir -p "$(dirname ${SIGNIN_DB_CA_MOUNT})"; printf %s "$${JOB_CA_ENV}" | base64 -d > ${SIGNIN_DB_CA_MOUNT}; fi`,
42
+ `psql "$${JOB_ADMIN_ENV}" -X -q -v ON_ERROR_STOP=1 > /dev/null 2>&1 <<'SQL' || fail`,
43
+ `\\getenv pw ${JOB_PASSWORD_ENV}`,
44
+ `SELECT 'CREATE ROLE ${BROKER_DB_USER} LOGIN' WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${BROKER_DB_USER}') \\gexec`,
45
+ `SELECT format('ALTER ROLE ${BROKER_DB_USER} PASSWORD %L', :'pw') \\gexec`,
46
+ `SELECT 'CREATE DATABASE ${BROKER_DB_NAME} OWNER ${BROKER_DB_USER} TEMPLATE template0 ENCODING ''UTF8''' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${BROKER_DB_NAME}') \\gexec`,
47
+ 'SQL',
48
+ 'echo "broker-db: ready"',
49
+ `touch ${JOB_DONE_FILE}`,
50
+ 'exec sleep infinity',
51
+ ];
52
+ }
53
+ /** The job: the sign-in database's pinned image, so `psql` matches the server's major version. */
54
+ export function brokerDbJobDockerfile() {
55
+ const encoded = Buffer.from(`${brokerDbJobScript().join('\n')}\n`, 'utf8').toString('base64');
56
+ return [
57
+ `FROM postgres:${pinnedRef('postgres')}`,
58
+ `RUN echo '${encoded}' | base64 -d > /usr/local/bin/broker-db && chmod 0755 /usr/local/bin/broker-db`,
59
+ 'ENTRYPOINT ["/usr/local/bin/broker-db"]',
60
+ `HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=1 CMD ["test", "-f", "${JOB_DONE_FILE}"]`,
61
+ ].join('\n');
62
+ }
63
+ /**
64
+ * The two env entries a broker with `mcp` entries needs, for the caller to write with the rest of the
65
+ * broker's env. A live value of either is kept (a hand-set one too), so a rerun makes nothing and the
66
+ * `broker` database job runs only while `MCP_DATABASE_URL` is unset. The secret is minted with
67
+ * `ensureGeneratedEnv`. The role's password is minted only when the URL is unset; a run that fails after
68
+ * the job and before the URL is written mints another and the job sets it, since nothing else uses the
69
+ * old one. Secrets go only to the job's and the broker's env and to `runtime.onSecret`.
70
+ */
71
+ export async function ensureBrokerMcpEnv(input) {
72
+ const { coolify, brokerAppId, runtime } = input;
73
+ let url = await currentEnvValue(coolify, brokerAppId, MCP_DATABASE_URL_ENV);
74
+ if (!url) {
75
+ const password = randomBytes(24).toString('base64url');
76
+ runtime.onSecret?.(password);
77
+ await runBrokerDbJob(input, password);
78
+ url = input.brokerUrl(password);
79
+ }
80
+ runtime.onSecret?.(url);
81
+ const minted = await ensureGeneratedEnv(coolify, brokerAppId, MCP_OAUTH_SECRET_ENV, () => randomBytes(32).toString('hex'), MCP_OAUTH_SECRET_MIN_LENGTH);
82
+ const secret = minted ?? (await currentEnvValue(coolify, brokerAppId, MCP_OAUTH_SECRET_ENV));
83
+ if (!secret) {
84
+ throw new Error(`${input.label}: the broker lost its ${MCP_OAUTH_SECRET_ENV} between two reads`);
85
+ }
86
+ runtime.onSecret?.(secret);
87
+ return [
88
+ { key: MCP_DATABASE_URL_ENV, value: url },
89
+ { key: MCP_OAUTH_SECRET_ENV, value: secret },
90
+ ];
91
+ }
92
+ /** Runs the job once, waits for it, deletes it. Every error is fixed text with the app id, never a cause. */
93
+ async function runBrokerDbJob(input, password) {
94
+ const { label, runtime } = input;
95
+ const coolify = input.coolify;
96
+ const { uuid: projectUuid, environment } = input.project;
97
+ let jobAppId;
98
+ try {
99
+ // A job left by an earlier run holds an old script and password: it is replaced, never reused.
100
+ const leftover = await boundedCall(coolify.findApplicationByName(input.jobName, { environmentIds: [environment.id] }), 'Coolify findApplicationByName');
101
+ if (leftover) {
102
+ await boundedCall(coolify.raw('DELETE', `/applications/${leftover.uuid}`), 'delete the job');
103
+ }
104
+ const server = await resolvePlatformServer(coolify);
105
+ const created = await boundedCall(coolify.createDockerfileApplication({
106
+ project_uuid: projectUuid,
107
+ server_uuid: server.uuid,
108
+ environment_name: environment.name,
109
+ environment_uuid: environment.uuid,
110
+ dockerfile: brokerDbJobDockerfile(),
111
+ name: input.jobName,
112
+ description: `makes the broker's MCP database (auth ADR 0002)`,
113
+ instant_deploy: false,
114
+ autogenerate_domain: false,
115
+ }), 'Coolify createDockerfileApplication');
116
+ jobAppId = created.uuid;
117
+ await coolify.updateApplication(jobAppId, { health_check_enabled: false });
118
+ await coolify.bulkUpsertEnvs(jobAppId, [
119
+ { key: JOB_ADMIN_ENV, value: input.adminUrl },
120
+ { key: JOB_PASSWORD_ENV, value: password },
121
+ ...(input.caPem
122
+ ? [{ key: JOB_CA_ENV, value: Buffer.from(input.caPem, 'utf8').toString('base64') }]
123
+ : []),
124
+ ]);
125
+ await coolify.deploy(jobAppId, true);
126
+ await waitForJob(coolify, jobAppId, runtime, {
127
+ job: "the broker's database job",
128
+ stopped: "the broker's database job stopped before it finished",
129
+ });
130
+ }
131
+ catch (error) {
132
+ const why = error instanceof JobFailure ? error.message : 'a Coolify call failed or timed out';
133
+ let cleaned = '';
134
+ if (jobAppId) {
135
+ const id = jobAppId;
136
+ try {
137
+ await boundedCall(coolify.raw('POST', `/applications/${id}/stop`), 'stop the job');
138
+ }
139
+ catch {
140
+ cleaned += '; the job app could not be stopped, stop it in Coolify';
141
+ }
142
+ try {
143
+ await boundedCall(coolify.bulkUpsertEnvs(id, [JOB_ADMIN_ENV, JOB_PASSWORD_ENV, JOB_CA_ENV].map((key) => ({ key, value: '' }))), 'clear the job env');
144
+ }
145
+ catch {
146
+ cleaned +=
147
+ '; its env could not be cleared, delete the app in Coolify (it holds the admin URL)';
148
+ }
149
+ }
150
+ throw new Error(`${label}: ${why}${jobAppId ? `; the job app ${jobAppId} is left stopped without secrets and the next apply replaces it${cleaned}` : ''}; apply again`);
151
+ }
152
+ try {
153
+ await boundedCall(coolify.raw('DELETE', `/applications/${jobAppId}`), 'delete the job');
154
+ }
155
+ catch {
156
+ throw new Error(`${label}: the broker's database job ran, but its app ${jobAppId} could not be deleted; delete it in Coolify (it holds the admin URL), then apply again`);
157
+ }
158
+ }
@@ -270,3 +270,15 @@ export declare const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = "jobs_registratio
270
270
  * {@link effectiveAiLimitMicros} is the one place that substitutes it.
271
271
  */
272
272
  export declare const DEFAULT_AI_LIMIT_MICROS = "112250000";
273
+ /**
274
+ * F5: one broker `AUTH_APPS` entry per entry with `uses.auth`. Idempotent on
275
+ * the recorded marker of what the last write said (ids, names, origins; never
276
+ * the key), so a rerun plans nothing. The values it needs come from steps
277
+ * that run earlier; one that nothing records and no step in this plan will
278
+ * record blocks it (rule 2). A foreign broker entry the last write found
279
+ * invalid is listed as a warning: the broker drops it, foundry keeps it.
280
+ */
281
+ export declare function planBrokerEntries(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], brokerWired: boolean): {
282
+ steps: DraftStep[];
283
+ blocked: UnmanagedItem[];
284
+ };
package/dist/plan/diff.js CHANGED
@@ -3,7 +3,7 @@ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, held
3
3
  import { entryHasDatabase } from '../worker-app-config.js';
4
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';
5
5
  import { planBlueGreen } from './blue-green.js';
6
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, desiredBouleEnv, planBouleOwners, staleCustomersEnv, 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_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, customersRoomValue, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
8
8
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
9
9
  import { planCompanyBackups } from './company-backups.js';
@@ -28,7 +28,7 @@ import { legacyUnmigrated, orgPostgresUndeliveredScopeKey, planOrgPostgresSteps,
28
28
  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';
29
29
  import { ownDnsHostnames } from './own-dns.js';
30
30
  import { ownHostnames } from './own-hostnames.js';
31
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
31
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSecretAvailability, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
32
32
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
33
33
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
34
34
  import { companyAppPostLogoutUris, postLogoutUpdateOwed, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
@@ -3955,7 +3955,7 @@ function planEntryClients(org, byKey, claimed) {
3955
3955
  * record blocks it (rule 2). A foreign broker entry the last write found
3956
3956
  * invalid is listed as a warning: the broker drops it, foundry keeps it.
3957
3957
  */
3958
- function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
3958
+ export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
3959
3959
  const steps = [];
3960
3960
  const blocked = [];
3961
3961
  for (const entry of org.hosting.entries) {
@@ -3978,6 +3978,16 @@ function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired) {
3978
3978
  });
3979
3979
  continue;
3980
3980
  }
3981
+ // Auth ADR 0002: a broker given an `mcp` entry before it holds its database and secret exits at boot.
3982
+ // A company's own sign-in step sets them and records it; a shared org's broker is the platform's, whose
3983
+ // marker this plan cannot see, so `writeBrokerEntry` refuses there until the platform step has run.
3984
+ if (auth.mcp && ownsServices(org) && recordedIds[PLATFORM_AUTH_MCP_SCOPE_KEY] !== '1') {
3985
+ blocked.push({
3986
+ description: `${at} is blocked, not planned: the broker's MCP database and secret are not set yet (the sign-in step sets them)`,
3987
+ key,
3988
+ });
3989
+ continue;
3990
+ }
3981
3991
  // A Worker app has no Coolify app to hold the broker key: its generated AUTH_APP_KEY
3982
3992
  // is delivered to the Worker first and read back from the held-key store.
3983
3993
  if (entry.workerApp && !brokerKeyDelivered(recordedIds, entry.key)) {
@@ -4102,13 +4112,16 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
4102
4112
  const wantedSignIn = wanted && org.hosting.entries.some((entry) => entry.uses?.auth?.signIn === 'email-code')
4103
4113
  ? 'email-code'
4104
4114
  : '';
4115
+ const room = org.hosting.entries.find((entry) => entry.uses?.auth?.room)?.uses?.auth?.room;
4116
+ const wantedRoom = room ? customersRoomValue(room) : '';
4105
4117
  const blocked = [];
4106
4118
  const customersOrgId = recordedIds[resourceKey('zitadel', 'customers', org.id)];
4107
4119
  if (!customersOrgId) {
4108
4120
  if (!wanted)
4109
4121
  return { step: null, blocked };
4110
4122
  const signIn = wantedSignIn === 'email-code' && metadataWired ? { signIn: wantedSignIn } : {};
4111
- if (wantedSignIn === 'email-code' && !metadataWired) {
4123
+ const roomDesired = wantedRoom && metadataWired ? { room: wantedRoom } : {};
4124
+ if ((wantedSignIn === 'email-code' || wantedRoom) && !metadataWired) {
4112
4125
  blocked.push(signInMetadataBlocked(org));
4113
4126
  }
4114
4127
  return {
@@ -4117,7 +4130,7 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
4117
4130
  system: 'zitadel',
4118
4131
  kind: 'customers',
4119
4132
  key: org.id,
4120
- desired: { signUp, ...signIn },
4133
+ desired: { signUp, ...signIn, ...roomDesired },
4121
4134
  },
4122
4135
  step: {
4123
4136
  description: `provision a ZITADEL org for ${org.slug}'s customers, with self-registration ${signUp ? 'on' : 'off'}${signIn.signIn ? ' and sign-in by emailed code' : ''}`,
@@ -4141,10 +4154,12 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
4141
4154
  const wantedSignUp = wanted && signUp ? '1' : '0';
4142
4155
  const appliedSignIn = recordedIds[CUSTOMERS_SIGN_IN_SCOPE_KEY] === 'email-code' ? 'email-code' : '';
4143
4156
  const signInDiffers = appliedSignIn !== wantedSignIn;
4144
- if (signInDiffers && !metadataWired)
4157
+ const roomDiffers = (recordedIds[CUSTOMERS_ROOM_SCOPE_KEY] ?? '') !== wantedRoom;
4158
+ if ((signInDiffers || roomDiffers) && !metadataWired)
4145
4159
  blocked.push(signInMetadataBlocked(org));
4146
4160
  const writeSignIn = signInDiffers && metadataWired;
4147
- if (applied === wantedSignUp && !writeSignIn)
4161
+ const writeRoom = roomDiffers && metadataWired;
4162
+ if (applied === wantedSignUp && !writeSignIn && !writeRoom)
4148
4163
  return { step: null, blocked };
4149
4164
  return {
4150
4165
  step: {
@@ -4155,11 +4170,14 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
4155
4170
  desired: {
4156
4171
  signUp: wantedSignUp === '1',
4157
4172
  ...(writeSignIn ? { signIn: wantedSignIn } : {}),
4173
+ ...(writeRoom ? { room: wantedRoom } : {}),
4158
4174
  },
4159
4175
  },
4160
4176
  step: {
4161
4177
  description: applied === wantedSignUp
4162
- ? `${wantedSignIn ? 'set' : 'remove'} the emailed-code sign-in metadata of ${org.slug}'s customers`
4178
+ ? writeSignIn
4179
+ ? `${wantedSignIn ? 'set' : 'remove'} the emailed-code sign-in metadata of ${org.slug}'s customers`
4180
+ : `${wantedRoom ? 'set' : 'remove'} the sign-in window text of ${org.slug}'s customers`
4163
4181
  : `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
4164
4182
  destroys: false,
4165
4183
  migrates: false,
@@ -2313,6 +2313,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2313
2313
  * a re-run after a crash finishes the job.
2314
2314
  */
2315
2315
  private zitadelCustomers;
2316
+ /**
2317
+ * `wtfalch.room` on the customers organisation: the planner's value (JSON)
2318
+ * while an entry declares a room, removed when none does. The planner puts
2319
+ * `room` in the step only when the recorded value differs. Returns the value
2320
+ * to record (empty after a removal), or null for no change.
2321
+ */
2322
+ private writeCustomersRoomMetadata;
2316
2323
  /**
2317
2324
  * `wtfalch.signIn = email-code` on the customers organisation while an entry
2318
2325
  * declares it, removed when none does. The planner puts `signIn` in the step
@@ -10,7 +10,7 @@ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID
10
10
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, } from './blue-green-apply.js';
11
11
  import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, splitServers, } from './blue-green.js';
12
12
  import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
13
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
13
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
14
14
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, mcpCallbackUri, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
15
15
  import { recordBuiltCommit } from './build-record.js';
16
16
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
@@ -50,7 +50,7 @@ import { LIMITS, limited } from './own-limits.js';
50
50
  import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, scopeFromOrg, } from './own-services.js';
51
51
  import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
52
52
  import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
53
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
53
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
54
54
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
55
55
  import { companyAppPostLogoutUris, postLogoutAppliedValue, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
56
56
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
@@ -2487,13 +2487,47 @@ export class FoundryStepExecutor {
2487
2487
  else
2488
2488
  await this.ports.zitadel.closeSelfRegistration(customers.id);
2489
2489
  await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
2490
- // The sign-in metadata comes before either marker: a failed write leaves both markers behind, so the next plan repeats the step.
2490
+ // The sign-in and room metadata come before every marker: a failed write leaves both markers behind, so the next plan repeats the step.
2491
2491
  const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context);
2492
+ const room = await this.writeCustomersRoomMetadata(org, customers.id, action, context);
2492
2493
  context.recordScopeId(CUSTOMERS_SIGN_UP_SCOPE_KEY, signUp ? '1' : '0');
2493
2494
  if (signIn)
2494
2495
  context.recordScopeId(CUSTOMERS_SIGN_IN_SCOPE_KEY, signIn);
2496
+ if (room !== null)
2497
+ context.recordScopeId(CUSTOMERS_ROOM_SCOPE_KEY, room);
2495
2498
  return { resourceId: customers.id };
2496
2499
  }
2500
+ /**
2501
+ * `wtfalch.room` on the customers organisation: the planner's value (JSON)
2502
+ * while an entry declares a room, removed when none does. The planner puts
2503
+ * `room` in the step only when the recorded value differs. Returns the value
2504
+ * to record (empty after a removal), or null for no change.
2505
+ */
2506
+ async writeCustomersRoomMetadata(org, customersOrgId, action, context) {
2507
+ const want = action.desired.room;
2508
+ if (want === undefined)
2509
+ return null;
2510
+ if (typeof want !== 'string') {
2511
+ throw new Error(`zitadel:customers for ${org.slug}: the planned sign-in window text is invalid`);
2512
+ }
2513
+ const { setOrgMetadata, removeOrgMetadata } = this.ports.zitadel;
2514
+ if (!setOrgMetadata || !removeOrgMetadata) {
2515
+ throw new Error(`zitadel:customers for ${org.slug}: the ZITADEL client cannot set organisation metadata`);
2516
+ }
2517
+ try {
2518
+ if (want !== '') {
2519
+ await setOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_ROOM_METADATA_KEY, want);
2520
+ return want;
2521
+ }
2522
+ if (!context.recordedIds()[CUSTOMERS_ROOM_SCOPE_KEY])
2523
+ return null;
2524
+ await removeOrgMetadata.call(this.ports.zitadel, customersOrgId, CUSTOMERS_ROOM_METADATA_KEY);
2525
+ return '';
2526
+ }
2527
+ catch {
2528
+ throw new Error(`zitadel:customers for ${org.slug}: writing the sign-in window text of organisation ${customersOrgId} failed`);
2529
+ }
2530
+ }
2497
2531
  /**
2498
2532
  * `wtfalch.signIn = email-code` on the customers organisation while an entry
2499
2533
  * declares it, removed when none does. The planner puts `signIn` in the step
@@ -7285,6 +7319,7 @@ export class FoundryStepExecutor {
7285
7319
  // In-app sign-in: the company's own stack gets the second login app. Its host table starts
7286
7320
  // empty; the login-host step writes it once the apps' clients exist.
7287
7321
  ...(inAppSignIn(org) ? { signInLogin: { bindings: null } } : {}),
7322
+ mcp: orgDeclaresMcp(org),
7288
7323
  });
7289
7324
  if (refChanged) {
7290
7325
  const now = scope.recordedIds();
@@ -10,7 +10,7 @@ import { mailStoreIsLegacy, mailStoreSwitchDescription } from './mail-store.js';
10
10
  import { ownHostnames } from './own-hostnames.js';
11
11
  import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
12
12
  import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_NODE_IP_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
13
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
13
+ import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, orgDeclaresMcp, platformMailMxHost, } from './platform-diff.js';
14
14
  import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
15
15
  import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
16
16
  import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn } from './sign-in.js';
@@ -162,6 +162,9 @@ export function planOwnAuth(options) {
162
162
  recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] &&
163
163
  recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
164
164
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
165
+ recordedIds[PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY] === AUTH_TUNNEL_ROUTES_VERSION &&
166
+ // An entry with `mcp` needs the broker's database and secret (auth ADR 0002).
167
+ (!orgDeclaresMcp(org) || recordedIds[PLATFORM_AUTH_MCP_SCOPE_KEY] === '1') &&
165
168
  (Boolean(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) || !brokerHoldsAnEntry) &&
166
169
  // In-app sign-in: the second login app is recorded once it is deployed and holds the token, which
167
170
  // needs the login client; until the broker holds an entry that cannot be, so the step waits.
@@ -60,6 +60,22 @@ export declare const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = "auth_z
60
60
  export declare const AUTH_ZITADEL_RUN_OPTIONS = "--entrypoint \"/app/zitadel start-from-init --masterkeyFromEnv --tlsMode external\"";
61
61
  /** The run options last applied to the ZITADEL app (value: the options string), so a changed value forces one redeploy. */
62
62
  export declare const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = "zitadel_platform_auth_run_options_applied";
63
+ /**
64
+ * The version of the sign-in tunnel's path rules (`provisionAuthStack`). Raise it when a rule
65
+ * changes, so an applied sign-in plans its step once more and the tunnel is written again.
66
+ * 2: the broker's MCP authorization paths (auth ADR 0002).
67
+ */
68
+ export declare const AUTH_TUNNEL_ROUTES_VERSION = "2";
69
+ /** The {@link AUTH_TUNNEL_ROUTES_VERSION} the sign-in tunnel was last written with. */
70
+ export declare const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = "auth_tunnel_routes_applied";
71
+ /**
72
+ * `'1'` once the broker holds `MCP_DATABASE_URL` and `MCP_OAUTH_SECRET` (auth ADR 0002), recorded by the
73
+ * sign-in step when an entry of that broker declares `uses.auth.mcp`. A broker entry with `mcp` is not
74
+ * written before it, and an applied sign-in that wants it and lacks it plans its step again.
75
+ */
76
+ export declare const PLATFORM_AUTH_MCP_SCOPE_KEY = "auth_mcp_env_applied";
77
+ /** True when a hosting entry of the org declares `uses.auth.mcp`: its broker needs the MCP database and secret. */
78
+ export declare function orgDeclaresMcp(org: Pick<DeclaredOrg, 'hosting'>): boolean;
63
79
  /**
64
80
  * The `gitRef` the platform's login, in-app login and broker apps were last built from (value: the ref). A
65
81
  * different declared ref plans the auth step again: it patches `git_branch` on those three apps and
@@ -65,6 +65,24 @@ export const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = 'auth_zitadel_a
65
65
  export const AUTH_ZITADEL_RUN_OPTIONS = '--entrypoint "/app/zitadel start-from-init --masterkeyFromEnv --tlsMode external"';
66
66
  /** The run options last applied to the ZITADEL app (value: the options string), so a changed value forces one redeploy. */
67
67
  export const PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY = 'zitadel_platform_auth_run_options_applied';
68
+ /**
69
+ * The version of the sign-in tunnel's path rules (`provisionAuthStack`). Raise it when a rule
70
+ * changes, so an applied sign-in plans its step once more and the tunnel is written again.
71
+ * 2: the broker's MCP authorization paths (auth ADR 0002).
72
+ */
73
+ export const AUTH_TUNNEL_ROUTES_VERSION = '2';
74
+ /** The {@link AUTH_TUNNEL_ROUTES_VERSION} the sign-in tunnel was last written with. */
75
+ export const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = 'auth_tunnel_routes_applied';
76
+ /**
77
+ * `'1'` once the broker holds `MCP_DATABASE_URL` and `MCP_OAUTH_SECRET` (auth ADR 0002), recorded by the
78
+ * sign-in step when an entry of that broker declares `uses.auth.mcp`. A broker entry with `mcp` is not
79
+ * written before it, and an applied sign-in that wants it and lacks it plans its step again.
80
+ */
81
+ export const PLATFORM_AUTH_MCP_SCOPE_KEY = 'auth_mcp_env_applied';
82
+ /** True when a hosting entry of the org declares `uses.auth.mcp`: its broker needs the MCP database and secret. */
83
+ export function orgDeclaresMcp(org) {
84
+ return org.hosting.enabled && org.hosting.entries.some((e) => Boolean(e.uses?.auth?.mcp));
85
+ }
68
86
  /**
69
87
  * The `gitRef` the platform's login, in-app login and broker apps were last built from (value: the ref). A
70
88
  * different declared ref plans the auth step again: it patches `git_branch` on those three apps and
@@ -2127,6 +2145,7 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
2127
2145
  // The ZITADEL run options reach the container only through the auth
2128
2146
  // step's redeploy, so a platform recorded before them still plans it.
2129
2147
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
2148
+ recordedIds[PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY] === AUTH_TUNNEL_ROUTES_VERSION &&
2130
2149
  recordedIds[PLATFORM_AUTH_APPLIED_GIT_REF_SCOPE_KEY] === platform.auth.gitRef) {
2131
2150
  return { step: null, blocked: standingNote };
2132
2151
  }
@@ -432,6 +432,11 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
432
432
  * (or the runner was never applied).
433
433
  */
434
434
  refreshFilesShare(_platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<Record<string, string> | null>;
435
+ /**
436
+ * True while a shared org declares `uses.auth.mcp`: its entry goes to the platform's broker, which then
437
+ * needs the MCP database and secret (auth ADR 0002). An own org's entry goes to its own broker.
438
+ */
439
+ private mcpWanted;
435
440
  /** True while a company signs in on its apps' own addresses through the platform's login. */
436
441
  private signInLoginWanted;
437
442
  /**
@@ -510,6 +515,11 @@ export declare const AUTH_LOGIN_PORT = "3000";
510
515
  * everything else, including the root).
511
516
  */
512
517
  export declare const AUTH_ZITADEL_PATH_PATTERN = "^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known|v2|v2beta|zitadel\\.[A-Za-z0-9.]+)(/|$)";
518
+ /**
519
+ * The broker's MCP authorization server (auth ADR 0002): its metadata and `/mcp-oauth/*`.
520
+ * Checked before {@link AUTH_ZITADEL_PATH_PATTERN}, which takes every other `/.well-known` path.
521
+ */
522
+ export declare const AUTH_BROKER_MCP_PATH_PATTERN = "^/(\\.well-known/oauth-authorization-server|mcp-oauth/.+)$";
513
523
  export declare const PLATFORM_AUTH_DB_SCOPE_KEY = "coolify_platform_auth_database";
514
524
  export declare const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = "coolify_platform_auth_zitadel_app";
515
525
  export declare const PLATFORM_AUTH_LOGIN_SCOPE_KEY = "coolify_platform_auth_login_app";
@@ -549,6 +559,11 @@ export interface AuthStackInput {
549
559
  signInLogin?: {
550
560
  bindings: string | null;
551
561
  };
562
+ /**
563
+ * An entry of this stack's broker declares `uses.auth.mcp` (auth ADR 0002): the broker needs a `broker`
564
+ * database on the sign-in Postgres and `MCP_OAUTH_SECRET` before that entry reaches it. Absent: neither.
565
+ */
566
+ mcp?: boolean;
552
567
  }
553
568
  /**
554
569
  * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
@@ -1312,6 +1327,33 @@ export declare function envsDiffer(coolify: Pick<CoolifyPort, 'listEnvs'>, appId
1312
1327
  key: string;
1313
1328
  value: string;
1314
1329
  }>): Promise<boolean>;
1330
+ /**
1331
+ * `null` once the app already has this key set -- generated only the first
1332
+ * time, never regenerated or overwritten, mirroring `executor.ts`'s
1333
+ * `ensureAuthCookieSecret` exactly. #211 round 4: `''`, `null` and
1334
+ * `undefined` all count as not set, the same "missing" a live Coolify
1335
+ * response can carry despite its declared `string` type
1336
+ * (`CoolifyPort.listEnvs`'s own doc comment) -- an entry present with one of
1337
+ * those values used to read as "already has this key set" and return `null`,
1338
+ * so a caller reading the value back afterwards (`provisionPlatformArchon`'s
1339
+ * own `AUTH_APP_KEY`) threw "lost its ... between two reads" on every single
1340
+ * apply instead of regenerating it.
1341
+ *
1342
+ * #211 round 5: `minLength`, when given, also counts a *present, non-empty*
1343
+ * value shorter than it as not set -- the broker's own `parseApps`
1344
+ * (`wtfalch/auth/broker/src/apps.ts`) refuses a `key` under 32 characters,
1345
+ * and this package minted a 32-character `AUTH_APP_KEY` from the start
1346
+ * (`provisionPlatformArchon`'s own comment on `randomBytes(32)`), so this
1347
+ * only ever changes behaviour for a key that reached the app some other way
1348
+ * -- a hand-set value, or one carried over from before that guarantee held.
1349
+ * Optional, and unused by every other call site (`ensureMasterkey`,
1350
+ * `ZITADEL_ADMIN_PASSWORD`, `AUTH_COOKIE_SECRET`, this file's recovery admin
1351
+ * password): none of those have a broker-enforced minimum, so widening this
1352
+ * check to them would reject a value that has always been fine.
1353
+ */
1354
+ export declare function ensureGeneratedEnv(coolify: Pick<CoolifyPort, 'listEnvs'>, appId: string, key: string, generate: () => string, minLength?: number): Promise<string | null>;
1355
+ /** The live value of `key` on `appId`, or `undefined` when unset -- `ensureGeneratedEnv`'s read half, for a caller that needs the value it already found. */
1356
+ export declare function currentEnvValue(coolify: Pick<CoolifyPort, 'listEnvs'>, appId: string, key: string): Promise<string | undefined>;
1315
1357
  /**
1316
1358
  * #171: everything {@link provisionPlatformFiles} needs beyond
1317
1359
  * {@link PlatformStepExecutionContext}, bundled for the same reason as
@@ -3,7 +3,7 @@ import { isIPv4 } from 'node:net';
3
3
  import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
4
4
  import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
- import { ownerMailboxes, sharedSecretRef, } from '../org.js';
6
+ import { ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
7
7
  import { applyArchonBlueGreen } from './archon-blue-green.js';
8
8
  import { ARCHON_REPO, archonSubject, healthTokenName, tunnelOriginFor, } from './blue-green.js';
9
9
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
@@ -21,6 +21,7 @@ import { LIMITS, limited } from './own-limits.js';
21
21
  import { postLogoutAppliedValue, postLogoutUrisFor } from './post-logout-uris.js';
22
22
  import { createReleaseHost } from './release-host.js';
23
23
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
24
+ import { BROKER_DB_NAME, BROKER_DB_USER, ensureBrokerMcpEnv } from './broker-mcp.js';
24
25
  import { SIGN_IN_BIND_KEY_ENV, SIGN_IN_BIND_KEY_MIN_LENGTH, SIGN_IN_BIND_KEY_SCOPE_KEY, } from './cookie-domain.js';
25
26
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
26
27
  import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
@@ -28,13 +29,13 @@ import { MAIL_APP_SCOPE_KEY, MAIL_DATABASE_NAME, MAIL_DATABASE_PASSWORD_ENV, MAI
28
29
  import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
29
30
  import { recordOwnDnsHostname } from './own-dns.js';
30
31
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
31
- import { AUTH_ZITADEL_RUN_OPTIONS, GITHUB_RELEASE_HOOK_DEPS_MISSING, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_DESKTOP_CALLBACK_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_POST_LOGOUT_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_GITHUB_RELEASE_HOOK_KEY, PLATFORM_HC_ACCESS_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROLE_CHECK_SCOPE_KEY, PLATFORM_MAIL_ROLE_ENSURED_SCOPE_KEY, PLATFORM_MAIL_ROLE_GRANTED_SCOPE_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_USER_ROLE, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
32
+ import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, GITHUB_RELEASE_HOOK_DEPS_MISSING, GITHUB_RELEASE_WEBHOOK_SECRET_ENV, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_DESKTOP_CALLBACK_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_POST_LOGOUT_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_GITHUB_RELEASE_HOOK_KEY, PLATFORM_HC_ACCESS_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROLE_CHECK_SCOPE_KEY, PLATFORM_MAIL_ROLE_ENSURED_SCOPE_KEY, PLATFORM_MAIL_ROLE_GRANTED_SCOPE_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_USER_ROLE, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, orgDeclaresMcp, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
32
33
  import { PLATFORM_ARCHON_IN_APP_SCOPE_KEY, archonInAppBack, archonInAppBind, archonInAppHost, archonInAppPoint, archonInAppUnbind, } from './sign-in-archon.js';
33
34
  import { LOGIN_HOST_BINDINGS_ENV } from './sign-in-bindings.js';
34
35
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
35
36
  import { SIGN_IN_BASE_PATH, SIGN_IN_BUILD_ARG_ENV, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppOnSharedLogin, signInLoginNames, signInUpstream, } from './sign-in.js';
36
37
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
37
- import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
38
+ import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, signinDbDsn, waitHealthy, withCallTimeout, } from './signin-db.js';
38
39
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
39
40
  import { githubReleaseHookPlanInputs, provisionGithubReleaseHook, } from './github-release-hook.js';
40
41
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
@@ -723,7 +724,11 @@ export class FoundryPlatformStepExecutor {
723
724
  }
724
725
  if (platform.auth.enabled && this.auth) {
725
726
  const loginClientDrifted = await authLoginClientDrifted(platform, this.auth, recordedIds);
726
- if (loginClientDrifted || (await this.signInLoginDrifted(this.auth, recordedIds))) {
727
+ // The broker lacks the MCP database and secret a shared org's entry needs (auth ADR 0002).
728
+ const mcpDrifted = recordedIds[PLATFORM_AUTH_MCP_SCOPE_KEY] !== '1' && (await this.mcpWanted());
729
+ if (loginClientDrifted ||
730
+ mcpDrifted ||
731
+ (await this.signInLoginDrifted(this.auth, recordedIds))) {
727
732
  result.auth = true;
728
733
  }
729
734
  if (await authAppsNotRunning(this.auth.coolify, recordedIds))
@@ -785,6 +790,13 @@ export class FoundryPlatformStepExecutor {
785
790
  await coolify.deploy(runnerAppId);
786
791
  return { [PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY]: normalHosts(wanted).join(',') };
787
792
  }
793
+ /**
794
+ * True while a shared org declares `uses.auth.mcp`: its entry goes to the platform's broker, which then
795
+ * needs the MCP database and secret (auth ADR 0002). An own org's entry goes to its own broker.
796
+ */
797
+ async mcpWanted() {
798
+ return (await this.declaredOrgStore.list()).some((org) => !ownsServices(org) && orgDeclaresMcp(org));
799
+ }
788
800
  /** True while a company signs in on its apps' own addresses through the platform's login. */
789
801
  async signInLoginWanted() {
790
802
  return (await this.declaredOrgStore.list()).some(inAppOnSharedLogin);
@@ -826,7 +838,11 @@ export class FoundryPlatformStepExecutor {
826
838
  throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
827
839
  }
828
840
  const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
829
- return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), { ownerCompany: company, signInLogin: await this.signInLoginWanted() });
841
+ return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), {
842
+ ownerCompany: company,
843
+ signInLogin: await this.signInLoginWanted(),
844
+ mcp: await this.mcpWanted(),
845
+ });
830
846
  }
831
847
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
832
848
  if (!this.files) {
@@ -1317,6 +1333,11 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
1317
1333
  * than folded into it.
1318
1334
  */
1319
1335
  const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
1336
+ /**
1337
+ * The broker's MCP authorization server (auth ADR 0002): its metadata and `/mcp-oauth/*`.
1338
+ * Checked before {@link AUTH_ZITADEL_PATH_PATTERN}, which takes every other `/.well-known` path.
1339
+ */
1340
+ export const AUTH_BROKER_MCP_PATH_PATTERN = '^/(\\.well-known/oauth-authorization-server|mcp-oauth/.+)$';
1320
1341
  const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
1321
1342
  export const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
1322
1343
  export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
@@ -1666,6 +1687,8 @@ export async function provisionAuthStack(scope, deps, target, input) {
1666
1687
  recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
1667
1688
  let databaseUrl;
1668
1689
  let signinDbCa;
1690
+ /** The broker role's connection string on the sign-in Postgres, for a password (auth ADR 0002). */
1691
+ let brokerMcpUrl;
1669
1692
  if (tlsDatabase) {
1670
1693
  const provisioned = await provisionSigninDb({
1671
1694
  coolify: deps.coolify,
@@ -1691,6 +1714,10 @@ export async function provisionAuthStack(scope, deps, target, input) {
1691
1714
  });
1692
1715
  databaseUrl = provisioned.dsn;
1693
1716
  signinDbCa = provisioned.ca;
1717
+ brokerMcpUrl = (password) => signinDbDsn(target.internal.signinDb, password, {
1718
+ user: BROKER_DB_USER,
1719
+ database: BROKER_DB_NAME,
1720
+ });
1694
1721
  await record(SIGNIN_DB_TLS_SCOPE_KEY, provisioned.appId);
1695
1722
  }
1696
1723
  else {
@@ -1717,6 +1744,14 @@ export async function provisionAuthStack(scope, deps, target, input) {
1717
1744
  throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
1718
1745
  }
1719
1746
  databaseUrl = db.internal_db_url;
1747
+ const legacyUrl = db.internal_db_url;
1748
+ brokerMcpUrl = (password) => {
1749
+ const url = new URL(legacyUrl);
1750
+ url.username = BROKER_DB_USER;
1751
+ url.password = password;
1752
+ url.pathname = `/${BROKER_DB_NAME}`;
1753
+ return url.toString();
1754
+ };
1720
1755
  }
1721
1756
  // ---- the ZITADEL app itself ---------------------------------------------
1722
1757
  const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
@@ -1953,7 +1988,26 @@ export async function provisionAuthStack(scope, deps, target, input) {
1953
1988
  // is, once held (#210): pushing it costs nothing even while the broker
1954
1989
  // still cannot start on the other two, and the deploy decision below stays
1955
1990
  // gated on `missingBrokerInputs` regardless.
1991
+ // Auth ADR 0002: a broker with an `mcp` entry exits without a `broker` database and `MCP_OAUTH_SECRET`, so
1992
+ // both are set here, before such an entry is written (`writeBrokerEntry` refuses until they are live).
1993
+ let mcpEnvs = [];
1994
+ if (input.mcp) {
1995
+ mcpEnvs = await ensureBrokerMcpEnv({
1996
+ coolify: deps.coolify,
1997
+ brokerAppId,
1998
+ jobName: `${target.names.authBroker}-mcp-db-job`,
1999
+ project: { uuid: projectUuid, environment },
2000
+ adminUrl: databaseUrl,
2001
+ caPem: signinDbCa,
2002
+ brokerUrl: brokerMcpUrl,
2003
+ runtime,
2004
+ label,
2005
+ });
2006
+ if (signinDbCa)
2007
+ await ensureSigninDbCaFile(deps.coolify, brokerAppId, signinDbCa, label);
2008
+ }
1956
2009
  const brokerEnvs = [
2010
+ ...mcpEnvs,
1957
2011
  {
1958
2012
  key: 'ZITADEL_API_URL',
1959
2013
  value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
@@ -2057,6 +2111,9 @@ export async function provisionAuthStack(scope, deps, target, input) {
2057
2111
  await deps.coolify.deploy(brokerAppId);
2058
2112
  await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
2059
2113
  }
2114
+ // The env is live on the broker (and deployed when it changed): the entry write may now carry `mcp`.
2115
+ if (input.mcp)
2116
+ await record(PLATFORM_AUTH_MCP_SCOPE_KEY, '1');
2060
2117
  // ---- tunnel and DNS ------------------------------------------------------
2061
2118
  const tunnels = await network.tunnels();
2062
2119
  const existingTunnel = await tunnels.findTunnelByName(target.names.authTunnel);
@@ -2064,6 +2121,11 @@ export async function provisionAuthStack(scope, deps, target, input) {
2064
2121
  await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
2065
2122
  await tunnels.putTunnelConfig(tunnel.id, {
2066
2123
  ingress: [
2124
+ {
2125
+ hostname,
2126
+ path: AUTH_BROKER_MCP_PATH_PATTERN,
2127
+ service: `http://${target.internal.authBroker}:${AUTH_BROKER_PORT}`,
2128
+ },
2067
2129
  {
2068
2130
  hostname,
2069
2131
  path: AUTH_ZITADEL_PATH_PATTERN,
@@ -2081,6 +2143,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2081
2143
  { service: 'http_status:404' },
2082
2144
  ],
2083
2145
  });
2146
+ await record(PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, AUTH_TUNNEL_ROUTES_VERSION);
2084
2147
  // ---- cloudflared connector for the tunnel above (#197) --------------------
2085
2148
  // Without this, `hostname` resolves to a tunnel nothing is dialed into and
2086
2149
  // answers with a Cloudflare error -- the known gap #197 closes for this
@@ -2551,7 +2614,7 @@ const platformAuthProject = {
2551
2614
  };
2552
2615
  /** The platform's sign-in: {@link provisionAuthStack} with {@link platformTarget} and the platform's own inputs. */
2553
2616
  async function provisionPlatformAuth(context, deps, options = {}) {
2554
- const { ownerCompany = null, signInLogin = false } = options;
2617
+ const { ownerCompany = null, signInLogin = false, mcp = false } = options;
2555
2618
  const { platform } = context;
2556
2619
  const scope = scopeFromPlatform(context);
2557
2620
  return provisionAuthStack(scope, deps, platformTarget(), {
@@ -2565,6 +2628,7 @@ async function provisionPlatformAuth(context, deps, options = {}) {
2565
2628
  ownerCompany,
2566
2629
  // In-app sign-in: the second login app copies the login's host table (the host's port writes that).
2567
2630
  ...(signInLogin ? { signInLogin: { bindings: null } } : {}),
2631
+ mcp,
2568
2632
  });
2569
2633
  }
2570
2634
  /**
@@ -7177,7 +7241,7 @@ export async function envsDiffer(coolify, appId, desired) {
7177
7241
  * password): none of those have a broker-enforced minimum, so widening this
7178
7242
  * check to them would reject a value that has always been fine.
7179
7243
  */
7180
- async function ensureGeneratedEnv(coolify, appId, key, generate, minLength) {
7244
+ export async function ensureGeneratedEnv(coolify, appId, key, generate, minLength) {
7181
7245
  const envs = await coolify.listEnvs(appId);
7182
7246
  const entry = envs.find((e) => e.key === key && !e.is_preview);
7183
7247
  const already = entry !== undefined &&
@@ -7189,7 +7253,7 @@ async function ensureGeneratedEnv(coolify, appId, key, generate, minLength) {
7189
7253
  return generate();
7190
7254
  }
7191
7255
  /** The live value of `key` on `appId`, or `undefined` when unset -- `ensureGeneratedEnv`'s read half, for a caller that needs the value it already found. */
7192
- async function currentEnvValue(coolify, appId, key) {
7256
+ export async function currentEnvValue(coolify, appId, key) {
7193
7257
  const envs = await coolify.listEnvs(appId);
7194
7258
  return envs.find((e) => e.key === key && !e.is_preview)?.value;
7195
7259
  }
@@ -105,6 +105,8 @@ export interface WorkerAppUpload {
105
105
  bindings: WorkerAppBinding[];
106
106
  /** Only the migrations not yet applied; absent when there are none. */
107
107
  migrations?: WorkerAppMigrations;
108
+ /** Durable Object classes the upload marks container-enabled; absent when the entry declares no container. */
109
+ containers?: string[];
108
110
  }
109
111
  export interface WorkerAppContainer {
110
112
  className: string;
@@ -299,7 +301,7 @@ export interface WorkerAppDeps {
299
301
  export declare const WORKER_APP_CALL_TIMEOUT_MS = 120000;
300
302
  export declare const WORKER_APP_KEYS_DELIVERY_ATTEMPTS = 3;
301
303
  /**
302
- * Races `task` against a timer. An error names the call, never the cause.
304
+ * Races `task` against a timer. An error names the call and edge's error code, never the cause.
303
305
  */
304
306
  export declare function withCallTimeout<T>(ms: number, label: string, task: () => Promise<T>): Promise<T>;
305
307
  /**
@@ -400,7 +400,7 @@ export function planWorkerApps(org, recordedIds, input) {
400
400
  export const WORKER_APP_CALL_TIMEOUT_MS = 120_000;
401
401
  export const WORKER_APP_KEYS_DELIVERY_ATTEMPTS = 3;
402
402
  /**
403
- * Races `task` against a timer. An error names the call, never the cause.
403
+ * Races `task` against a timer. An error names the call and edge's error code, never the cause.
404
404
  */
405
405
  export function withCallTimeout(ms, label, task) {
406
406
  return new Promise((resolve, reject) => {
@@ -411,11 +411,15 @@ export function withCallTimeout(ms, label, task) {
411
411
  }, (error) => {
412
412
  clearTimeout(timer);
413
413
  // The cause is dropped: a provider error can echo a request body. The
414
- // HTTP status stays, because callers tell "already gone" by it.
415
- const failure = new Error(`${label} failed`);
416
- const status = error?.status;
414
+ // HTTP status stays, because callers tell "already gone" by it. So does
415
+ // edge's error code (a fixed lower-case enum, never a secret), to say why.
416
+ const { status, code } = (error ?? {});
417
+ const known = typeof code === 'string' && /^[a-z_]{1,40}$/.test(code) ? code : undefined;
418
+ const failure = new Error(known ? `${label} failed (${known})` : `${label} failed`);
417
419
  if (typeof status === 'number')
418
420
  failure.status = status;
421
+ if (known)
422
+ failure.code = known;
419
423
  reject(failure);
420
424
  });
421
425
  });
@@ -523,6 +527,9 @@ export async function applyWorkerApp(deps, org, action, context) {
523
527
  : []),
524
528
  ],
525
529
  ...(migrations ? { migrations } : {}),
530
+ ...(want.containers.length > 0
531
+ ? { containers: want.containers.map((c) => c.className) }
532
+ : {}),
526
533
  }));
527
534
  context.recordScopeId(workerAppTagsScopeKey(entryKey), JSON.stringify(declaredTags));
528
535
  await context.persistScopeIds();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.52.1",
3
+ "version": "0.54.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",