@wtfalch/foundry 0.69.0 → 0.70.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.
@@ -11,6 +11,8 @@ export declare const ONBOARDING_PATH_MAX_LENGTH = 200;
11
11
  * at most {@link ONBOARDING_PATH_MAX_LENGTH} characters.
12
12
  */
13
13
  export declare function isPagePath(value: unknown): value is string;
14
+ /** A path the gate can open for a machine caller: the same rule edge applies, so a declared path is always one a bypass can be made for. */
15
+ export declare const isEdgePath: (path: string) => boolean;
14
16
  /** The most roles an entry may offer, as the broker allows. */
15
17
  export declare const WORKSPACE_ROLES_MAX = 20;
16
18
  /** A role name is the role's key in the broker's store. */
@@ -21,6 +21,12 @@ export function isPagePath(value) {
21
21
  !/[\s\p{Cc}]/u.test(value) &&
22
22
  value.length <= ONBOARDING_PATH_MAX_LENGTH);
23
23
  }
24
+ /** What edge accepts as the path of a destination: segments of `a-z 0-9 . _ ~ -`, none empty, `.` or `..`, at most 200 characters. */
25
+ const EDGE_PATH = /^(\/[a-z0-9._~-]+)+$/;
26
+ /** A path the gate can open for a machine caller: the same rule edge applies, so a declared path is always one a bypass can be made for. */
27
+ export const isEdgePath = (path) => path.length <= ONBOARDING_PATH_MAX_LENGTH &&
28
+ EDGE_PATH.test(path) &&
29
+ !path.split('/').some((s) => s === '.' || s === '..');
24
30
  /** The most roles an entry may offer, as the broker allows. */
25
31
  export const WORKSPACE_ROLES_MAX = 20;
26
32
  /** A role name is the role's key in the broker's store. */
@@ -1,4 +1,4 @@
1
- import { MCP_PATH, isPagePath } from '../entry-rules.js';
1
+ import { MCP_PATH, isEdgePath, isPagePath } from '../entry-rules.js';
2
2
  import { problem, setAuthPart, text } from './entry-parts.js';
3
3
  /** `uses.auth.mcp`: the app serves AI assistants (MCP clients) that sign the customer in. */
4
4
  export const assistants = {
@@ -29,6 +29,21 @@ export const assistants = {
29
29
  kind: 'path',
30
30
  advanced: true,
31
31
  },
32
+ {
33
+ key: 'eventsPath',
34
+ label: 'Where we tell your app about assistants',
35
+ help: 'Set an address on your app, such as /api/auth/events, and we tell your app when people connect or disconnect assistants, so it can keep its own records. Leave it empty and we send nothing.',
36
+ kind: 'path',
37
+ advanced: true,
38
+ },
39
+ {
40
+ key: 'managePath',
41
+ label: 'Page where people see their assistants',
42
+ help: 'The page on your app where each person sees their connected assistants. We link to it from the mail we send when one connects.',
43
+ kind: 'path',
44
+ advanced: true,
45
+ default: '/',
46
+ },
32
47
  ],
