@wtfalch/foundry 0.69.0 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/bootstrap/declare.js +2 -0
  2. package/dist/entry-rules.d.ts +2 -0
  3. package/dist/entry-rules.js +6 -0
  4. package/dist/migrations/0070_platform_usage_tail.sql +12 -0
  5. package/dist/modules/assistants.js +32 -1
  6. package/dist/org.d.ts +9 -2
  7. package/dist/org.js +14 -1
  8. package/dist/plan/blue-green-apply.js +8 -0
  9. package/dist/plan/broker-entries.d.ts +14 -8
  10. package/dist/plan/broker-entries.js +14 -2
  11. package/dist/plan/diff.js +28 -9
  12. package/dist/plan/edge-access.d.ts +9 -0
  13. package/dist/plan/edge-access.js +20 -0
  14. package/dist/plan/entry-auth-events.d.ts +53 -0
  15. package/dist/plan/entry-auth-events.js +112 -0
  16. package/dist/plan/executor.d.ts +4 -0
  17. package/dist/plan/executor.js +97 -14
  18. package/dist/plan/machine-paths.d.ts +1 -1
  19. package/dist/plan/machine-paths.js +6 -6
  20. package/dist/plan/own-diff.js +4 -1
  21. package/dist/plan/plan-apply.d.ts +2 -0
  22. package/dist/plan/platform-diff.d.ts +62 -0
  23. package/dist/plan/platform-diff.js +165 -4
  24. package/dist/plan/platform-providers.d.ts +16 -0
  25. package/dist/plan/platform-providers.js +86 -4
  26. package/dist/plan/stage-env.js +1 -0
  27. package/dist/plan/stage-isolation.js +1 -0
  28. package/dist/plan/usage-tail-worker.d.ts +29 -0
  29. package/dist/plan/usage-tail-worker.js +105 -0
  30. package/dist/plan/worker-app.d.ts +1 -1
  31. package/dist/plan/worker-app.js +21 -0
  32. package/dist/plan/workspace-sign-in.d.ts +2 -7
  33. package/dist/plan/workspace-sign-in.js +5 -1
  34. package/dist/platform.d.ts +20 -0
  35. package/dist/platform.js +14 -0
  36. package/dist/store/postgres.js +8 -0
  37. package/dist/store/schema.d.ts +51 -0
  38. package/dist/store/schema.js +4 -0
  39. package/package.json +1 -1
@@ -170,6 +170,8 @@ export function declaredPlatformFor(apex, serverIpv4) {
170
170
  // only after Archon runs. Declared on with no pin, it would be reported
171
171
  // blocked and the bootstrap would never be complete.
172
172
  uptime: { enabled: false, tag: '', sha256: '' },
173
+ // foundry#1024: off until a human pins an edge release tag and digest, the same reason as uptime.
174
+ usageTail: { enabled: false, tag: '', sha256: '' },
173
175
  heartbeatsProvider: 'self-hosted',
174
176
  };
175
177
  }
