@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.
- package/dist/bootstrap/declare.js +2 -0
- package/dist/entry-rules.d.ts +2 -0
- package/dist/entry-rules.js +6 -0
- package/dist/migrations/0070_platform_usage_tail.sql +12 -0
- package/dist/modules/assistants.js +32 -1
- package/dist/org.d.ts +9 -2
- package/dist/org.js +14 -1
- package/dist/plan/blue-green-apply.js +8 -0
- package/dist/plan/broker-entries.d.ts +14 -8
- package/dist/plan/broker-entries.js +14 -2
- package/dist/plan/diff.js +28 -9
- package/dist/plan/edge-access.d.ts +9 -0
- package/dist/plan/edge-access.js +20 -0
- package/dist/plan/entry-auth-events.d.ts +53 -0
- package/dist/plan/entry-auth-events.js +112 -0
- package/dist/plan/executor.d.ts +4 -0
- package/dist/plan/executor.js +97 -14
- package/dist/plan/machine-paths.d.ts +1 -1
- package/dist/plan/machine-paths.js +6 -6
- package/dist/plan/own-diff.js +4 -1
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/platform-diff.d.ts +62 -0
- package/dist/plan/platform-diff.js +165 -4
- package/dist/plan/platform-providers.d.ts +16 -0
- package/dist/plan/platform-providers.js +86 -4
- package/dist/plan/stage-env.js +1 -0
- package/dist/plan/stage-isolation.js +1 -0
- package/dist/plan/usage-tail-worker.d.ts +29 -0
- package/dist/plan/usage-tail-worker.js +105 -0
- package/dist/plan/worker-app.d.ts +1 -1
- package/dist/plan/worker-app.js +21 -0
- package/dist/plan/workspace-sign-in.d.ts +2 -7
- package/dist/plan/workspace-sign-in.js +5 -1
- package/dist/platform.d.ts +20 -0
- package/dist/platform.js +14 -0
- package/dist/store/postgres.js +8 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- 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
|
}
|
package/dist/entry-rules.d.ts
CHANGED
|
@@ -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. */
|
package/dist/entry-rules.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
package/dist/plan/edge-access.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -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.
|