33
48
  adds: [
34
49
  {
@@ -46,6 +61,14 @@ export const assistants = {
46
61
  if (onboarding !== undefined && !isPagePath(onboarding)) {
47
62
  return problem('onboardingPath', 'The page must start with "/", such as /onboarding, with no spaces, "?", "#" or "..".');
48
63
  }
64
+ const events = text(values, 'eventsPath');
65
+ if (events !== undefined && !isEdgePath(events)) {
66
+ return problem('eventsPath', 'The address must start with "/" and use only lower-case letters, digits, ".", "_", "~" and "-" in each part, such as /api/auth/events.');
67
+ }
68
+ const manage = text(values, 'managePath');
69
+ if (manage !== undefined && !isPagePath(manage)) {
70
+ return problem('managePath', 'The page must start with "/", with no spaces, "?", "#" or "..".');
71
+ }
49
72
  return null;
50
73
  },
51
74
  toEntry(entry, values) {
@@ -55,9 +78,13 @@ export const assistants = {
55
78
  return problem(null, 'Help for AI assistants needs Sign-in. Turn on Sign-in first.');
56
79
  }
57
80
  const onboardingPath = text(values, 'onboardingPath');
81
+ const eventsPath = text(values, 'eventsPath');
82
+ const managePath = text(values, 'managePath');
58
83
  return setAuthPart(entry, 'mcp', {
59
84
  path: values.path,
60
85
  ...(onboardingPath !== undefined ? { onboardingPath } : {}),
86
+ ...(eventsPath !== undefined ? { events: { path: eventsPath } } : {}),
87
+ ...(managePath !== undefined ? { managePath } : {}),
61
88
  });
62
89
  },
63
90
  fromEntry(entry) {
@@ -67,6 +94,10 @@ export const assistants = {
67
94
  const values = { path: mcp.path };
68
95
  if (mcp.onboardingPath !== undefined)
69
96
  values.onboardingPath = mcp.onboardingPath;
97
+ if (mcp.events !== undefined)
98
+ values.eventsPath = mcp.events.path;
99
+ if (mcp.managePath !== undefined)
100
+ values.managePath = mcp.managePath;
70
101
  return values;
71
102
  },
72
103
  };
package/dist/org.d.ts CHANGED
@@ -301,7 +301,7 @@ export type AccessRule = {
301
301
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
302
302
  export declare const APP_ENTRY_KEY = "app";
303
303
  /** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
304
- 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' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | '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' | 'hosting_github_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' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | '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' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_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' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | '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_needs_own_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' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
304
+ 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' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | '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' | 'hosting_github_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' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | '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' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_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' | 'mcp_events_path_invalid' | 'mcp_manage_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | '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_needs_own_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' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
305
305
  /**
306
306
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
307
307
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -620,11 +620,18 @@ export interface EntryAuth {
620
620
  * broker is the authorization server, ZITADEL signs the person in. `path` is
621
621
  * the MCP endpoint on the entry's host; the resource is
622
622
  * `https://<the entry's host><path>`, derived and never declared. Needs
623
- * `users: 'customers'` and `registration: 'open'`.
623
+ * `users: 'customers'` and `registration: 'open'`. `events.path` is where
624
+ * the broker posts signed assistant events; foundry mints the signing secret
625
+ * once and gives it to the broker entry and the app as `AUTH_EVENTS_SECRET`.
626
+ * `managePath` is the page where people see their assistants.
624
627
  */
625
628
  mcp?: {
626
629
  path: string;
627
630
  onboardingPath?: string;
631
+ events?: {
632
+ path: string;
633
+ };
634
+ managePath?: string;
628
635
  };
629
636
  /**
630
637
  * How the customer proves who they are. `password` is the default.
package/dist/org.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_RULES, checkName, explain } from '@wtfalch/auth/names';
2
2
  import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
3
- import { AI_ENTRY_PERMISSIONS, AI_WEBHOOK_PATH, FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, ROOM_UNSAFE_TEXT, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, isPagePath, } from './entry-rules.js';
3
+ import { AI_ENTRY_PERMISSIONS, AI_WEBHOOK_PATH, FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, ROOM_UNSAFE_TEXT, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, isEdgePath, isPagePath, } from './entry-rules.js';
4
4
  import { hostedLabelRefusal } from './hosted-label.js';
5
5
  import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
6
6
  export { AI_ENTRY_PERMISSIONS, FILES_ENTRY_PERMISSIONS, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, } from './entry-rules.js';
@@ -948,6 +948,19 @@ function validateEntryAuth(entry, hosting, domain) {
948
948
  if (onboarding !== undefined && !isPagePath(onboarding)) {
949
949
  return refusal('mcp_onboarding_path_invalid', `hosting.entries.${entry.key}.uses.auth.mcp.onboardingPath`, `${at} mcp.onboardingPath must be an absolute path that starts with '/', such as '/onboarding', with no '..', '?', '#' or spaces, and at most ${ONBOARDING_PATH_MAX_LENGTH} characters`);
950
950
  }
951
+ const events = auth.mcp.events;
952
+ if (events !== undefined &&
953
+ (typeof events !== 'object' ||
954
+ events === null ||
955
+ Array.isArray(events) ||
956
+ typeof events.path !== 'string' ||
957
+ !isEdgePath(events.path))) {
958
+ return refusal('mcp_events_path_invalid', `hosting.entries.${entry.key}.uses.auth.mcp.events.path`, `${at} mcp.events.path must be an absolute path that starts with '/', such as '/api/auth/events', made of lower-case a-z, 0-9, '.', '_', '~' and '-' with no empty or '..' part, and at most ${ONBOARDING_PATH_MAX_LENGTH} characters`);
959
+ }
960
+ const manage = auth.mcp.managePath;
961
+ if (manage !== undefined && !isPagePath(manage)) {
962
+ return refusal('mcp_manage_path_invalid', `hosting.entries.${entry.key}.uses.auth.mcp.managePath`, `${at} mcp.managePath must be an absolute path that starts with '/', such as '/settings', with no '..', '?', '#' or spaces, and at most ${ONBOARDING_PATH_MAX_LENGTH} characters`);
963
+ }
951
964
  }
952
965
  if (auth.workspaces !== undefined) {
953
966
  const refused = validateEntryWorkspaces(entry, auth.workspaces, at);
@@ -710,6 +710,14 @@ export async function ensureStage(deps, subject, serverUuid) {
710
710
  else {
711
711
  console.info(`${deps.label}: stage sign-in was not set up: the host gave no sign-in for this subject, so ${bgKey.stageSignIn(subject.id)} stays unrecorded`);
712
712
  }
713
+ // A workspace whose entry stopped declaring events drops the secret; the held value stays.
714
+ if (deps.signIn && !stageEnv.some((e) => e.key === 'AUTH_EVENTS_SECRET')) {
715
+ for (const row of await deps.coolify.listEnvs(appId)) {
716
+ if (row.key === 'AUTH_EVENTS_SECRET' && row.uuid) {
717
+ await deps.coolify.deleteEnv(appId, row.uuid);
718
+ }
719
+ }
720
+ }
713
721
  await upsertEnv(deps, appId, stageEnv);
714
722
  if (signInGuard)
715
723
  deps.record(bgKey.stageSignIn(subject.id), signInGuard);
@@ -36,12 +36,21 @@ export declare const mcpCallbackUri: (org: DeclaredOrg) => string;
36
36
  * compares are the same value. `null` without `mcp`, or until the project
37
37
  * exists.
38
38
  */
39
- export declare function entryMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
39
+ export declare function entryMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): BrokerEntryMcp | null;
40
+ /**
41
+ * What a broker entry says about its MCP resource. `events` carries the address only: the signing secret is
42
+ * held, and `writeBrokerEntry` adds it when it writes the entry, so the recorded want never holds it.
43
+ */
44
+ export interface BrokerEntryMcp {
40
45
  resource: string;
41
46
  projectId: string;
42
47
  loginHost: string;
43
48
  onboardingUrl?: string;
44
- } | null;
49
+ events?: {
50
+ url: string;
51
+ };
52
+ manageUrl?: string;
53
+ }
45
54
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
46
55
  export declare function entryAuthEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
47
56
  /** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
@@ -56,12 +65,7 @@ export interface BrokerEntryWant {
56
65
  registration: boolean;
57
66
  replaces: string | null;
58
67
  /** Present only for an entry with `uses.auth.mcp`: what the broker needs to be its authorization server. */
59
- mcp?: {
60
- resource: string;
61
- projectId: string;
62
- loginHost: string;
63
- onboardingUrl?: string;
64
- };
68
+ mcp?: BrokerEntryMcp;
65
69
  /** Present only for an entry with `uses.auth.workspaces`. The key's hash is not part of it: see {@link writeBrokerEntry}. */
66
70
  workspaces?: {
67
71
  roles: string[];
@@ -174,6 +178,8 @@ export declare function writeBrokerEntry(broker: BrokerPort, input: {
174
178
  workspacesKeyHash?: string;
175
179
  /** The operator's held connection string for the broker's workspaces database, written to the broker when it differs. */
176
180
  workspacesDatabaseUrl?: string;
181
+ /** The held events secret. Required with `want.mcp.events`; it goes into the entry and nowhere else. */
182
+ eventsSecret?: string;
177
183
  }): Promise<{
178
184
  invalidForeign: string[];
179
185
  replaced: string | null;
@@ -59,6 +59,8 @@ export function entryMcp(org, entry, recorded) {
59
59
  projectId,
60
60
  loginHost: loginHostnameFor(org),
61
61
  ...(mcp.onboardingPath ? { onboardingUrl: `https://${host}${mcp.onboardingPath}` } : {}),
62
+ ...(mcp.events ? { events: { url: `https://${host}${mcp.events.path}` } } : {}),
63
+ ...(mcp.managePath ? { manageUrl: `https://${host}${mcp.managePath}` } : {}),
62
64
  };
63
65
  }
64
66
  /** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
@@ -286,8 +288,18 @@ export async function writeBrokerEntry(broker, input) {
286
288
  registration: want.registration,
287
289
  };
288
290
  // The previous write's `mcp` goes when the entry no longer declares it.
289
- if (want.mcp)
290
- entry.mcp = want.mcp;
291
+ if (want.mcp) {
292
+ const { events, ...mcp } = want.mcp;
293
+ if (events && !input.eventsSecret) {
294
+ throw new Error(`${label}: the events secret is missing, so nothing was written`);
295
+ }
296
+ entry.mcp = {
297
+ ...mcp,
298
+ ...(events && input.eventsSecret
299
+ ? { events: { url: events.url, secret: input.eventsSecret } }
300
+ : {}),
301
+ };
302
+ }
291
303
  else
292
304
  entry.mcp = undefined;
293
305
  // The previous write's `workspaces` goes when the entry no longer declares it; the key hash goes with it.
package/dist/plan/diff.js CHANGED
@@ -21,6 +21,7 @@ import { onPlatformNetwork } from './destinations.js';
21
21
  import { planEntryAiKeys } from './entry-ai-key.js';
22
22
  import { planEntryAnalyticsKeys } from './entry-analytics-key.js';
23
23
  import { planEntryAudit } from './entry-audit.js';
24
+ import { authEventsAppliedScopeKey, authEventsBlocked, authEventsMarker, } from './entry-auth-events.js';
24
25
  import { CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsShapeMatches, customerGrantsToDrop, wantedCustomerGrants, } from './entry-customer-grants.js';
25
26
  import { planEdgeReservedLabels, planEdgeSlug, planEntryEdgeKeys } from './entry-edge-key.js';
26
27
  import { planFilesKeys } from './entry-files-key.js';
@@ -1049,6 +1050,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1049
1050
  unmanaged.push(...appsDbRoute.blocked);
1050
1051
  unmanaged.push(...appsZones.blocked);
1051
1052
  unmanaged.push(...entryJobs.blocked);
1053
+ unmanaged.push(...authEventsBlocked(org, recordedIds));
1052
1054
  unmanaged.push(...entryAudit.blocked);
1053
1055
  unmanaged.push(...entryRepoKeys.blocked);
1054
1056
  unmanaged.push(...edgeReservedLabels.blocked);
@@ -3888,8 +3890,9 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3888
3890
  // F5 (rule 3): who signs in, which client and which issuer reach an app
3889
3891
  // that is already live. `pushAppEnv` records what it pushed.
3890
3892
  if (entry.uses?.auth &&
3891
- JSON.stringify(entryAuthEnv(org, entry, recordedIds, authIssuer)) !==
3892
- (recordedIds[entryAuthEnvAppliedScopeKey(entryKey)] ?? '{}')) {
3893
+ (JSON.stringify(entryAuthEnv(org, entry, recordedIds, authIssuer)) !==
3894
+ (recordedIds[entryAuthEnvAppliedScopeKey(entryKey)] ?? '{}') ||
3895
+ authEventsMarker(entry) !== (recordedIds[authEventsAppliedScopeKey(entryKey)] ?? ''))) {
3893
3896
  return {
3894
3897
  step: {
3895
3898
  action,
@@ -0,0 +1,53 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ /**
3
+ * `uses.auth.mcp.events`: the broker posts signed assistant events to the app. Foundry makes one signing secret
4
+ * per broker entry (`<entry>` and `<entry>-work` each have their own), holds it, and gives it to two places: the
5
+ * broker entry (`mcp.events.secret`) and the app (`AUTH_EVENTS_SECRET`). The plan and the recorded markers carry
6
+ * only whether events are on and the address, never the secret.
7
+ */
8
+ /** The app's env name, and the Worker app's secret name. */
9
+ export declare const AUTH_EVENTS_ENV = "AUTH_EVENTS_SECRET";
10
+ /** The secret was made and is not held yet: the ref, never the secret (CLAUDE.md, Platform steps, rule 1). */
11
+ export declare const authEventsUndeliveredScopeKey: (entryKey: string) => string;
12
+ /** What the app's env was last written with for events: `on` or empty. A change replans the app step. */
13
+ export declare const authEventsAppliedScopeKey: (entryKey: string) => string;
14
+ /** The held-key ref of an entry's events secret. At most 127 characters for an entry key of up to 63. */
15
+ export declare const authEventsSecretRef: (orgId: string, entryKey: string) => string;
16
+ /** The hold is tried this many times (rule 1). */
17
+ export declare const AUTH_EVENTS_HOLD_TRIES = 3;
18
+ /** Every held-key read and write is one outside call. */
19
+ export declare const AUTH_EVENTS_HELD_TIMEOUT_MS = 15000;
20
+ /** `whsec_` and the base64 of 32 random bytes: the Standard Webhooks secret format. */
21
+ export declare const newAuthEventsSecret: () => string;
22
+ /** The entry declares `uses.auth.mcp.events`. */
23
+ export declare const authEventsWanted: (entry: HostingEntry) => boolean;
24
+ /** What `auth_events_applied.<entry>` holds for an entry. */
25
+ export declare const authEventsMarker: (entry: HostingEntry) => string;
26
+ interface HeldKeys {
27
+ resolve(ref: string): Promise<string | null | undefined>;
28
+ put?(ref: string, value: string): Promise<void>;
29
+ }
30
+ /**
31
+ * The entry's events secret: read from the held keys, else made and held. The marker is recorded and persisted
32
+ * before the hold, so a crash after the mint never mints again: a recorded marker with nothing held stops here
33
+ * and names the ref. The hold is tried 3 times with the same secret and the marker is cleared after it. Errors
34
+ * name the entry and the ref, never the secret or the cause. `secret` registers the value for redaction first.
35
+ */
36
+ export declare function ensureAuthEventsSecret(input: {
37
+ orgId: string;
38
+ slug: string;
39
+ entryKey: string;
40
+ heldKeys: HeldKeys | undefined;
41
+ recorded: () => Readonly<Record<string, string>>;
42
+ record: (key: string, value: string) => void;
43
+ persist: () => Promise<void>;
44
+ secret: (value: string) => void;
45
+ }): Promise<string>;
46
+ /** The secret as already held, or an error that names the ref. A broker-entry step only reads. */
47
+ export declare function heldAuthEventsSecret(heldKeys: Pick<HeldKeys, 'resolve'> | undefined, org: Pick<DeclaredOrg, 'id' | 'slug'>, entryKey: string, step?: string): Promise<string>;
48
+ /** One blocked line per recorded marker, for the planner to show. */
49
+ export declare function authEventsBlocked(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Array<{
50
+ description: string;
51
+ key: string;
52
+ }>;
53
+ export {};
@@ -0,0 +1,112 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { withTimeout } from './call-timeout.js';
3
+ /**
4
+ * `uses.auth.mcp.events`: the broker posts signed assistant events to the app. Foundry makes one signing secret
5
+ * per broker entry (`<entry>` and `<entry>-work` each have their own), holds it, and gives it to two places: the
6
+ * broker entry (`mcp.events.secret`) and the app (`AUTH_EVENTS_SECRET`). The plan and the recorded markers carry
7
+ * only whether events are on and the address, never the secret.
8
+ */
9
+ /** The app's env name, and the Worker app's secret name. */
10
+ export const AUTH_EVENTS_ENV = 'AUTH_EVENTS_SECRET';
11
+ /** The secret was made and is not held yet: the ref, never the secret (CLAUDE.md, Platform steps, rule 1). */
12
+ export const authEventsUndeliveredScopeKey = (entryKey) => `auth_events_secret_undelivered.${entryKey}`;
13
+ /** What the app's env was last written with for events: `on` or empty. A change replans the app step. */
14
+ export const authEventsAppliedScopeKey = (entryKey) => `auth_events_applied.${entryKey}`;
15
+ /** The held-key ref of an entry's events secret. At most 127 characters for an entry key of up to 63. */
16
+ export const authEventsSecretRef = (orgId, entryKey) => `foundry:auth-events-secret:${orgId}:${entryKey}`;
17
+ /** The hold is tried this many times (rule 1). */
18
+ export const AUTH_EVENTS_HOLD_TRIES = 3;
19
+ /** Every held-key read and write is one outside call. */
20
+ export const AUTH_EVENTS_HELD_TIMEOUT_MS = 15_000;
21
+ /** `whsec_` and the base64 of 32 random bytes: the Standard Webhooks secret format. */
22
+ export const newAuthEventsSecret = () => `whsec_${randomBytes(32).toString('base64')}`;
23
+ /** The entry declares `uses.auth.mcp.events`. */
24
+ export const authEventsWanted = (entry) => entry.uses?.auth?.mcp?.events !== undefined;
25
+ /** What `auth_events_applied.<entry>` holds for an entry. */
26
+ export const authEventsMarker = (entry) => authEventsWanted(entry) ? 'on' : '';
27
+ /**
28
+ * The entry's events secret: read from the held keys, else made and held. The marker is recorded and persisted
29
+ * before the hold, so a crash after the mint never mints again: a recorded marker with nothing held stops here
30
+ * and names the ref. The hold is tried 3 times with the same secret and the marker is cleared after it. Errors
31
+ * name the entry and the ref, never the secret or the cause. `secret` registers the value for redaction first.
32
+ */
33
+ export async function ensureAuthEventsSecret(input) {
34
+ const { entryKey } = input;
35
+ const ref = authEventsSecretRef(input.orgId, entryKey);
36
+ const marker = authEventsUndeliveredScopeKey(entryKey);
37
+ const at = `coolify:app for ${input.slug}`;
38
+ const heldKeys = input.heldKeys;
39
+ const held = await withTimeout('held key read', AUTH_EVENTS_HELD_TIMEOUT_MS, async () => heldKeys?.resolve(ref));
40
+ if (held) {
41
+ input.secret(held);
42
+ if (input.recorded()[marker]) {
43
+ input.record(marker, '');
44
+ await input.persist();
45
+ }
46
+ return held;
47
+ }
48
+ if (input.recorded()[marker]) {
49
+ throw new Error(`${at}: the events secret of entry '${entryKey}' was made but never held (ref ${ref}); it is not made again. Hold a value at that ref, or remove the recorded key ${marker}`);
50
+ }
51
+ const put = heldKeys?.put;
52
+ if (typeof put !== 'function') {
53
+ throw new Error(`${at}: the held keys cannot put the events secret of entry '${entryKey}'`);
54
+ }
55
+ const value = newAuthEventsSecret();
56
+ input.secret(value);
57
+ input.record(marker, ref);
58
+ await input.persist();
59
+ for (let attempt = 1; attempt <= AUTH_EVENTS_HOLD_TRIES; attempt++) {
60
+ let stored = false;
61
+ try {
62
+ await withTimeout('held key write', AUTH_EVENTS_HELD_TIMEOUT_MS, () => put.call(heldKeys, ref, value));
63
+ stored = true;
64
+ }
65
+ catch {
66
+ // The cause can echo the value; the ref is all a reader needs.
67
+ }
68
+ if (!stored)
69
+ continue;
70
+ input.record(marker, '');
71
+ await input.persist();
72
+ // Two applies that both found nothing held both mint and the later put wins: let a put that is
73
+ // landing finish, then end on what is held. A put that lands later than that is not seen here.
74
+ await new Promise((resolve) => setTimeout(resolve, 0));
75
+ let final = value;
76
+ try {
77
+ final =
78
+ (await withTimeout('held key read', AUTH_EVENTS_HELD_TIMEOUT_MS, async () => heldKeys?.resolve(ref))) || value;
79
+ }
80
+ catch (error) {
81
+ // The put went through, so the apply goes on with the value just put; the cause can echo a value.
82
+ console.warn(`${at}: the events secret of entry '${entryKey}' could not be read back after it was held (${error?.name ?? 'Error'})`);
83
+ }
84
+ input.secret(final);
85
+ return final;
86
+ }
87
+ throw new Error(`${at}: the events secret of entry '${entryKey}' was made but could not be held after ${AUTH_EVENTS_HOLD_TRIES} tries (ref ${ref})`);
88
+ }
89
+ /** The secret as already held, or an error that names the ref. A broker-entry step only reads. */
90
+ export async function heldAuthEventsSecret(heldKeys, org, entryKey, step = 'coolify:broker-entry') {
91
+ const ref = authEventsSecretRef(org.id, entryKey);
92
+ const value = await withTimeout('held key read', AUTH_EVENTS_HELD_TIMEOUT_MS, async () => heldKeys?.resolve(ref));
93
+ if (!value) {
94
+ throw new Error(`${step} for ${org.slug}: the events secret of entry '${entryKey}' is not held yet (ref ${ref}); the app step makes it`);
95
+ }
96
+ return value;
97
+ }
98
+ /** One blocked line per recorded marker, for the planner to show. */
99
+ export function authEventsBlocked(org, recorded) {
100
+ return Object.entries(recorded).flatMap(([key, id]) => {
101
+ const prefix = authEventsUndeliveredScopeKey('');
102
+ if (!id || !key.startsWith(prefix))
103
+ return [];
104
+ const entryKey = key.slice(prefix.length);
105
+ return [
106
+ {
107
+ description: `${org.slug}'s ${entryKey} events secret was made but never held (ref ${id}); it is not made again. Hold a value at that ref, or remove the recorded key ${key}`,
108
+ key: `coolify:auth-events:${entryKey}`,
109
+ },
110
+ ];
111
+ });
112
+ }
@@ -2736,6 +2736,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2736
2736
  * provisioned, where this one is unconditional (#239).
2737
2737
  */
2738
2738
  private pushAppEnv;
2739
+ /** Takes `AUTH_EVENTS_SECRET` out of the app's env once the entry stops declaring events. The held value stays: the held store has no delete. */
2740
+ private dropAuthEventsEnv;
2739
2741
  /**
2740
2742
  * The install's edge label token: read from the held keys, else minted and held first, so a crash
2741
2743
  * after the `put` hands the same value out on the next apply. Read once more after the `put`, so two
@@ -2777,6 +2779,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2777
2779
  * broker would drop.
2778
2780
  */
2779
2781
  private coolifyBrokerEntry;
2782
+ /** The held events secret of a broker entry that declares events, registered for redaction; nothing when it declares none. */
2783
+ private eventsInput;
2780
2784
  /**
2781
2785
  * What the broker entry needs beyond its want when it declares workspaces: the hash of the held key (the key
2782
2786
  * itself is registered for redaction and goes no further) and the operator's held database connection string.
@@ -43,6 +43,7 @@ import { createEdgeZones } from './edge-zones.js';
43
43
  import { AI_ENTRY_KEY_DAYS, AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
44
44
  import { ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS, entryAnalyticsAppliedScopeKey, entryAnalyticsRetiredScopeKey, entryAnalyticsUndeliveredScopeKey, stageAnalyticsKeyName, } from './entry-analytics-key.js';
45
45
  import { applyEntryAudit } from './entry-audit.js';
46
+ import { AUTH_EVENTS_ENV, authEventsAppliedScopeKey, authEventsMarker, authEventsWanted, ensureAuthEventsSecret, heldAuthEventsSecret, } from './entry-auth-events.js';
46
47
  import { CUSTOMER_GRANTS_CREDENTIAL_ENV, CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsJson, customerGrantsMintedScopeKey, customerGrantsRef, customerGrantsToDrop, ensureCustomerGrantsCredential, wantedCustomerGrants, } from './entry-customer-grants.js';
47
48
  import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, EDGE_SLUG_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, bouleEdgeKeyWanted, edgeKeyAppRecordKey, edgeKeyDomain, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
48
49
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
@@ -3485,6 +3486,21 @@ export class FoundryStepExecutor {
3485
3486
  });
3486
3487
  env.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
3487
3488
  }
3489
+ if (authEventsWanted(entry)) {
3490
+ env.push({
3491
+ key: AUTH_EVENTS_ENV,
3492
+ value: await ensureAuthEventsSecret({
3493
+ orgId: org.id,
3494
+ slug: org.slug,
3495
+ entryKey: workspaceClientKey(entry.key),
3496
+ heldKeys,
3497
+ recorded: () => context.recordedIds(),
3498
+ record: (key, value) => context.recordScopeId(key, value),
3499
+ persist: () => context.persistScopeIds(),
3500
+ secret: (value) => void this.handledSecrets.add(value),
3501
+ }),
3502
+ });
3503
+ }
3488
3504
  const workspacesKeys = [];
3489
3505
  for (const live of org.hosting.entries.filter((e) => e.uses?.auth?.workspaces)) {
3490
3506
  const value = await heldKeys?.resolve(workspacesKeyRef(org.id, live.key));
@@ -3537,7 +3553,10 @@ export class FoundryStepExecutor {
3537
3553
  }
3538
3554
  this.handledSecrets.add(appKey);
3539
3555
  const clientKey = workspaceClientKey(entry.key);
3540
- await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, clientKey) : {});
3556
+ await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context, {
3557
+ ...(wanted.want.workspaces ? await this.workspacesInputs(org, clientKey) : {}),
3558
+ ...(await this.eventsInput(org, clientKey, wanted.want)),
3559
+ });
3541
3560
  return {};
3542
3561
  }
3543
3562
  /**
@@ -4336,6 +4355,7 @@ export class FoundryStepExecutor {
4336
4355
  context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
4337
4356
  if (entry.uses?.auth) {
4338
4357
  context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
4358
+ context.recordScopeId(authEventsAppliedScopeKey(entry.key), appEnv.eventsApplied);
4339
4359
  }
4340
4360
  if (dbPush.pushed)
4341
4361
  context.recordScopeId(dbEnvAppliedScopeKey(entry.key), '1');
@@ -4948,6 +4968,22 @@ export class FoundryStepExecutor {
4948
4968
  });
4949
4969
  envs.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
4950
4970
  }
4971
+ if (authEventsWanted(entry)) {
4972
+ const eventsSecret = await ensureAuthEventsSecret({
4973
+ orgId: org.id,
4974
+ slug: org.slug,
4975
+ entryKey: entry.key,
4976
+ heldKeys: this.ports.heldKeys,
4977
+ recorded: () => context.recordedIds(),
4978
+ record: (key, value) => context.recordScopeId(key, value),
4979
+ persist: () => context.persistScopeIds(),
4980
+ secret: (value) => void this.handledSecrets.add(value),
4981
+ });
4982
+ envs.push({ key: AUTH_EVENTS_ENV, value: eventsSecret });
4983
+ }
4984
+ else if (recorded[authEventsAppliedScopeKey(entry.key)] === 'on') {
4985
+ await this.dropAuthEventsEnv(appId);
4986
+ }
4951
4987
  }
4952
4988
  else {
4953
4989
  const zitadelOrgId = recorded.zitadel_org;
@@ -5024,11 +5060,28 @@ export class FoundryStepExecutor {
5024
5060
  changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
5025
5061
  heldChanged ||
5026
5062
  (Boolean(entry.uses?.auth) &&
5027
- authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
5063
+ (authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}') ||
5064
+ authEventsMarker(entry) !== (recorded[authEventsAppliedScopeKey(entry.key)] ?? ''))),
5028
5065
  applied,
5029
5066
  authApplied,
5067
+ eventsApplied: authEventsMarker(entry),
5030
5068
  };
5031
5069
  }
5070
+ /** Takes `AUTH_EVENTS_SECRET` out of the app's env once the entry stops declaring events. The held value stays: the held store has no delete. */
5071
+ async dropAuthEventsEnv(appId) {
5072
+ const rows = await this.ports.coolify.listEnvs(appId);
5073
+ for (const row of rows) {
5074
+ if (row.key !== AUTH_EVENTS_ENV || !row.uuid)
5075
+ continue;
5076
+ try {
5077
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
5078
+ }
5079
+ catch (error) {
5080
+ if (!isNotFoundError(error))
5081
+ throw error;
5082
+ }
5083
+ }
5084
+ }
5032
5085
  /**
5033
5086
  * The install's edge label token: read from the held keys, else minted and held first, so a crash
5034
5087
  * after the `put` hands the same value out on the next apply. Read once more after the `put`, so two
@@ -5133,7 +5186,9 @@ export class FoundryStepExecutor {
5133
5186
  if (entry.workerApp) {
5134
5187
  // No Coolify app: the key is the held AUTH_APP_KEY the Worker app was given.
5135
5188
  const heldKey = await heldBrokerKey(this.ports.heldKeys, org, entry.key);
5136
- await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, heldKey, context);
5189
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, heldKey, context, {
5190
+ ...(await this.eventsInput(org, entry.key, wanted.want)),
5191
+ });
5137
5192
  return {};
5138
5193
  }
5139
5194
  const appId = recorded[`coolify:app:${action.key}`];
@@ -5144,9 +5199,20 @@ export class FoundryStepExecutor {
5144
5199
  if (!appKey || appKey.length < 32) {
5145
5200
  throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
5146
5201
  }
5147
- await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context, wanted.want.workspaces ? await this.workspacesInputs(org, entry.key) : {});
5202
+ await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context, {
5203
+ ...(wanted.want.workspaces ? await this.workspacesInputs(org, entry.key) : {}),
5204
+ ...(await this.eventsInput(org, entry.key, wanted.want)),
5205
+ });
5148
5206
  return {};
5149
5207
  }
5208
+ /** The held events secret of a broker entry that declares events, registered for redaction; nothing when it declares none. */
5209
+ async eventsInput(org, entryKey, want) {
5210
+ if (!want.mcp?.events)
5211
+ return {};
5212
+ const eventsSecret = await heldAuthEventsSecret(this.ports.heldKeys, org, entryKey);
5213
+ this.handledSecrets.add(eventsSecret);
5214
+ return { eventsSecret };
5215
+ }
5150
5216
  /**
5151
5217
  * What the broker entry needs beyond its want when it declares workspaces: the hash of the held key (the key
5152
5218
  * itself is registered for redaction and goes no further) and the operator's held database connection string.
@@ -5234,13 +5300,13 @@ export class FoundryStepExecutor {
5234
5300
  return {};
5235
5301
  }
5236
5302
  /** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
5237
- async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context, workspaces = {}) {
5303
+ async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context, extra = {}) {
5238
5304
  const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
5239
5305
  const result = await writeBrokerEntry(broker, {
5240
5306
  want,
5241
5307
  appKey,
5242
5308
  previous: previousRaw ? JSON.parse(previousRaw) : null,
5243
- ...workspaces,
5309
+ ...extra,
5244
5310
  });
5245
5311
  context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
5246
5312
  // Last: a run that failed above plans the step again.
@@ -2,7 +2,7 @@ import { type DeclaredOrg, type HostingEntry } from '../org.js';
2
2
  import type { DraftStep } from './diff.js';
3
3
  /**
4
4
  * F11: the paths of a gated entry that a machine calls, not a person: the ai service posts to
5
- * `uses.ai.webhookPath`, Boule calls `/api/customer-grants`. A gate (`gates.ts`) stops a machine, so each of
5
+ * `uses.ai.webhookPath`, Boule calls `/api/customer-grants`, the broker posts signed assistant events to `uses.auth.mcp.events.path`. A gate (`gates.ts`) stops a machine, so each of
6
6
  * these paths on each gated host gets its own path-scoped Access application (`<host><path>`) with a bypass
7
7
  * policy, the opposite of a private page's path gate. Cloudflare applies the most specific application, so
8
8
  * the bypass wins over the host's whole-host gate. The app itself checks the machine's credential.
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { isEdgePath } from '../entry-rules.js';
2
3
  import { isLiveEntry } from '../org.js';
3
4
  import { resolveGateHostnames } from './gates.js';
4
5
  import { entryHostnames, workspaceHost } from './hostnames.js';
@@ -6,7 +7,7 @@ import { appStepKey } from './platform-diff.js';
6
7
  import { workspaceEntries } from './workspace.js';
7
8
  /**
8
9
  * F11: the paths of a gated entry that a machine calls, not a person: the ai service posts to
9
- * `uses.ai.webhookPath`, Boule calls `/api/customer-grants`. A gate (`gates.ts`) stops a machine, so each of
10
+ * `uses.ai.webhookPath`, Boule calls `/api/customer-grants`, the broker posts signed assistant events to `uses.auth.mcp.events.path`. A gate (`gates.ts`) stops a machine, so each of
10
11
  * these paths on each gated host gets its own path-scoped Access application (`<host><path>`) with a bypass
11
12
  * policy, the opposite of a private page's path gate. Cloudflare applies the most specific application, so
12
13
  * the bypass wins over the host's whole-host gate. The app itself checks the machine's credential.
@@ -21,17 +22,16 @@ export function entryMachinePaths(entry) {
21
22
  paths.push(webhook);
22
23
  if (entry.uses?.customerGrants)
23
24
  paths.push(CUSTOMER_GRANTS_PATH);
25
+ const events = entry.uses?.auth?.mcp?.events?.path;
26
+ if (events !== undefined)
27
+ paths.push(events);
24
28
  return [...new Set(paths)];
25
29
  }
26
30
  /** The step key stem of one machine bypass: a hash of host and path, so it fits a key and needs no escaping. */
27
31
  export function machineBypassKey(host, path) {
28
32
  return `mp-${createHash('sha256').update(`${host}\0${path}`).digest('hex').slice(0, 16)}`;
29
33
  }
30
- /** What edge accepts as the path of a destination: segments of `a-z 0-9 . _ ~ -`, none empty, `.` or `..`, at most 200 characters. */
31
- const EDGE_PATH = /^(\/[a-z0-9._~-]+)+$/;
32
- const edgeAccepts = (path) => path.length <= 200 &&
33
- EDGE_PATH.test(path) &&
34
- !path.split('/').some((s) => s === '.' || s === '..');
34
+ const edgeAccepts = isEdgePath;
35
35
  /** The recorded `host/path` a machine bypass was made for, by its step key stem, read for the delete's wording. */
36
36
  export const machineBypassScopeKey = (stem) => `machine_bypass_${stem}`;
37
37
  /**
@@ -19,6 +19,7 @@ export const STAGE_SIGN_IN_NAMES = [
19
19
  'AUTH_COOKIE_SECRET',
20
20
  'WORKSPACES_URL',
21
21
  'WORKSPACES_API_KEY',
22
+ 'AUTH_EVENTS_SECRET',
22
23
  'MCP_AUDIENCE',
23
24
  'MCP_RESOURCE',
24
25
  'MCP_ISSUER',
@@ -89,6 +89,7 @@ export const STAGE_ALLOWLIST = {
89
89
  'ANALYTICS_SITE_KEY',
90
90
  'WORKSPACES_URL',
91
91
  'WORKSPACES_API_KEY',
92
+ 'AUTH_EVENTS_SECRET',
92
93
  'MCP_AUDIENCE',
93
94
  'MCP_RESOURCE',
94
95
  'MCP_ISSUER',
@@ -178,7 +178,7 @@ export interface WorkerAppWant {
178
178
  */
179
179
  secrets: {
180
180
  name: string;
181
- source: 'generated' | 'keysCustody' | 'heldSecret';
181
+ source: 'generated' | 'keysCustody' | 'heldSecret' | 'authEvents';
182
182
  hostId?: string;
183
183
  version?: string;
184
184
  }[];
@@ -4,6 +4,7 @@ import { APPS_DOMAIN, heldEnvRef } from '../org.js';
4
4
  import { WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
5
5
  import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
6
6
  import { entryAuthEnv } from './broker-entries.js';
7
+ import { AUTH_EVENTS_ENV, ensureAuthEventsSecret } from './entry-auth-events.js';
7
8
  import { workerAppHostname } from './hostnames.js';
8
9
  import { workspaceWorkerParent } from './workspace-worker.js';
9
10
  import { appStepKey } from './platform-diff.js';
@@ -165,6 +166,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
165
166
  missing.push(what);
166
167
  }
167
168
  secrets.push({ name: WORKER_APP_AUTH_KEY_SECRET, source: 'generated' });
169
+ // The signing secret of assistant events: made by rule 1 (`ensureAuthEventsSecret`), shared with the broker entry.
170
+ if (entry.uses?.auth?.mcp?.events)
171
+ secrets.push({ name: AUTH_EVENTS_ENV, source: 'authEvents' });
168
172
  if (entry.uses?.auth?.mcp) {
169
173
  for (const name of ['MCP_AUDIENCE', 'MCP_RESOURCE', 'MCP_ISSUER']) {
170
174
  if (env[name])
@@ -577,6 +581,23 @@ async function deliverSecrets(deps, org, entryKey, want, context, at, call) {
577
581
  if (delivered[secret.name] !== version)
578
582
  generated.push({ name: secret.name, value, version });
579
583
  }
584
+ for (const secret of want.secrets) {
585
+ if (secret.source !== 'authEvents')
586
+ continue;
587
+ const value = await ensureAuthEventsSecret({
588
+ orgId: org.id,
589
+ slug: org.slug,
590
+ entryKey,
591
+ heldKeys,
592
+ recorded: () => context.recordedIds(),
593
+ record: (key, recordedValue) => context.recordScopeId(key, recordedValue),
594
+ persist: () => context.persistScopeIds(),
595
+ secret: (held) => deps.secret?.(held),
596
+ });
597
+ const version = secretVersion(value);
598
+ if (delivered[secret.name] !== version)
599
+ generated.push({ name: secret.name, value, version });
600
+ }
580
601
  // Held secrets: the company's value, read from the held-key store. The plan found each one held; a
581
602
  // value gone since stops the step, and the error names the ref, never a value.
582
603
  for (const secret of want.secrets) {
@@ -1,6 +1,6 @@
1
1
  import type { DeclaredOrg, HostingEntry } from '../org.js';
2
2
  import type { UnmanagedItem } from '../plan.js';
3
- import { type BrokerEntryInput, type BrokerEntryWant } from './broker-entries.js';
3
+ import { type BrokerEntryInput, type BrokerEntryMcp, type BrokerEntryWant } from './broker-entries.js';
4
4
  import type { DraftStep } from './diff.js';
5
5
  import type { LiveResource } from './live.js';
6
6
  /** The step `coolify:workspace-broker-entry` and its action key, for the planner and the executor to share. */
@@ -10,12 +10,7 @@ export declare const WORKSPACE_BROKER_ENTRY_KIND = "workspace-broker-entry";
10
10
  * project in the test customers organisation (recorded by `zitadel:mcp-project` under the client's name), and the
11
11
  * onboarding page on the workspace host. `null` without `mcp` or until the project is recorded.
12
12
  */
13
- export declare function workspaceMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
14
- resource: string;
15
- projectId: string;
16
- loginHost: string;
17
- onboardingUrl?: string;
18
- } | null;
13
+ export declare function workspaceMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): BrokerEntryMcp | null;
19
14
  /**
20
15
  * What the app's env says about who signs in at the workspace, as recorded so far, or `null` until the
21
16
  * organisation, client and issuer are all known. Non-secret values only.
@@ -37,6 +37,8 @@ export function workspaceMcp(org, entry, recorded) {
37
37
  projectId,
38
38
  loginHost: loginHostnameFor(org),
39
39
  ...(mcp.onboardingPath ? { onboardingUrl: `https://${host}${mcp.onboardingPath}` } : {}),
40
+ ...(mcp.events ? { events: { url: `https://${host}${mcp.events.path}` } } : {}),
41
+ ...(mcp.managePath ? { manageUrl: `https://${host}${mcp.managePath}` } : {}),
40
42
  };
41
43
  }
42
44
  /**
@@ -90,7 +92,9 @@ export function liveClientIds(org, recorded) {
90
92
  /** What `ws.<key>.signin` records: the sign-in env above as JSON, or `null` while it is not all known. */
91
93
  export function workspaceSignInMarker(org, entry, recorded, issuer) {
92
94
  const env = workspaceAuthEnv(org, entry, recorded, issuer);
93
- return env ? JSON.stringify(env) : null;
95
+ if (!env)
96
+ return null;
97
+ return entry.uses?.auth?.mcp?.events ? `${JSON.stringify(env)}+events` : JSON.stringify(env);
94
98
  }
95
99
  /**
96
100
  * The test customers organisation, planned once an entry's workspace signs in customers. Sign-up and the emailed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.69.0",
3
+ "version": "0.70.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",