@@ -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. */
@@ -0,0 +1,12 @@
1
+ -- @wtfalch/foundry: the usage tail Worker on the declared platform (foundry#1024).
2
+ -- Mirrors `src/store/schema.ts`'s `foundryDeclaredPlatform` additions.
3
+ -- `usage_tail_tag` and `usage_tail_sha256` are the pin of the edge release
4
+ -- whose built Worker foundry deploys; both empty means nothing is pinned
5
+ -- yet, and the planner reports that instead of planning the step. Unlike the
6
+ -- uptime Worker, this one is off by default: nothing has pinned a release
7
+ -- yet on any existing platform.
8
+
9
+ alter table foundry_declared_platform
10
+ add column if not exists usage_tail_enabled boolean not null default false,
11
+ add column if not exists usage_tail_tag text not null default '',
12
+ add column if not exists usage_tail_sha256 text not null default '';
@@ -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';
@@ -175,6 +176,18 @@ function liveResourceName(r) {
175
176
  const type = r.kind === 'dns_record' && typeof r.config.type === 'string' ? `${r.config.type} ` : '';
176
177
  return `${type}${name}, ${r.key}`;
177
178
  }
179
+ /**
180
+ * The provider's own name for a live resource, for a park or provision
181
+ * step's own prose (#1062, pain point 3): an operator told to "park the
182
+ * database" acted on the company's Coolify server, named after the same
183
+ * company, instead of the database resource the step meant. `null` when
184
+ * there is no live resource yet, or it carries no name -- unlike
185
+ * {@link liveResourceName} above, this never falls back to the bare key,
186
+ * since an id is not a name a step's own text should ever show.
187
+ */
188
+ function liveName(live) {
189
+ return typeof live?.config.name === 'string' && live.config.name !== '' ? live.config.name : null;
190
+ }
178
191
  /**
179
192
  * The diff engine: #19's "a plan reads live state from Zitadel, Coolify and
180
193
  * Cloudflare and diffs it against the declared org", built on #10's rule
@@ -1049,6 +1062,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1049
1062
  unmanaged.push(...appsDbRoute.blocked);
1050
1063
  unmanaged.push(...appsZones.blocked);
1051
1064
  unmanaged.push(...entryJobs.blocked);
1065
+ unmanaged.push(...authEventsBlocked(org, recordedIds));
1052
1066
  unmanaged.push(...entryAudit.blocked);
1053
1067
  unmanaged.push(...entryRepoKeys.blocked);
1054
1068
  unmanaged.push(...edgeReservedLabels.blocked);
@@ -3164,7 +3178,7 @@ function planKosmos(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
3164
3178
  if (!kosmosOn(org)) {
3165
3179
  // Switched off: park a live app (stop it, keep the database), like CMS and calls. A stopped app is
3166
3180
  // already parked, so the plan reaches empty.
3167
- const parked = planHostingToggle('coolify', 'kosmos', false, org, byKey, claimed, () => `provision ${org.slug}'s kosmos`, () => `park ${org.slug}'s kosmos (stops serving; its database is retained, not deleted)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
3181
+ const parked = planHostingToggle('coolify', 'kosmos', false, org, byKey, claimed, () => `provision ${org.slug}'s kosmos`, (live) => `park ${org.slug}'s kosmos${liveName(live) ? ` ${liveName(live)}` : ''} (stops serving; its database is retained, not deleted)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
3168
3182
  return { step: parked, blocked: null };
3169
3183
  }
3170
3184
  const key = resourceKey('coolify', 'kosmos', org.id);
@@ -3531,7 +3545,11 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3531
3545
  * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
3532
3546
  * the org's own app, database or tunnel configuration, it parks it.
3533
3547
  */
3534
- function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
3548
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed,
3549
+ // #1062: takes the live resource so a caller can name it (Coolify's own
3550
+ // name for it, from `config.name`), rather than a generic noun a park or
3551
+ // provision step's own text can be told apart from the server it runs on.
3552
+ describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
3535
3553
  const key = resourceKey(system, kind, stepKey);
3536
3554
  const live = byKey.get(key);
3537
3555
  // A live resource is on unless it says it is parked. Providers' own
@@ -3549,7 +3567,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
3549
3567
  return null;
3550
3568
  return {
3551
3569
  action: { system, kind, key: stepKey, desired: { enabled: false } },
3552
- step: { description: describePark(), destroys: true, migrates: false },
3570
+ step: { description: describePark(live), destroys: true, migrates: false },
3553
3571
  };
3554
3572
  }
3555
3573
  if (live)
@@ -3560,7 +3578,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
3560
3578
  return null;
3561
3579
  return {
3562
3580
  action: { system, kind, key: stepKey, desired: { enabled: true } },
3563
- step: { description: describeProvision(), destroys: false, migrates: false },
3581
+ step: { description: describeProvision(live), destroys: false, migrates: false },
3564
3582
  };
3565
3583
  }
