@wtfalch/foundry 0.68.3 → 0.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/seal.d.ts +2 -0
- package/dist/audit/seal.js +50 -4
- package/dist/audit/sealer-role.d.ts +1 -1
- package/dist/audit/sealer-role.js +1 -1
- package/dist/audit/tick.js +1 -0
- package/dist/entry-rules.d.ts +2 -0
- package/dist/entry-rules.js +6 -0
- package/dist/modules/assistants.js +32 -1
- package/dist/modules/audit-ledger.d.ts +7 -5
- package/dist/modules/audit-ledger.js +9 -5
- 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 +5 -2
- 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 +72 -6
- package/dist/plan/machine-paths.d.ts +1 -1
- package/dist/plan/machine-paths.js +6 -6
- package/dist/plan/stage-env.js +1 -0
- package/dist/plan/stage-isolation.js +1 -0
- package/dist/plan/worker-app.d.ts +3 -1
- package/dist/plan/worker-app.js +31 -0
- package/dist/plan/workspace-sign-in.d.ts +2 -7
- package/dist/plan/workspace-sign-in.js +5 -1
- package/package.json +1 -1
package/dist/audit/seal.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface SealResult {
|
|
|
28
28
|
treeSize?: number;
|
|
29
29
|
/** How many checkpoints the authority stamped in this tick. */
|
|
30
30
|
anchored: number;
|
|
31
|
+
/** How many erased rows this tick sealed the erasure of; on a sweep that failed part way, the rows sealed before the failure. */
|
|
32
|
+
erasures: number;
|
|
31
33
|
/** Plain words on why a step did not happen. Present whenever `outcome` is not `sealed`, or anchoring did not run. */
|
|
32
34
|
reason?: string;
|
|
33
35
|
at: string;
|
package/dist/audit/seal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { anchorCheckpoints, sealCheckpoint } from '@wtfalch/audit-sealing';
|
|
1
|
+
import { anchorCheckpoints, computeErasureHash, sealCheckpoint } from '@wtfalch/audit-sealing';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
2
3
|
import { isUnavailable } from './connector.js';
|
|
3
4
|
/**
|
|
4
5
|
* The sealing and anchoring tick (foundry#929, audit#56). The host calls {@link sealLedgers} on
|
|
@@ -51,6 +52,7 @@ async function sealOne(deps, ledger, now) {
|
|
|
51
52
|
...base,
|
|
52
53
|
outcome: 'failed',
|
|
53
54
|
anchored: 0,
|
|
55
|
+
erasures: 0,
|
|
54
56
|
reason: `could not open the ledger: ${plainFailure(error)}`,
|
|
55
57
|
at: now().toISOString(),
|
|
56
58
|
};
|
|
@@ -60,6 +62,7 @@ async function sealOne(deps, ledger, now) {
|
|
|
60
62
|
...base,
|
|
61
63
|
outcome: 'skipped',
|
|
62
64
|
anchored: 0,
|
|
65
|
+
erasures: 0,
|
|
63
66
|
reason: `not sealed, the ledger is not reachable: ${connection.unavailable}`,
|
|
64
67
|
at: now().toISOString(),
|
|
65
68
|
};
|
|
@@ -68,17 +71,33 @@ async function sealOne(deps, ledger, now) {
|
|
|
68
71
|
let outcome = 'unchanged';
|
|
69
72
|
let treeSize;
|
|
70
73
|
let anchored = 0;
|
|
74
|
+
let erasures = 0;
|
|
71
75
|
try {
|
|
76
|
+
const progress = { sealed: 0 };
|
|
77
|
+
try {
|
|
78
|
+
await sealErasures(connection.handle, progress);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (isMissingGrant(error)) {
|
|
82
|
+
notes.push("erasures not sealed yet: the sealing role gets the grant on the app's next apply");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
outcome = 'failed';
|
|
86
|
+
notes.push(`sealing erasures failed: ${plainFailure(error)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
erasures = progress.sealed;
|
|
72
90
|
try {
|
|
73
91
|
const checkpoint = await sealCheckpoint(connection.handle, {
|
|
74
92
|
ledger: ledger.ledger,
|
|
75
93
|
signer: deps.signer,
|
|
76
94
|
});
|
|
77
95
|
if (checkpoint) {
|
|
78
|
-
outcome
|
|
96
|
+
if (outcome !== 'failed')
|
|
97
|
+
outcome = 'sealed';
|
|
79
98
|
treeSize = checkpoint.tree_size;
|
|
80
99
|
}
|
|
81
|
-
else {
|
|
100
|
+
else if (outcome !== 'failed') {
|
|
82
101
|
notes.push('nothing sealed, no entry since the last checkpoint');
|
|
83
102
|
}
|
|
84
103
|
}
|
|
@@ -87,7 +106,8 @@ async function sealOne(deps, ledger, now) {
|
|
|
87
106
|
...base,
|
|
88
107
|
outcome: 'failed',
|
|
89
108
|
anchored: 0,
|
|
90
|
-
|
|
109
|
+
erasures,
|
|
110
|
+
reason: [`sealing failed: ${plainFailure(error)}`, ...notes].join('; '),
|
|
91
111
|
at: now().toISOString(),
|
|
92
112
|
};
|
|
93
113
|
}
|
|
@@ -125,7 +145,33 @@ async function sealOne(deps, ledger, now) {
|
|
|
125
145
|
outcome,
|
|
126
146
|
...(treeSize === undefined ? {} : { treeSize }),
|
|
127
147
|
anchored,
|
|
148
|
+
erasures,
|
|
128
149
|
...(notes.length > 0 ? { reason: notes.join('; ') } : {}),
|
|
129
150
|
at: now().toISOString(),
|
|
130
151
|
};
|
|
131
152
|
}
|
|
153
|
+
const rowsOf = (result) => Array.isArray(result) ? result : (result.rows ?? []);
|
|
154
|
+
/**
|
|
155
|
+
* An existing sealing role gets the erasure grants only on its app's next apply, so 42501 (insufficient privilege)
|
|
156
|
+
* and 42883 (undefined function) mean "not yet", not broken. Read on the error or the driver error it wraps.
|
|
157
|
+
*/
|
|
158
|
+
function isMissingGrant(error) {
|
|
159
|
+
for (let e = error, depth = 0; e && depth < 3; depth++) {
|
|
160
|
+
const code = e.code;
|
|
161
|
+
if (code === '42501' || code === '42883')
|
|
162
|
+
return true;
|
|
163
|
+
e = e.cause;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
async function sealErasures(handle, progress) {
|
|
168
|
+
const pending = rowsOf(await handle.execute(sql `select id, row_hash, erased_at from audit_pending_erasures()`));
|
|
169
|
+
for (const row of pending) {
|
|
170
|
+
if (!row.row_hash || row.erased_at === null || row.erased_at === undefined)
|
|
171
|
+
continue;
|
|
172
|
+
const erasedAt = row.erased_at instanceof Date ? row.erased_at : new Date(String(row.erased_at));
|
|
173
|
+
const erasureHash = await computeErasureHash(row.row_hash, erasedAt);
|
|
174
|
+
await handle.execute(sql `select audit_seal_erasure(${row.id}, ${erasureHash})`);
|
|
175
|
+
progress.sealed++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -26,7 +26,7 @@ export interface SealerRoleSql {
|
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Makes `user` true: it can log in with `password`, is no superuser and bypasses no row security, may read and
|
|
29
|
-
* insert into the checkpoint, signing-key and anchor tables and run the
|
|
29
|
+
* insert into the checkpoint, signing-key and anchor tables and run the sealing functions (leaves, key retirement, pending erasures, erasure sealing), and holds nothing
|
|
30
30
|
* else. The grants are listed here and in `AUDIT_SEALER_GRANTS`; `audit/sealer-role.test.ts` runs a real
|
|
31
31
|
* checkpoint as this role to prove the list is enough.
|
|
32
32
|
*/
|
|
@@ -36,7 +36,7 @@ const ident = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
|
36
36
|
const literal = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
37
37
|
/**
|
|
38
38
|
* Makes `user` true: it can log in with `password`, is no superuser and bypasses no row security, may read and
|
|
39
|
-
* insert into the checkpoint, signing-key and anchor tables and run the
|
|
39
|
+
* insert into the checkpoint, signing-key and anchor tables and run the sealing functions (leaves, key retirement, pending erasures, erasure sealing), and holds nothing
|
|
40
40
|
* else. The grants are listed here and in `AUDIT_SEALER_GRANTS`; `audit/sealer-role.test.ts` runs a real
|
|
41
41
|
* checkpoint as this role to prove the list is enough.
|
|
42
42
|
*/
|
package/dist/audit/tick.js
CHANGED
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. */
|
|
@@ -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
|
};
|
|
@@ -12,13 +12,15 @@ export declare const AUDIT_PACKAGE_VERSION = "0.14.0";
|
|
|
12
12
|
/** Insert and read only: the runtime role has UPDATE, DELETE and TRUNCATE revoked on each. */
|
|
13
13
|
export declare const AUDIT_APPEND_ONLY_TABLES: readonly string[];
|
|
14
14
|
/**
|
|
15
|
-
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the
|
|
16
|
-
* functions (`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the sealing
|
|
16
|
+
* functions (`audit_chain_leaves`, `audit_retire_signing_key`) and the erasure sealing functions
|
|
17
|
+
* (`audit_pending_erasures`, `audit_seal_erasure`), which only the platform's sealing role runs. An app may list
|
|
18
|
+
* `audit_erase_person(text, text, text)` itself and nothing else from the erase set: it pseudonymises, and the
|
|
19
|
+
* sealing role seals each erasure in the next seal tick (William, 2026-10-26, settles foundry#929 item 10 for
|
|
20
|
+
* this case).
|
|
19
21
|
*/
|
|
20
22
|
export declare const AUDIT_APP_GRANTS: readonly string[];
|
|
21
|
-
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key. */
|
|
23
|
+
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key, seals erasures. */
|
|
22
24
|
export declare const AUDIT_SEALER_GRANTS: readonly string[];
|
|
23
25
|
type Database = NonNullable<WorkerAppConfig['database']>;
|
|
24
26
|
/**
|
|
@@ -16,16 +16,20 @@ export const AUDIT_APPEND_ONLY_TABLES = [
|
|
|
16
16
|
'audit_anchors',
|
|
17
17
|
];
|
|
18
18
|
/**
|
|
19
|
-
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the
|
|
20
|
-
* functions (`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* What the app's runtime role may run: the two functions the chain is written through. Not granted: the sealing
|
|
20
|
+
* functions (`audit_chain_leaves`, `audit_retire_signing_key`) and the erasure sealing functions
|
|
21
|
+
* (`audit_pending_erasures`, `audit_seal_erasure`), which only the platform's sealing role runs. An app may list
|
|
22
|
+
* `audit_erase_person(text, text, text)` itself and nothing else from the erase set: it pseudonymises, and the
|
|
23
|
+
* sealing role seals each erasure in the next seal tick (William, 2026-10-26, settles foundry#929 item 10 for
|
|
24
|
+
* this case).
|
|
23
25
|
*/
|
|
24
26
|
export const AUDIT_APP_GRANTS = ['audit_chain_tail()', 'audit_chain_tail_v2()'];
|
|
25
|
-
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key. */
|
|
27
|
+
/** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key, seals erasures. */
|
|
26
28
|
export const AUDIT_SEALER_GRANTS = [
|
|
27
29
|
'audit_chain_leaves(bigint, integer)',
|
|
28
30
|
'audit_retire_signing_key(text)',
|
|
31
|
+
'audit_pending_erasures()',
|
|
32
|
+
'audit_seal_erasure(bigint, text)',
|
|
29
33
|
];
|
|
30
34
|
const without = (list, drop) => (list ?? []).filter((item) => !drop.includes(item));
|
|
31
35
|
/**
|
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';
|
|
@@ -1049,6 +1050,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
1049
1050
|
unmanaged.push(...appsDbRoute.blocked);
|
|
1050
1051
|
unmanaged.push(...appsZones.blocked);
|
|
1051
1052
|
unmanaged.push(...entryJobs.blocked);
|
|
1053
|
+
unmanaged.push(...authEventsBlocked(org, recordedIds));
|
|
1052
1054
|
unmanaged.push(...entryAudit.blocked);
|
|
1053
1055
|
unmanaged.push(...entryRepoKeys.blocked);
|
|
1054
1056
|
unmanaged.push(...edgeReservedLabels.blocked);
|
|
@@ -3888,8 +3890,9 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
3888
3890
|
// F5 (rule 3): who signs in, which client and which issuer reach an app
|
|
3889
3891
|
// that is already live. `pushAppEnv` records what it pushed.
|
|
3890
3892
|
if (entry.uses?.auth &&
|
|
3891
|
-
JSON.stringify(entryAuthEnv(org, entry, recordedIds, authIssuer)) !==
|
|
3892
|
-
(recordedIds[entryAuthEnvAppliedScopeKey(entryKey)] ?? '{}')
|
|
3893
|
+
(JSON.stringify(entryAuthEnv(org, entry, recordedIds, authIssuer)) !==
|
|
3894
|
+
(recordedIds[entryAuthEnvAppliedScopeKey(entryKey)] ?? '{}') ||
|
|
3895
|
+
authEventsMarker(entry) !== (recordedIds[authEventsAppliedScopeKey(entryKey)] ?? ''))) {
|
|
3893
3896
|
return {
|
|
3894
3897
|
step: {
|
|
3895
3898
|
action,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* `uses.auth.mcp.events`: the broker posts signed assistant events to the app. Foundry makes one signing secret
|
|
4
|
+
* per broker entry (`<entry>` and `<entry>-work` each have their own), holds it, and gives it to two places: the
|
|
5
|
+
* broker entry (`mcp.events.secret`) and the app (`AUTH_EVENTS_SECRET`). The plan and the recorded markers carry
|
|
6
|
+
* only whether events are on and the address, never the secret.
|
|
7
|
+
*/
|
|
8
|
+
/** The app's env name, and the Worker app's secret name. */
|
|
9
|
+
export declare const AUTH_EVENTS_ENV = "AUTH_EVENTS_SECRET";
|
|
10
|
+
/** The secret was made and is not held yet: the ref, never the secret (CLAUDE.md, Platform steps, rule 1). */
|
|
11
|
+
export declare const authEventsUndeliveredScopeKey: (entryKey: string) => string;
|
|
12
|
+
/** What the app's env was last written with for events: `on` or empty. A change replans the app step. */
|
|
13
|
+
export declare const authEventsAppliedScopeKey: (entryKey: string) => string;
|
|
14
|
+
/** The held-key ref of an entry's events secret. At most 127 characters for an entry key of up to 63. */
|
|
15
|
+
export declare const authEventsSecretRef: (orgId: string, entryKey: string) => string;
|
|
16
|
+
/** The hold is tried this many times (rule 1). */
|
|
17
|
+
export declare const AUTH_EVENTS_HOLD_TRIES = 3;
|
|
18
|
+
/** Every held-key read and write is one outside call. */
|
|
19
|
+
export declare const AUTH_EVENTS_HELD_TIMEOUT_MS = 15000;
|
|
20
|
+
/** `whsec_` and the base64 of 32 random bytes: the Standard Webhooks secret format. */
|
|
21
|
+
export declare const newAuthEventsSecret: () => string;
|
|
22
|
+
/** The entry declares `uses.auth.mcp.events`. */
|
|
23
|
+
export declare const authEventsWanted: (entry: HostingEntry) => boolean;
|
|
24
|
+
/** What `auth_events_applied.<entry>` holds for an entry. */
|
|
25
|
+
export declare const authEventsMarker: (entry: HostingEntry) => string;
|
|
26
|
+
interface HeldKeys {
|
|
27
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
28
|
+
put?(ref: string, value: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The entry's events secret: read from the held keys, else made and held. The marker is recorded and persisted
|
|
32
|
+
* before the hold, so a crash after the mint never mints again: a recorded marker with nothing held stops here
|
|
33
|
+
* and names the ref. The hold is tried 3 times with the same secret and the marker is cleared after it. Errors
|
|
34
|
+
* name the entry and the ref, never the secret or the cause. `secret` registers the value for redaction first.
|
|
35
|
+
*/
|
|
36
|
+
export declare function ensureAuthEventsSecret(input: {
|
|
37
|
+
orgId: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
entryKey: string;
|
|
40
|
+
heldKeys: HeldKeys | undefined;
|
|
41
|
+
recorded: () => Readonly<Record<string, string>>;
|
|
42
|
+
record: (key: string, value: string) => void;
|
|
43
|
+
persist: () => Promise<void>;
|
|
44
|
+
secret: (value: string) => void;
|
|
45
|
+
}): Promise<string>;
|
|
46
|
+
/** The secret as already held, or an error that names the ref. A broker-entry step only reads. */
|
|
47
|
+
export declare function heldAuthEventsSecret(heldKeys: Pick<HeldKeys, 'resolve'> | undefined, org: Pick<DeclaredOrg, 'id' | 'slug'>, entryKey: string, step?: string): Promise<string>;
|
|
48
|
+
/** One blocked line per recorded marker, for the planner to show. */
|
|
49
|
+
export declare function authEventsBlocked(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Array<{
|
|
50
|
+
description: string;
|
|
51
|
+
key: string;
|
|
52
|
+
}>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { withTimeout } from './call-timeout.js';
|
|
3
|
+
/**
|
|
4
|
+
* `uses.auth.mcp.events`: the broker posts signed assistant events to the app. Foundry makes one signing secret
|
|
5
|
+
* per broker entry (`<entry>` and `<entry>-work` each have their own), holds it, and gives it to two places: the
|
|
6
|
+
* broker entry (`mcp.events.secret`) and the app (`AUTH_EVENTS_SECRET`). The plan and the recorded markers carry
|
|
7
|
+
* only whether events are on and the address, never the secret.
|
|
8
|
+
*/
|
|
9
|
+
/** The app's env name, and the Worker app's secret name. */
|
|
10
|
+
export const AUTH_EVENTS_ENV = 'AUTH_EVENTS_SECRET';
|
|
11
|
+
/** The secret was made and is not held yet: the ref, never the secret (CLAUDE.md, Platform steps, rule 1). */
|
|
12
|
+
export const authEventsUndeliveredScopeKey = (entryKey) => `auth_events_secret_undelivered.${entryKey}`;
|
|
13
|
+
/** What the app's env was last written with for events: `on` or empty. A change replans the app step. */
|
|
14
|
+
export const authEventsAppliedScopeKey = (entryKey) => `auth_events_applied.${entryKey}`;
|
|
15
|
+
/** The held-key ref of an entry's events secret. At most 127 characters for an entry key of up to 63. */
|
|
16
|
+
export const authEventsSecretRef = (orgId, entryKey) => `foundry:auth-events-secret:${orgId}:${entryKey}`;
|
|
17
|
+
/** The hold is tried this many times (rule 1). */
|
|
18
|
+
export const AUTH_EVENTS_HOLD_TRIES = 3;
|
|
19
|
+
/** Every held-key read and write is one outside call. */
|
|
20
|
+
export const AUTH_EVENTS_HELD_TIMEOUT_MS = 15_000;
|
|
21
|
+
/** `whsec_` and the base64 of 32 random bytes: the Standard Webhooks secret format. */
|
|
22
|
+
export const newAuthEventsSecret = () => `whsec_${randomBytes(32).toString('base64')}`;
|
|
23
|
+
/** The entry declares `uses.auth.mcp.events`. */
|
|
24
|
+
export const authEventsWanted = (entry) => entry.uses?.auth?.mcp?.events !== undefined;
|
|
25
|
+
/** What `auth_events_applied.<entry>` holds for an entry. */
|
|
26
|
+
export const authEventsMarker = (entry) => authEventsWanted(entry) ? 'on' : '';
|
|
27
|
+
/**
|
|
28
|
+
* The entry's events secret: read from the held keys, else made and held. The marker is recorded and persisted
|
|
29
|
+
* before the hold, so a crash after the mint never mints again: a recorded marker with nothing held stops here
|
|
30
|
+
* and names the ref. The hold is tried 3 times with the same secret and the marker is cleared after it. Errors
|
|
31
|
+
* name the entry and the ref, never the secret or the cause. `secret` registers the value for redaction first.
|
|
32
|
+
*/
|
|
33
|
+
export async function ensureAuthEventsSecret(input) {
|
|
34
|
+
const { entryKey } = input;
|
|
35
|
+
const ref = authEventsSecretRef(input.orgId, entryKey);
|
|
36
|
+
const marker = authEventsUndeliveredScopeKey(entryKey);
|
|
37
|
+
const at = `coolify:app for ${input.slug}`;
|
|
38
|
+
const heldKeys = input.heldKeys;
|
|
39
|
+
const held = await withTimeout('held key read', AUTH_EVENTS_HELD_TIMEOUT_MS, async () => heldKeys?.resolve(ref));
|
|
40
|
+
if (held) {
|
|
41
|
+
input.secret(held);
|
|
42
|
+
if (input.recorded()[marker]) {
|
|
43
|
+
input.record(marker, '');
|
|
44
|
+
await input.persist();
|
|
45
|
+
}
|
|
46
|
+
return held;
|
|
47
|
+
}
|
|
48
|
+
if (input.recorded()[marker]) {
|
|
49
|
+
throw new Error(`${at}: the events secret of entry '${entryKey}' was made but never held (ref ${ref}); it is not made again. Hold a value at that ref, or remove the recorded key ${marker}`);
|
|
50
|
+
}
|
|
51
|
+
const put = heldKeys?.put;
|
|
52
|
+
if (typeof put !== 'function') {
|
|
53
|
+
throw new Error(`${at}: the held keys cannot put the events secret of entry '${entryKey}'`);
|
|
54
|
+
}
|
|
55
|
+
const value = newAuthEventsSecret();
|
|
56
|
+
input.secret(value);
|
|
57
|
+
input.record(marker, ref);
|
|
58
|
+
await input.persist();
|
|
59
|
+
for (let attempt = 1; attempt <= AUTH_EVENTS_HOLD_TRIES; attempt++) {
|
|
60
|
+
let stored = false;
|
|
61
|
+
try {
|
|
62
|
+
await withTimeout('held key write', AUTH_EVENTS_HELD_TIMEOUT_MS, () => put.call(heldKeys, ref, value));
|
|
63
|
+
stored = true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// The cause can echo the value; the ref is all a reader needs.
|
|
67
|
+
}
|
|
68
|
+
if (!stored)
|
|
69
|
+
continue;
|
|
70
|
+
input.record(marker, '');
|
|
71
|
+
await input.persist();
|
|
72
|
+
// Two applies that both found nothing held both mint and the later put wins: let a put that is
|
|
73
|
+
// landing finish, then end on what is held. A put that lands later than that is not seen here.
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
75
|
+
let final = value;
|
|
76
|
+
try {
|
|
77
|
+
final =
|
|
78
|
+
(await withTimeout('held key read', AUTH_EVENTS_HELD_TIMEOUT_MS, async () => heldKeys?.resolve(ref))) || value;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// The put went through, so the apply goes on with the value just put; the cause can echo a value.
|
|
82
|
+
console.warn(`${at}: the events secret of entry '${entryKey}' could not be read back after it was held (${error?.name ?? 'Error'})`);
|
|
83
|
+
}
|
|
84
|
+
input.secret(final);
|
|
85
|
+
return final;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`${at}: the events secret of entry '${entryKey}' was made but could not be held after ${AUTH_EVENTS_HOLD_TRIES} tries (ref ${ref})`);
|
|
88
|
+
}
|
|
89
|
+
/** The secret as already held, or an error that names the ref. A broker-entry step only reads. */
|
|
90
|
+
export async function heldAuthEventsSecret(heldKeys, org, entryKey, step = 'coolify:broker-entry') {
|
|
91
|
+
const ref = authEventsSecretRef(org.id, entryKey);
|
|
92
|
+
const value = await withTimeout('held key read', AUTH_EVENTS_HELD_TIMEOUT_MS, async () => heldKeys?.resolve(ref));
|
|
93
|
+
if (!value) {
|
|
94
|
+
throw new Error(`${step} for ${org.slug}: the events secret of entry '${entryKey}' is not held yet (ref ${ref}); the app step makes it`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
/** One blocked line per recorded marker, for the planner to show. */
|
|
99
|
+
export function authEventsBlocked(org, recorded) {
|
|
100
|
+
return Object.entries(recorded).flatMap(([key, id]) => {
|
|
101
|
+
const prefix = authEventsUndeliveredScopeKey('');
|
|
102
|
+
if (!id || !key.startsWith(prefix))
|
|
103
|
+
return [];
|
|
104
|
+
const entryKey = key.slice(prefix.length);
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
description: `${org.slug}'s ${entryKey} events secret was made but never held (ref ${id}); it is not made again. Hold a value at that ref, or remove the recorded key ${key}`,
|
|
108
|
+
key: `coolify:auth-events:${entryKey}`,
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
});
|
|
112
|
+
}
|
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.
|
package/dist/plan/executor.js
CHANGED
|
@@ -43,6 +43,7 @@ import { createEdgeZones } from './edge-zones.js';
|
|
|
43
43
|
import { AI_ENTRY_KEY_DAYS, AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
|
|
44
44
|
import { ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS, entryAnalyticsAppliedScopeKey, entryAnalyticsRetiredScopeKey, entryAnalyticsUndeliveredScopeKey, stageAnalyticsKeyName, } from './entry-analytics-key.js';
|
|
45
45
|
import { applyEntryAudit } from './entry-audit.js';
|
|
46
|
+
import { AUTH_EVENTS_ENV, authEventsAppliedScopeKey, authEventsMarker, authEventsWanted, ensureAuthEventsSecret, heldAuthEventsSecret, } from './entry-auth-events.js';
|
|
46
47
|
import { CUSTOMER_GRANTS_CREDENTIAL_ENV, CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsJson, customerGrantsMintedScopeKey, customerGrantsRef, customerGrantsToDrop, ensureCustomerGrantsCredential, wantedCustomerGrants, } from './entry-customer-grants.js';
|
|
47
48
|
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, EDGE_SLUG_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, bouleEdgeKeyWanted, edgeKeyAppRecordKey, edgeKeyDomain, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
48
49
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
@@ -3485,6 +3486,21 @@ export class FoundryStepExecutor {
|
|
|
3485
3486
|
});
|
|
3486
3487
|
env.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
|
|
3487
3488
|
}
|
|
3489
|
+
if (authEventsWanted(entry)) {
|
|
3490
|
+
env.push({
|
|
3491
|
+
key: AUTH_EVENTS_ENV,
|
|
3492
|
+
value: await ensureAuthEventsSecret({
|
|
3493
|
+
orgId: org.id,
|
|
3494
|
+
slug: org.slug,
|
|
3495
|
+
entryKey: workspaceClientKey(entry.key),
|
|
3496
|
+
heldKeys,
|
|
3497
|
+
recorded: () => context.recordedIds(),
|
|
3498
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
3499
|
+
persist: () => context.persistScopeIds(),
|
|
3500
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
3501
|
+
}),
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3488
3504
|
const workspacesKeys = [];
|
|
3489
3505
|
for (const live of org.hosting.entries.filter((e) => e.uses?.auth?.workspaces)) {
|
|
3490
3506
|
const value = await heldKeys?.resolve(workspacesKeyRef(org.id, live.key));
|
|
@@ -3537,7 +3553,10 @@ export class FoundryStepExecutor {
|
|
|
3537
3553
|
}
|
|
3538
3554
|
this.handledSecrets.add(appKey);
|
|
3539
3555
|
const clientKey = workspaceClientKey(entry.key);
|
|
3540
|
-
await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context,
|
|
3556
|
+
await this.writeAppBrokerEntry(org, broker, clientKey, wanted.want, appKey, context, {
|
|
3557
|
+
...(wanted.want.workspaces ? await this.workspacesInputs(org, clientKey) : {}),
|
|
3558
|
+
...(await this.eventsInput(org, clientKey, wanted.want)),
|
|
3559
|
+
});
|
|
3541
3560
|
return {};
|
|
3542
3561
|
}
|
|
3543
3562
|
/**
|
|
@@ -4336,6 +4355,7 @@ export class FoundryStepExecutor {
|
|
|
4336
4355
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
4337
4356
|
if (entry.uses?.auth) {
|
|
4338
4357
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
4358
|
+
context.recordScopeId(authEventsAppliedScopeKey(entry.key), appEnv.eventsApplied);
|
|
4339
4359
|
}
|
|
4340
4360
|
if (dbPush.pushed)
|
|
4341
4361
|
context.recordScopeId(dbEnvAppliedScopeKey(entry.key), '1');
|
|
@@ -4948,6 +4968,22 @@ export class FoundryStepExecutor {
|
|
|
4948
4968
|
});
|
|
4949
4969
|
envs.push({ key: 'WORKSPACES_API_KEY', value: workspacesKey });
|
|
4950
4970
|
}
|
|
4971
|
+
if (authEventsWanted(entry)) {
|
|
4972
|
+
const eventsSecret = await ensureAuthEventsSecret({
|
|
4973
|
+
orgId: org.id,
|
|
4974
|
+
slug: org.slug,
|
|
4975
|
+
entryKey: entry.key,
|
|
4976
|
+
heldKeys: this.ports.heldKeys,
|
|
4977
|
+
recorded: () => context.recordedIds(),
|
|
4978
|
+
record: (key, value) => context.recordScopeId(key, value),
|
|
4979
|
+
persist: () => context.persistScopeIds(),
|
|
4980
|
+
secret: (value) => void this.handledSecrets.add(value),
|
|
4981
|
+
});
|
|
4982
|
+
envs.push({ key: AUTH_EVENTS_ENV, value: eventsSecret });
|
|
4983
|
+
}
|
|
4984
|
+
else if (recorded[authEventsAppliedScopeKey(entry.key)] === 'on') {
|
|
4985
|
+
await this.dropAuthEventsEnv(appId);
|
|
4986
|
+
}
|
|
4951
4987
|
}
|
|
4952
4988
|
else {
|
|
4953
4989
|
const zitadelOrgId = recorded.zitadel_org;
|
|
@@ -5024,11 +5060,28 @@ export class FoundryStepExecutor {
|
|
|
5024
5060
|
changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
5025
5061
|
heldChanged ||
|
|
5026
5062
|
(Boolean(entry.uses?.auth) &&
|
|
5027
|
-
authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')
|
|
5063
|
+
(authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
5064
|
+
authEventsMarker(entry) !== (recorded[authEventsAppliedScopeKey(entry.key)] ?? ''))),
|
|
5028
5065
|
applied,
|
|
5029
5066
|
authApplied,
|
|
5067
|
+
eventsApplied: authEventsMarker(entry),
|
|
5030
5068
|
};
|
|
5031
5069
|
}
|
|
5070
|
+
/** Takes `AUTH_EVENTS_SECRET` out of the app's env once the entry stops declaring events. The held value stays: the held store has no delete. */
|
|
5071
|
+
async dropAuthEventsEnv(appId) {
|
|
5072
|
+
const rows = await this.ports.coolify.listEnvs(appId);
|
|
5073
|
+
for (const row of rows) {
|
|
5074
|
+
if (row.key !== AUTH_EVENTS_ENV || !row.uuid)
|
|
5075
|
+
continue;
|
|
5076
|
+
try {
|
|
5077
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
5078
|
+
}
|
|
5079
|
+
catch (error) {
|
|
5080
|
+
if (!isNotFoundError(error))
|
|
5081
|
+
throw error;
|
|
5082
|
+
}
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5032
5085
|
/**
|
|
5033
5086
|
* The install's edge label token: read from the held keys, else minted and held first, so a crash
|
|
5034
5087
|
* after the `put` hands the same value out on the next apply. Read once more after the `put`, so two
|
|
@@ -5133,7 +5186,9 @@ export class FoundryStepExecutor {
|
|
|
5133
5186
|
if (entry.workerApp) {
|
|
5134
5187
|
// No Coolify app: the key is the held AUTH_APP_KEY the Worker app was given.
|
|
5135
5188
|
const heldKey = await heldBrokerKey(this.ports.heldKeys, org, entry.key);
|
|
5136
|
-
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, heldKey, context
|
|
5189
|
+
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, heldKey, context, {
|
|
5190
|
+
...(await this.eventsInput(org, entry.key, wanted.want)),
|
|
5191
|
+
});
|
|
5137
5192
|
return {};
|
|
5138
5193
|
}
|
|
5139
5194
|
const appId = recorded[`coolify:app:${action.key}`];
|
|
@@ -5144,9 +5199,20 @@ export class FoundryStepExecutor {
|
|
|
5144
5199
|
if (!appKey || appKey.length < 32) {
|
|
5145
5200
|
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
5146
5201
|
}
|
|
5147
|
-
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context,
|
|
5202
|
+
await this.writeAppBrokerEntry(org, broker, entry.key, wanted.want, appKey, context, {
|
|
5203
|
+
...(wanted.want.workspaces ? await this.workspacesInputs(org, entry.key) : {}),
|
|
5204
|
+
...(await this.eventsInput(org, entry.key, wanted.want)),
|
|
5205
|
+
});
|
|
5148
5206
|
return {};
|
|
5149
5207
|
}
|
|
5208
|
+
/** The held events secret of a broker entry that declares events, registered for redaction; nothing when it declares none. */
|
|
5209
|
+
async eventsInput(org, entryKey, want) {
|
|
5210
|
+
if (!want.mcp?.events)
|
|
5211
|
+
return {};
|
|
5212
|
+
const eventsSecret = await heldAuthEventsSecret(this.ports.heldKeys, org, entryKey);
|
|
5213
|
+
this.handledSecrets.add(eventsSecret);
|
|
5214
|
+
return { eventsSecret };
|
|
5215
|
+
}
|
|
5150
5216
|
/**
|
|
5151
5217
|
* What the broker entry needs beyond its want when it declares workspaces: the hash of the held key (the key
|
|
5152
5218
|
* itself is registered for redaction and goes no further) and the operator's held database connection string.
|
|
@@ -5234,13 +5300,13 @@ export class FoundryStepExecutor {
|
|
|
5234
5300
|
return {};
|
|
5235
5301
|
}
|
|
5236
5302
|
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
5237
|
-
async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context,
|
|
5303
|
+
async writeAppBrokerEntry(_org, broker, entryKey, want, appKey, context, extra = {}) {
|
|
5238
5304
|
const previousRaw = context.recordedIds()[brokerEntryAppliedScopeKey(entryKey)];
|
|
5239
5305
|
const result = await writeBrokerEntry(broker, {
|
|
5240
5306
|
want,
|
|
5241
5307
|
appKey,
|
|
5242
5308
|
previous: previousRaw ? JSON.parse(previousRaw) : null,
|
|
5243
|
-
...
|
|
5309
|
+
...extra,
|
|
5244
5310
|
});
|
|
5245
5311
|
context.recordScopeId(brokerEntryWarningsScopeKey(entryKey), JSON.stringify(result.invalidForeign));
|
|
5246
5312
|
// Last: a run that failed above plans the step again.
|
|
@@ -2,7 +2,7 @@ import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
|
2
2
|
import type { DraftStep } from './diff.js';
|
|
3
3
|
/**
|
|
4
4
|
* F11: the paths of a gated entry that a machine calls, not a person: the ai service posts to
|
|
5
|
-
* `uses.ai.webhookPath`, Boule calls `/api/customer-grants`. A gate (`gates.ts`) stops a machine, so each of
|
|
5
|
+
* `uses.ai.webhookPath`, Boule calls `/api/customer-grants`, the broker posts signed assistant events to `uses.auth.mcp.events.path`. A gate (`gates.ts`) stops a machine, so each of
|
|
6
6
|
* these paths on each gated host gets its own path-scoped Access application (`<host><path>`) with a bypass
|
|
7
7
|
* policy, the opposite of a private page's path gate. Cloudflare applies the most specific application, so
|
|
8
8
|
* the bypass wins over the host's whole-host gate. The app itself checks the machine's credential.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isEdgePath } from '../entry-rules.js';
|
|
2
3
|
import { isLiveEntry } from '../org.js';
|
|
3
4
|
import { resolveGateHostnames } from './gates.js';
|
|
4
5
|
import { entryHostnames, workspaceHost } from './hostnames.js';
|
|
@@ -6,7 +7,7 @@ import { appStepKey } from './platform-diff.js';
|
|
|
6
7
|
import { workspaceEntries } from './workspace.js';
|
|
7
8
|
/**
|
|
8
9
|
* F11: the paths of a gated entry that a machine calls, not a person: the ai service posts to
|
|
9
|
-
* `uses.ai.webhookPath`, Boule calls `/api/customer-grants`. A gate (`gates.ts`) stops a machine, so each of
|
|
10
|
+
* `uses.ai.webhookPath`, Boule calls `/api/customer-grants`, the broker posts signed assistant events to `uses.auth.mcp.events.path`. A gate (`gates.ts`) stops a machine, so each of
|
|
10
11
|
* these paths on each gated host gets its own path-scoped Access application (`<host><path>`) with a bypass
|
|
11
12
|
* policy, the opposite of a private page's path gate. Cloudflare applies the most specific application, so
|
|
12
13
|
* the bypass wins over the host's whole-host gate. The app itself checks the machine's credential.
|
|
@@ -21,17 +22,16 @@ export function entryMachinePaths(entry) {
|
|
|
21
22
|
paths.push(webhook);
|
|
22
23
|
if (entry.uses?.customerGrants)
|
|
23
24
|
paths.push(CUSTOMER_GRANTS_PATH);
|
|
25
|
+
const events = entry.uses?.auth?.mcp?.events?.path;
|
|
26
|
+
if (events !== undefined)
|
|
27
|
+
paths.push(events);
|
|
24
28
|
return [...new Set(paths)];
|
|
25
29
|
}
|
|
26
30
|
/** The step key stem of one machine bypass: a hash of host and path, so it fits a key and needs no escaping. */
|
|
27
31
|
export function machineBypassKey(host, path) {
|
|
28
32
|
return `mp-${createHash('sha256').update(`${host}\0${path}`).digest('hex').slice(0, 16)}`;
|
|
29
33
|
}
|
|
30
|
-
|
|
31
|
-
const EDGE_PATH = /^(\/[a-z0-9._~-]+)+$/;
|
|
32
|
-
const edgeAccepts = (path) => path.length <= 200 &&
|
|
33
|
-
EDGE_PATH.test(path) &&
|
|
34
|
-
!path.split('/').some((s) => s === '.' || s === '..');
|
|
34
|
+
const edgeAccepts = isEdgePath;
|
|
35
35
|
/** The recorded `host/path` a machine bypass was made for, by its step key stem, read for the delete's wording. */
|
|
36
36
|
export const machineBypassScopeKey = (stem) => `machine_bypass_${stem}`;
|
|
37
37
|
/**
|
package/dist/plan/stage-env.js
CHANGED
|
@@ -167,6 +167,8 @@ export interface WorkerAppWant {
|
|
|
167
167
|
noDelete?: string[];
|
|
168
168
|
grants?: string[];
|
|
169
169
|
migrationsSha256?: string;
|
|
170
|
+
/** `uses.audit`: a hash of `AUDIT_SEALER_GRANTS`, so an applied app is planned again when the list changes and its sealing role gets the new grants. */
|
|
171
|
+
auditSealerGrants?: string;
|
|
170
172
|
};
|
|
171
173
|
/** `workerApp.triggers.crons`, sorted. Absent when none, so a Worker without cron keeps the marker it already has. */
|
|
172
174
|
crons?: string[];
|
|
@@ -176,7 +178,7 @@ export interface WorkerAppWant {
|
|
|
176
178
|
*/
|
|
177
179
|
secrets: {
|
|
178
180
|
name: string;
|
|
179
|
-
source: 'generated' | 'keysCustody' | 'heldSecret';
|
|
181
|
+
source: 'generated' | 'keysCustody' | 'heldSecret' | 'authEvents';
|
|
180
182
|
hostId?: string;
|
|
181
183
|
version?: string;
|
|
182
184
|
}[];
|
package/dist/plan/worker-app.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { AUDIT_SEALER_GRANTS } from '../modules/audit-ledger.js';
|
|
2
3
|
import { APPS_DOMAIN, heldEnvRef } from '../org.js';
|
|
3
4
|
import { WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
|
|
4
5
|
import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
|
|
5
6
|
import { entryAuthEnv } from './broker-entries.js';
|
|
7
|
+
import { AUTH_EVENTS_ENV, ensureAuthEventsSecret } from './entry-auth-events.js';
|
|
6
8
|
import { workerAppHostname } from './hostnames.js';
|
|
7
9
|
import { workspaceWorkerParent } from './workspace-worker.js';
|
|
8
10
|
import { appStepKey } from './platform-diff.js';
|
|
@@ -164,6 +166,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
164
166
|
missing.push(what);
|
|
165
167
|
}
|
|
166
168
|
secrets.push({ name: WORKER_APP_AUTH_KEY_SECRET, source: 'generated' });
|
|
169
|
+
// The signing secret of assistant events: made by rule 1 (`ensureAuthEventsSecret`), shared with the broker entry.
|
|
170
|
+
if (entry.uses?.auth?.mcp?.events)
|
|
171
|
+
secrets.push({ name: AUTH_EVENTS_ENV, source: 'authEvents' });
|
|
167
172
|
if (entry.uses?.auth?.mcp) {
|
|
168
173
|
for (const name of ['MCP_AUDIENCE', 'MCP_RESOURCE', 'MCP_ISSUER']) {
|
|
169
174
|
if (env[name])
|
|
@@ -203,6 +208,15 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
203
208
|
...(app.database.migrationsSha256
|
|
204
209
|
? { migrationsSha256: app.database.migrationsSha256 }
|
|
205
210
|
: {}),
|
|
211
|
+
// The order of AUDIT_SEALER_GRANTS is part of the hash: a reorder plans every audit app once.
|
|
212
|
+
...(entry.uses?.audit === true
|
|
213
|
+
? {
|
|
214
|
+
auditSealerGrants: createHash('sha256')
|
|
215
|
+
.update(JSON.stringify(AUDIT_SEALER_GRANTS))
|
|
216
|
+
.digest('hex')
|
|
217
|
+
.slice(0, 16),
|
|
218
|
+
}
|
|
219
|
+
: {}),
|
|
206
220
|
},
|
|
207
221
|
}
|
|
208
222
|
: {}),
|
|
@@ -567,6 +581,23 @@ async function deliverSecrets(deps, org, entryKey, want, context, at, call) {
|
|
|
567
581
|
if (delivered[secret.name] !== version)
|
|
568
582
|
generated.push({ name: secret.name, value, version });
|
|
569
583
|
}
|
|
584
|
+
for (const secret of want.secrets) {
|
|
585
|
+
if (secret.source !== 'authEvents')
|
|
586
|
+
continue;
|
|
587
|
+
const value = await ensureAuthEventsSecret({
|
|
588
|
+
orgId: org.id,
|
|
589
|
+
slug: org.slug,
|
|
590
|
+
entryKey,
|
|
591
|
+
heldKeys,
|
|
592
|
+
recorded: () => context.recordedIds(),
|
|
593
|
+
record: (key, recordedValue) => context.recordScopeId(key, recordedValue),
|
|
594
|
+
persist: () => context.persistScopeIds(),
|
|
595
|
+
secret: (held) => deps.secret?.(held),
|
|
596
|
+
});
|
|
597
|
+
const version = secretVersion(value);
|
|
598
|
+
if (delivered[secret.name] !== version)
|
|
599
|
+
generated.push({ name: secret.name, value, version });
|
|
600
|
+
}
|
|
570
601
|
// Held secrets: the company's value, read from the held-key store. The plan found each one held; a
|
|
571
602
|
// value gone since stops the step, and the error names the ref, never a value.
|
|
572
603
|
for (const secret of want.secrets) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
2
|
import type { UnmanagedItem } from '../plan.js';
|
|
3
|
-
import { type BrokerEntryInput, type BrokerEntryWant } from './broker-entries.js';
|
|
3
|
+
import { type BrokerEntryInput, type BrokerEntryMcp, type BrokerEntryWant } from './broker-entries.js';
|
|
4
4
|
import type { DraftStep } from './diff.js';
|
|
5
5
|
import type { LiveResource } from './live.js';
|
|
6
6
|
/** The step `coolify:workspace-broker-entry` and its action key, for the planner and the executor to share. */
|
|
@@ -10,12 +10,7 @@ export declare const WORKSPACE_BROKER_ENTRY_KIND = "workspace-broker-entry";
|
|
|
10
10
|
* project in the test customers organisation (recorded by `zitadel:mcp-project` under the client's name), and the
|
|
11
11
|
* onboarding page on the workspace host. `null` without `mcp` or until the project is recorded.
|
|
12
12
|
*/
|
|
13
|
-
export declare function workspaceMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>):
|
|
14
|
-
resource: string;
|
|
15
|
-
projectId: string;
|
|
16
|
-
loginHost: string;
|
|
17
|
-
onboardingUrl?: string;
|
|
18
|
-
} | null;
|
|
13
|
+
export declare function workspaceMcp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): BrokerEntryMcp | null;
|
|
19
14
|
/**
|
|
20
15
|
* What the app's env says about who signs in at the workspace, as recorded so far, or `null` until the
|
|
21
16
|
* organisation, client and issuer are all known. Non-secret values only.
|
|
@@ -37,6 +37,8 @@ export function workspaceMcp(org, entry, recorded) {
|
|
|
37
37
|
projectId,
|
|
38
38
|
loginHost: loginHostnameFor(org),
|
|
39
39
|
...(mcp.onboardingPath ? { onboardingUrl: `https://${host}${mcp.onboardingPath}` } : {}),
|
|
40
|
+
...(mcp.events ? { events: { url: `https://${host}${mcp.events.path}` } } : {}),
|
|
41
|
+
...(mcp.managePath ? { manageUrl: `https://${host}${mcp.managePath}` } : {}),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
@@ -90,7 +92,9 @@ export function liveClientIds(org, recorded) {
|
|
|
90
92
|
/** What `ws.<key>.signin` records: the sign-in env above as JSON, or `null` while it is not all known. */
|
|
91
93
|
export function workspaceSignInMarker(org, entry, recorded, issuer) {
|
|
92
94
|
const env = workspaceAuthEnv(org, entry, recorded, issuer);
|
|
93
|
-
|
|
95
|
+
if (!env)
|
|
96
|
+
return null;
|
|
97
|
+
return entry.uses?.auth?.mcp?.events ? `${JSON.stringify(env)}+events` : JSON.stringify(env);
|
|
94
98
|
}
|
|
95
99
|
/**
|
|
96
100
|
* The test customers organisation, planned once an entry's workspace signs in customers. Sign-up and the emailed
|