3566
3584
  /**
@@ -3699,7 +3717,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3699
3717
  });
3700
3718
  }
3701
3719
  const refused = noRepo || composeBuilt;
3702
- const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}${volumesNote(entry)}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
3720
+ const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !refused, org, byKey, claimed, () => `provision ${org.slug}'s ${label}${volumesNote(entry)}`, (live) => `park ${org.slug}'s ${label}${liveName(live) ? ` ${liveName(live)}` : ''} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
3703
3721
  // #447: only the live app's own `git_repository` decides, never the
3704
3722
  // declared entry (a `repo: null` entry can sit over a bespoke app, which is
3705
3723
  // parked as before). Only the `app` key ever held the default app, and an
@@ -3888,8 +3906,9 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3888
3906
  // F5 (rule 3): who signs in, which client and which issuer reach an app
3889
3907
  // that is already live. `pushAppEnv` records what it pushed.
3890
3908
  if (entry.uses?.auth &&
3891
- JSON.stringify(entryAuthEnv(org, entry, recordedIds, authIssuer)) !==
3892
- (recordedIds[entryAuthEnvAppliedScopeKey(entryKey)] ?? '{}')) {
3909
+ (JSON.stringify(entryAuthEnv(org, entry, recordedIds, authIssuer)) !==
3910
+ (recordedIds[entryAuthEnvAppliedScopeKey(entryKey)] ?? '{}') ||
3911
+ authEventsMarker(entry) !== (recordedIds[authEventsAppliedScopeKey(entryKey)] ?? ''))) {
3893
3912
  return {
3894
3913
  step: {
3895
3914
  action,
@@ -4250,7 +4269,7 @@ function planDatabase(org, byKey, claimed, recordedIds) {
4250
4269
  if (!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds)) {
4251
4270
  return null;
4252
4271
  }
4253
- return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
4272
+ return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, (live) => `park ${org.slug}'s database${liveName(live) ? ` ${liveName(live)}` : ''} (data is retained, not deleted -- #7 corrections 1)`, org.id, (live) => !/^(exited|stopped)/.test(String(live.config.status ?? '')));
4254
4273
  }
4255
4274
  function sortedNames(names) {
4256
4275
  return names.filter(Boolean).sort().join(',');
@@ -4295,7 +4314,7 @@ function planTunnel(org, byKey, claimed, recordedIds, platformApex) {
4295
4314
  appsOn(org) ||
4296
4315
  // A company without a domain still gets a tunnel of its own for an entry at `<slug>.oikos.host`.
4297
4316
  entryRoutes(org, recordedIds).length > 0;
4298
- const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
4317
+ const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, (live) => `park ${org.slug}'s Cloudflare tunnel${liveName(live) ? ` ${liveName(live)}` : ''} (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
4299
4318
  // Cloudflare's status, read against what is declared: a park cannot change
4300
4319
  // it, so judging on/off by status alone would never let the plan empty.
4301
4320
  // Wanted on: only `down` needs a repair (`inactive` is a fresh tunnel
@@ -115,6 +115,15 @@ export interface AccessGateInput {
115
115
  emails: string[];
116
116
  }
117
117
  export declare const isNotFound: (error: unknown) => boolean;
118
+ /**
119
+ * Throws the operator-facing message when `error` is edge's generic 404 for
120
+ * a route this SDK expects but the running release predates (#1061, pain
121
+ * point 12: production edge six days behind the SDK failed foundry's first
122
+ * apps step with the raw "Route not found" and no hint why). Returns
123
+ * otherwise, so the caller's own handling of `error` -- a conflict branch,
124
+ * an `isNotFound`/`isNotFoundError` "already gone" check -- still runs.
125
+ */
126
+ export declare function edgeTooOld(error: unknown, what: string): void;
118
127
  /**
119
128
  * Edge answers a policy, delete or create call for a live Access app with no
120
129
  * edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
@@ -3,6 +3,26 @@ import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-d
3
3
  export const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
4
4
  /** Edge's 409 for a live app on the domain with no edge row. */
5
5
  const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
6
+ /**
7
+ * Edge's generic 404 fallback for a route no release of it answers yet
8
+ * (`service.ts`'s `errorResponse('not_found', 'Route not found')`), as
9
+ * opposed to a resource-specific 404 ("Key not found" and the like), which
10
+ * never carries this exact message.
11
+ */
12
+ const isRouteMissing = (error) => error instanceof EdgeApiError && error.status === 404 && error.message === 'Route not found';
13
+ /**
14
+ * Throws the operator-facing message when `error` is edge's generic 404 for
15
+ * a route this SDK expects but the running release predates (#1061, pain
16
+ * point 12: production edge six days behind the SDK failed foundry's first
17
+ * apps step with the raw "Route not found" and no hint why). Returns
18
+ * otherwise, so the caller's own handling of `error` -- a conflict branch,
19
+ * an `isNotFound`/`isNotFoundError` "already gone" check -- still runs.
20
+ */
21
+ export function edgeTooOld(error, what) {
22
+ if (isRouteMissing(error)) {
23
+ throw new Error(`${what} is blocked: the running edge has no route for this step; deploy edge to the release foundry expects`);
24
+ }
25
+ }
6
26
  /**
7
27
  * Edge answers a policy, delete or create call for a live Access app with no
8
28
  * edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
@@ -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.