@wtfalch/foundry 0.78.1 → 0.79.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/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/mcp-resources/client.d.ts +16 -0
- package/dist/mcp-resources/client.js +69 -0
- package/dist/mcp-resources/index.d.ts +3 -0
- package/dist/mcp-resources/index.js +3 -0
- package/dist/mcp-resources/schema.d.ts +1707 -0
- package/dist/mcp-resources/schema.js +253 -0
- package/dist/mcp-resources/validation.d.ts +21 -0
- package/dist/mcp-resources/validation.js +97 -0
- package/dist/modules/ai.js +14 -3
- package/dist/modules/customer-grants.js +1 -1
- package/dist/modules/email.d.ts +2 -1
- package/dist/modules/email.js +3 -2
- package/dist/modules/files.js +4 -1
- package/dist/modules/index.js +4 -1
- package/dist/modules/jobs.js +1 -1
- package/dist/modules/mcp-resources.d.ts +2 -0
- package/dist/modules/mcp-resources.js +61 -0
- package/dist/modules/outputs.js +1 -0
- package/dist/modules/repository.d.ts +2 -0
- package/dist/modules/repository.js +3 -1
- package/dist/modules/workspaces.js +1 -1
- package/dist/org.d.ts +3 -0
- package/dist/org.js +10 -7
- package/dist/plan/ai-apps-minter.d.ts +79 -0
- package/dist/plan/ai-apps-minter.js +263 -0
- package/dist/plan/ai-boule-key.d.ts +1 -1
- package/dist/plan/ai-boule-key.js +10 -3
- package/dist/plan/broker-entries.js +4 -4
- package/dist/plan/diff.d.ts +6 -0
- package/dist/plan/diff.js +33 -13
- package/dist/plan/entry-ai-key.d.ts +2 -0
- package/dist/plan/entry-ai-key.js +4 -2
- package/dist/plan/entry-customer-grants.d.ts +1 -1
- package/dist/plan/entry-customer-grants.js +4 -2
- package/dist/plan/entry-files-key.d.ts +10 -0
- package/dist/plan/entry-files-key.js +6 -1
- package/dist/plan/entry-jobs-boule-key.js +1 -1
- package/dist/plan/entry-jobs.js +1 -1
- package/dist/plan/entry-repo-key.js +2 -2
- package/dist/plan/entry-workspaces.d.ts +5 -0
- package/dist/plan/entry-workspaces.js +3 -1
- package/dist/plan/executor.d.ts +27 -0
- package/dist/plan/executor.js +125 -4
- package/dist/plan/files-apps-minter.d.ts +131 -0
- package/dist/plan/files-apps-minter.js +336 -0
- package/dist/plan/files-boule-key.js +2 -2
- package/dist/plan/files-space-alert.js +3 -3
- package/dist/plan/jobs-apps-key.d.ts +29 -0
- package/dist/plan/jobs-apps-key.js +228 -0
- package/dist/plan/jobs-project-settings.js +2 -0
- package/dist/plan/jobs-scheduler-http.d.ts +7 -0
- package/dist/plan/jobs-scheduler-http.js +32 -1
- package/dist/plan/own-jobs-route.d.ts +1 -1
- package/dist/plan/own-jobs-route.js +1 -1
- package/dist/plan/plan-apply.d.ts +6 -0
- package/dist/plan/plan-apply.js +45 -3
- package/dist/plan/platform-diff.d.ts +1 -1
- package/dist/plan/platform-diff.js +3 -2
- package/dist/release/state.d.ts +1 -1
- package/dist/store/schema.d.ts +2 -2
- package/dist/worker-app-config.js +11 -2
- package/package.json +5 -1
package/dist/org.js
CHANGED
|
@@ -2,6 +2,7 @@ import { DEFAULT_RULES, checkName, explain } from '@wtfalch/auth/names';
|
|
|
2
2
|
import { floatingRefBlocked, floatingTagBlocked } from './bootstrap/versions.js';
|
|
3
3
|
import { AI_ENTRY_PERMISSIONS, AI_WEBHOOK_PATH, FILES_COLLECTION_NAME, FILES_ENTRY_PERMISSIONS, FILES_SHARE_LINK_DAYS, FILES_SHARE_LINK_DAYS_DEFAULT, JOBS_PAUSE_AFTER_MAX, JOBS_RETRIES_MAX, MAIL_BOUNCES_MAX, MAIL_FROM_NAME, MAIL_FROM_NAME_MAX, MAIL_REPLY_TO, MAIL_REPLY_TO_MAX, MCP_PATH, ONBOARDING_PATH_MAX_LENGTH, PERCENT_MAX, PERCENT_MIN, ROOM_STEPS_MAX, ROOM_STEPS_MIN, ROOM_TEXT_MAX, ROOM_UNSAFE_TEXT, WORKSPACE_ROLE, WORKSPACE_ROLES_MAX, isEdgePath, isPagePath, wholeIn, } from './entry-rules.js';
|
|
4
4
|
import { hostedLabelRefusal } from './hosted-label.js';
|
|
5
|
+
import { McpResourceIntegrationSchema, } from './mcp-resources/schema.js';
|
|
5
6
|
import { entryHasDatabase, validateArtifact, validateEntryWorkerApp, } from './worker-app-config.js';
|
|
6
7
|
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';
|
|
7
8
|
/** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
|
|
@@ -1002,6 +1003,7 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
1002
1003
|
if (both) {
|
|
1003
1004
|
const unsupported = [
|
|
1004
1005
|
[auth.mcp, 'uses.auth.mcp', 'mcp'],
|
|
1006
|
+
[auth.mcpResources, 'uses.auth.mcpResources', 'mcpResources'],
|
|
1005
1007
|
[auth.workspaces, 'uses.auth.workspaces', 'workspaces'],
|
|
1006
1008
|
[auth.replacesBrokerEntry, 'uses.auth.replacesBrokerEntry', 'replacesBrokerEntry'],
|
|
1007
1009
|
[entry.blueGreen, 'blueGreen', 'blueGreen'],
|
|
@@ -1029,6 +1031,10 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
1029
1031
|
if (!hasExactHostname(entry, hosting, domain)) {
|
|
1030
1032
|
return refusal('auth_needs_exact_hostname', `hosting.entries.${entry.key}.hostnames`, `${at} needs an exact hostname to sign in at`);
|
|
1031
1033
|
}
|
|
1034
|
+
if (auth.mcpResources !== undefined &&
|
|
1035
|
+
(!signsInCustomers(auth) || !McpResourceIntegrationSchema.safeParse(auth.mcpResources).success)) {
|
|
1036
|
+
return refusal('mcp_path_invalid', `hosting.entries.${entry.key}.uses.auth.mcpResources`, `${at} mcpResources needs customer sign-in, version 1 and a safe absolute path prefix`);
|
|
1037
|
+
}
|
|
1032
1038
|
if (auth.mcp !== undefined) {
|
|
1033
1039
|
if (!signsInCustomers(auth)) {
|
|
1034
1040
|
return refusal('mcp_needs_open_customers', `hosting.entries.${entry.key}.uses.auth.mcp`, `${at} mcp needs users 'customers': an MCP client signs the customer in, and registration may be open or closed`);
|
|
@@ -1133,7 +1139,7 @@ function validateEntryAuth(entry, hosting, domain) {
|
|
|
1133
1139
|
return null;
|
|
1134
1140
|
}
|
|
1135
1141
|
/**
|
|
1136
|
-
* `uses.auth.workspaces`: customers only
|
|
1142
|
+
* `uses.auth.workspaces`: customers only; Boule delivers a Worker copy’s key through the registrar. Roles the
|
|
1137
1143
|
* broker accepts (1 to 20 distinct names, no `c_` prefix, `owner` only as the first, never `self_service`),
|
|
1138
1144
|
* a plain invite path, and no `env` entry that names a value it writes.
|
|
1139
1145
|
*/
|
|
@@ -1142,9 +1148,6 @@ function validateEntryWorkspaces(entry, workspaces, at) {
|
|
|
1142
1148
|
if (!signsInCustomers(entry.uses?.auth)) {
|
|
1143
1149
|
return refusal('workspaces_needs_customers', path, `${at} workspaces needs users 'customers': workspaces belong to an app's own customers`);
|
|
1144
1150
|
}
|
|
1145
|
-
if (entry.workerApp !== undefined) {
|
|
1146
|
-
return refusal('workspaces_needs_server', path, `${at} workspaces needs an app that runs as a server: a Worker app has nowhere to keep the workspaces key`);
|
|
1147
|
-
}
|
|
1148
1151
|
const config = workspaces;
|
|
1149
1152
|
const roles = config?.roles;
|
|
1150
1153
|
if (!Array.isArray(roles) || roles.length < 1 || roles.length > WORKSPACE_ROLES_MAX) {
|
|
@@ -1395,7 +1398,7 @@ function validateEntryEdge(entry, hosting, domain) {
|
|
|
1395
1398
|
export const REPO_ENTRY_ENV_NAMES = ['GIT_REPO_URL', 'GIT_SSH_KEY'];
|
|
1396
1399
|
/**
|
|
1397
1400
|
* `uses.repo` (F12): `{ access: 'read' | 'write' }` and nothing else, on an entry that has its own `repo` (a
|
|
1398
|
-
* Worker app
|
|
1401
|
+
* Worker app instead uses Boule's source-bound HTTPS credential), and no server `env` entry that
|
|
1399
1402
|
* names a value it writes.
|
|
1400
1403
|
*/
|
|
1401
1404
|
function validateEntryRepoUse(entry) {
|
|
@@ -1411,7 +1414,7 @@ function validateEntryRepoUse(entry) {
|
|
|
1411
1414
|
Object.keys(value).some((k) => k !== 'access')) {
|
|
1412
1415
|
return refusal('repo_use_invalid', path, `${at} must be { access: 'read' } or { access: 'write' }: read lets the app clone its repo, write lets it push too`);
|
|
1413
1416
|
}
|
|
1414
|
-
if (entry.repo === null) {
|
|
1417
|
+
if (entry.repo === null && !entry.workerApp) {
|
|
1415
1418
|
return refusal('repo_use_needs_repo', path, `${at} gives the app access to its own repo, and this entry has none: set repo, or remove uses.repo`);
|
|
1416
1419
|
}
|
|
1417
1420
|
const named = Object.keys(entry.env ?? {}).find((name) => REPO_ENTRY_ENV_NAMES.includes(name));
|
|
@@ -1425,7 +1428,7 @@ export const CUSTOMER_GRANTS_ENTRY_ENV_NAMES = ['CUSTOMER_GRANTS_CREDENTIAL'];
|
|
|
1425
1428
|
/**
|
|
1426
1429
|
* `uses.customerGrants`: `true`, customers signing in (`uses.auth` with `users: 'customers'`, which already
|
|
1427
1430
|
* needs hosting on and an exact hostname for the app), and no `env` entry that names the credential. A Worker
|
|
1428
|
-
* app is
|
|
1431
|
+
* app is accepted by `validateEntryWorkerApp`; Boule owns its credential and directory registration.
|
|
1429
1432
|
*/
|
|
1430
1433
|
function validateEntryCustomerGrants(entry) {
|
|
1431
1434
|
const value = entry.uses?.customerGrants;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
export declare const AI_APPS_PERMISSIONS: readonly ["ai.keys:mint", "ai.keys:revoke", "ai.offerings:invoke"];
|
|
5
|
+
export declare const AI_APPS_ENV: {
|
|
6
|
+
readonly url: "AI_APPS_URL";
|
|
7
|
+
readonly organisationId: "AI_APPS_ORGANISATION_ID";
|
|
8
|
+
readonly keyId: "AI_APPS_KEY_ID";
|
|
9
|
+
readonly keyExpires: "AI_APPS_KEY_EXPIRES";
|
|
10
|
+
readonly key: "AI_APPS_KEY";
|
|
11
|
+
};
|
|
12
|
+
export declare const aiAppsMinterRef: (id: string) => string;
|
|
13
|
+
export declare const aiAppsParentRef: (org: string, parent: string) => string;
|
|
14
|
+
export interface AiAppsMinterStep {
|
|
15
|
+
mode: 'mint' | 'renew' | 'deliver' | 'retire';
|
|
16
|
+
url?: string;
|
|
17
|
+
organisationId?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
parent?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function planAiAppsMinter(org: DeclaredOrg, ids: Readonly<Record<string, string>>, planned: readonly DraftStep[], ready: OrgPlatformReadiness, now: number, capability: boolean, rootHeld: boolean, minterHeld?: boolean): {
|
|
22
|
+
steps: DraftStep[];
|
|
23
|
+
blocked: {
|
|
24
|
+
description: string;
|
|
25
|
+
key: string;
|
|
26
|
+
}[];
|
|
27
|
+
};
|
|
28
|
+
export interface AiAppsMinterDeps {
|
|
29
|
+
org: {
|
|
30
|
+
id: string;
|
|
31
|
+
slug: string;
|
|
32
|
+
};
|
|
33
|
+
bouleAppId?: string;
|
|
34
|
+
ai: {
|
|
35
|
+
mintNestedKey(input: {
|
|
36
|
+
organisationId: string;
|
|
37
|
+
minterKeyId: string;
|
|
38
|
+
name: string;
|
|
39
|
+
expiresDays: number;
|
|
40
|
+
permissions: string[];
|
|
41
|
+
}): Promise<{
|
|
42
|
+
id: string;
|
|
43
|
+
secret: string;
|
|
44
|
+
expiresAt: string;
|
|
45
|
+
}>;
|
|
46
|
+
revokeDescendantKey(input: {
|
|
47
|
+
organisationId: string;
|
|
48
|
+
credential: string;
|
|
49
|
+
keyId: string;
|
|
50
|
+
}): Promise<unknown>;
|
|
51
|
+
};
|
|
52
|
+
heldKeys: {
|
|
53
|
+
resolve(ref: string): Promise<string | null | undefined>;
|
|
54
|
+
put(ref: string, value: string): Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
coolify: {
|
|
57
|
+
bulkUpsertEnvs(app: string, values: {
|
|
58
|
+
key: string;
|
|
59
|
+
value: string;
|
|
60
|
+
literal?: boolean;
|
|
61
|
+
}[]): Promise<unknown>;
|
|
62
|
+
listEnvs(app: string): Promise<{
|
|
63
|
+
uuid?: string;
|
|
64
|
+
key: string;
|
|
65
|
+
is_preview: boolean;
|
|
66
|
+
}[]>;
|
|
67
|
+
deleteEnv(app: string, id: string): Promise<unknown>;
|
|
68
|
+
restart(app: string): Promise<unknown>;
|
|
69
|
+
};
|
|
70
|
+
recorded(): Readonly<Record<string, string>>;
|
|
71
|
+
record(key: string, value: string): void;
|
|
72
|
+
forget(key: string): void;
|
|
73
|
+
persist(): Promise<void>;
|
|
74
|
+
secret(value: string): void;
|
|
75
|
+
now?: () => number;
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
}
|
|
78
|
+
/** Retains each prior parent authority in held custody: renewals never revoke a minter and its still-running children. */
|
|
79
|
+
export declare function ensureAiAppsMinter(deps: AiAppsMinterDeps, step: AiAppsMinterStep): Promise<void>;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
|
+
import { parseIds } from './ai-boule-key.js';
|
|
3
|
+
export const AI_APPS_PERMISSIONS = [
|
|
4
|
+
'ai.keys:mint',
|
|
5
|
+
'ai.keys:revoke',
|
|
6
|
+
'ai.offerings:invoke',
|
|
7
|
+
];
|
|
8
|
+
export const AI_APPS_ENV = {
|
|
9
|
+
url: 'AI_APPS_URL',
|
|
10
|
+
organisationId: 'AI_APPS_ORGANISATION_ID',
|
|
11
|
+
keyId: 'AI_APPS_KEY_ID',
|
|
12
|
+
keyExpires: 'AI_APPS_KEY_EXPIRES',
|
|
13
|
+
key: 'AI_APPS_KEY',
|
|
14
|
+
};
|
|
15
|
+
const S = 'ai_apps_minter';
|
|
16
|
+
const DAY = 86_400_000;
|
|
17
|
+
export const aiAppsMinterRef = (id) => `ai:apps-minter:${id}`;
|
|
18
|
+
export const aiAppsParentRef = (org, parent) => `ai:apps-parent:${org}:${parent}`;
|
|
19
|
+
export function planAiAppsMinter(org, ids, planned, ready, now, capability, rootHeld, minterHeld = true) {
|
|
20
|
+
const steps = [];
|
|
21
|
+
const blocked = [];
|
|
22
|
+
const key = `ai:apps-minter:${org.id}`;
|
|
23
|
+
const block = (why) => blocked.push({ key, description: `${org.slug}'s Worker AI keys are blocked: ${why}` });
|
|
24
|
+
const emit = (desired) => steps.push({
|
|
25
|
+
action: { system: 'ai', kind: 'apps-minter', key: org.id, desired: { ...desired } },
|
|
26
|
+
step: {
|
|
27
|
+
description: desired.mode === 'retire'
|
|
28
|
+
? `retire ${org.slug}'s Worker AI minters and their app keys`
|
|
29
|
+
: `${desired.mode} ${org.slug}'s Worker AI minter below its organisation root, hold it and deliver AI_APPS_* to Boule`,
|
|
30
|
+
destroys: desired.mode === 'retire',
|
|
31
|
+
migrates: false,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (!appsOn(org)) {
|
|
35
|
+
if (ids[S] || ids[`${S}_previous`] || ids[`${S}_undelivered`] || ids[`${S}_minting`]) {
|
|
36
|
+
if (capability)
|
|
37
|
+
emit({ mode: 'retire' });
|
|
38
|
+
else
|
|
39
|
+
block('the scoped AI key lifecycle adapter is not enabled');
|
|
40
|
+
}
|
|
41
|
+
return { steps, blocked };
|
|
42
|
+
}
|
|
43
|
+
if (!ready.aiReady)
|
|
44
|
+
return { steps, blocked };
|
|
45
|
+
if (!capability)
|
|
46
|
+
return { steps, blocked };
|
|
47
|
+
if (ids[`${S}_minting`] || (ids[`${S}_undelivered`] && ids[`${S}_undelivered`] !== ids[S])) {
|
|
48
|
+
block('a one-time minter may have been made but was not safely held; inspect it before another is minted');
|
|
49
|
+
return { steps, blocked };
|
|
50
|
+
}
|
|
51
|
+
if (ids[S] && !minterHeld) {
|
|
52
|
+
block('the recorded minter has no held value; restore or retire it before continuing');
|
|
53
|
+
return { steps, blocked };
|
|
54
|
+
}
|
|
55
|
+
if (!ready.aiUrl || !ready.canHoldSecrets) {
|
|
56
|
+
block('AI or held credential custody is not ready');
|
|
57
|
+
return { steps, blocked };
|
|
58
|
+
}
|
|
59
|
+
const parent = ids[`ai:key:${org.id}`];
|
|
60
|
+
const rootPlanned = planned.some((p) => p.action.system === 'ai' && p.action.kind === 'key' && p.action.key === org.id);
|
|
61
|
+
if (!rootPlanned && (!parent || !rootHeld)) {
|
|
62
|
+
block('the organisation root key and its held secret are required');
|
|
63
|
+
return { steps, blocked };
|
|
64
|
+
}
|
|
65
|
+
if (!ids[`coolify:boule:${org.id}`] &&
|
|
66
|
+
!planned.some((p) => p.action.system === 'coolify' && p.action.kind === 'boule' && p.action.key === org.id)) {
|
|
67
|
+
block('Boule is not recorded yet');
|
|
68
|
+
return { steps, blocked };
|
|
69
|
+
}
|
|
70
|
+
const mode = ids[S] && ids[`${S}_undelivered`] === ids[S]
|
|
71
|
+
? 'deliver'
|
|
72
|
+
: !ids[S]
|
|
73
|
+
? 'mint'
|
|
74
|
+
: rootPlanned || ids[`${S}_parent`] !== parent
|
|
75
|
+
? 'renew'
|
|
76
|
+
: Date.parse(ids[`${S}_expires`] ?? '') - now < 30 * DAY
|
|
77
|
+
? 'renew'
|
|
78
|
+
: ids[`${S}_undelivered`] || ids[`${S}_applied`] !== ready.aiUrl
|
|
79
|
+
? 'deliver'
|
|
80
|
+
: null;
|
|
81
|
+
if (mode === 'renew' && ids[`${S}_clamped`] === parent && !rootPlanned) {
|
|
82
|
+
block('the organisation root must be renewed before another minter can live long enough');
|
|
83
|
+
return { steps, blocked };
|
|
84
|
+
}
|
|
85
|
+
if (mode)
|
|
86
|
+
emit({
|
|
87
|
+
mode,
|
|
88
|
+
url: ready.aiUrl,
|
|
89
|
+
organisationId: org.id,
|
|
90
|
+
name: `${org.slug}-boule-apps-ai`,
|
|
91
|
+
parent,
|
|
92
|
+
});
|
|
93
|
+
return { steps, blocked };
|
|
94
|
+
}
|
|
95
|
+
async function timed(read, ms) {
|
|
96
|
+
let timer;
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([
|
|
99
|
+
read(),
|
|
100
|
+
new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Retains each prior parent authority in held custody: renewals never revoke a minter and its still-running children. */
|
|
110
|
+
export async function ensureAiAppsMinter(deps, step) {
|
|
111
|
+
const at = `ai:apps-minter for ${deps.org.slug}`;
|
|
112
|
+
const ms = deps.timeoutMs ?? 30_000;
|
|
113
|
+
const now = deps.now ?? Date.now;
|
|
114
|
+
const call = (fn) => timed(fn, ms);
|
|
115
|
+
const ids = deps.recorded();
|
|
116
|
+
const ref = aiAppsMinterRef(deps.org.id);
|
|
117
|
+
const hold = async (ref, value) => {
|
|
118
|
+
deps.secret(value);
|
|
119
|
+
for (let i = 0; i < 3; i++) {
|
|
120
|
+
try {
|
|
121
|
+
await call(() => deps.heldKeys.put(ref, value));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
125
|
+
}
|
|
126
|
+
throw new Error(`${at}: the credential could not be held at ${ref}`);
|
|
127
|
+
};
|
|
128
|
+
const resolve = async (ref) => {
|
|
129
|
+
try {
|
|
130
|
+
const value = await call(() => deps.heldKeys.resolve(ref));
|
|
131
|
+
if (value)
|
|
132
|
+
deps.secret(value);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new Error(`${at}: held credential ${ref} could not be read`);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
if (step.mode === 'retire') {
|
|
140
|
+
if (ids[`${S}_minting`])
|
|
141
|
+
throw new Error(`${at}: an uncertain minter must be inspected before retirement`);
|
|
142
|
+
const keys = [
|
|
143
|
+
...new Set([ids[S], ids[`${S}_undelivered`], ...parseIds(ids[`${S}_previous`])].filter((v) => Boolean(v))),
|
|
144
|
+
];
|
|
145
|
+
for (const keyId of keys) {
|
|
146
|
+
const expires = Date.parse(ids[`${S}_expires_${keyId}`] ?? '');
|
|
147
|
+
if (Number.isFinite(expires) && expires <= now())
|
|
148
|
+
continue;
|
|
149
|
+
const parent = ids[`${S}_parent_${keyId}`];
|
|
150
|
+
if (!parent)
|
|
151
|
+
throw new Error(`${at}: key ${keyId} has no recorded parent authority`);
|
|
152
|
+
const credential = await resolve(aiAppsParentRef(deps.org.id, parent));
|
|
153
|
+
if (!credential)
|
|
154
|
+
throw new Error(`${at}: parent authority for key ${keyId} is not held`);
|
|
155
|
+
try {
|
|
156
|
+
await call(() => deps.ai.revokeDescendantKey({ organisationId: deps.org.id, credential, keyId }));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new Error(`${at}: key ${keyId} could not be retired`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (deps.bouleAppId) {
|
|
163
|
+
const app = deps.bouleAppId;
|
|
164
|
+
try {
|
|
165
|
+
const rows = await call(() => deps.coolify.listEnvs(app));
|
|
166
|
+
const names = new Set(Object.values(AI_APPS_ENV));
|
|
167
|
+
for (const row of rows)
|
|
168
|
+
if (row.uuid && !row.is_preview && names.has(row.key))
|
|
169
|
+
await call(() => deps.coolify.deleteEnv(app, row.uuid));
|
|
170
|
+
await call(() => deps.coolify.restart(app));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new Error(`${at}: revoked minters could not be removed from Boule's environment`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const name of Object.keys(ids))
|
|
177
|
+
if (name === S || name.startsWith(`${S}_`))
|
|
178
|
+
deps.forget(name);
|
|
179
|
+
await deps.persist();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!deps.bouleAppId || !step.url)
|
|
183
|
+
throw new Error(`${at}: Boule or AI is not recorded yet`);
|
|
184
|
+
if (ids[`${S}_minting`] || (ids[`${S}_undelivered`] && ids[`${S}_undelivered`] !== ids[S]))
|
|
185
|
+
throw new Error(`${at}: a one-time minter was not safely held; no second mint is allowed`);
|
|
186
|
+
let keyId = ids[S];
|
|
187
|
+
let key = keyId ? await resolve(ref) : null;
|
|
188
|
+
if (keyId && !key)
|
|
189
|
+
throw new Error(`${at}: the recorded minter has no held value; no new key was minted`);
|
|
190
|
+
if (step.mode !== 'deliver') {
|
|
191
|
+
if (keyId) {
|
|
192
|
+
const previous = parseIds(ids[`${S}_previous`]);
|
|
193
|
+
if (!previous.includes(keyId))
|
|
194
|
+
previous.push(keyId);
|
|
195
|
+
deps.record(`${S}_previous`, JSON.stringify(previous));
|
|
196
|
+
await deps.persist();
|
|
197
|
+
}
|
|
198
|
+
const parent = deps.recorded()[`ai:key:${deps.org.id}`];
|
|
199
|
+
if (!parent)
|
|
200
|
+
throw new Error(`${at}: the organisation root is not recorded`);
|
|
201
|
+
const root = await resolve(`ai:key:${deps.org.slug}`);
|
|
202
|
+
if (!root)
|
|
203
|
+
throw new Error(`${at}: the organisation root secret is not held`);
|
|
204
|
+
await hold(aiAppsParentRef(deps.org.id, parent), root);
|
|
205
|
+
deps.record(`${S}_minting`, '1');
|
|
206
|
+
await deps.persist();
|
|
207
|
+
let minted;
|
|
208
|
+
try {
|
|
209
|
+
minted = await call(() => deps.ai.mintNestedKey({
|
|
210
|
+
organisationId: deps.org.id,
|
|
211
|
+
minterKeyId: parent,
|
|
212
|
+
name: step.name ?? `${deps.org.slug}-boule-apps-ai`,
|
|
213
|
+
expiresDays: 365,
|
|
214
|
+
permissions: [...AI_APPS_PERMISSIONS],
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new Error(`${at}: AI did not confirm a one-time minter; inspect it before retrying`);
|
|
219
|
+
}
|
|
220
|
+
if (minted.secret)
|
|
221
|
+
deps.secret(minted.secret);
|
|
222
|
+
if (!minted.id)
|
|
223
|
+
throw new Error(`${at}: AI returned no minter identifier`);
|
|
224
|
+
deps.record(`${S}_undelivered`, minted.id);
|
|
225
|
+
deps.record(`${S}_parent_${minted.id}`, parent);
|
|
226
|
+
deps.forget(`${S}_minting`);
|
|
227
|
+
await deps.persist();
|
|
228
|
+
if (!minted.secret || !Number.isFinite(Date.parse(minted.expiresAt)))
|
|
229
|
+
throw new Error(`${at}: the one-time minter response was incomplete`);
|
|
230
|
+
await hold(ref, minted.secret);
|
|
231
|
+
deps.record(S, minted.id);
|
|
232
|
+
deps.record(`${S}_parent`, parent);
|
|
233
|
+
deps.record(`${S}_expires`, minted.expiresAt);
|
|
234
|
+
deps.record(`${S}_expires_${minted.id}`, minted.expiresAt);
|
|
235
|
+
deps.record(`${S}_clamped`, Date.parse(minted.expiresAt) - now() < 30 * DAY ? parent : '');
|
|
236
|
+
await deps.persist();
|
|
237
|
+
key = minted.secret;
|
|
238
|
+
keyId = minted.id;
|
|
239
|
+
}
|
|
240
|
+
if (!key || !keyId)
|
|
241
|
+
throw new Error(`${at}: the recorded minter's held value is missing; no new key was minted`);
|
|
242
|
+
const app = deps.bouleAppId;
|
|
243
|
+
try {
|
|
244
|
+
await call(() => deps.coolify.bulkUpsertEnvs(app, [
|
|
245
|
+
{ key: AI_APPS_ENV.url, value: step.url, literal: true },
|
|
246
|
+
{ key: AI_APPS_ENV.organisationId, value: deps.org.id, literal: true },
|
|
247
|
+
{ key: AI_APPS_ENV.keyId, value: keyId, literal: true },
|
|
248
|
+
{
|
|
249
|
+
key: AI_APPS_ENV.keyExpires,
|
|
250
|
+
value: deps.recorded()[`${S}_expires`] ?? '',
|
|
251
|
+
literal: true,
|
|
252
|
+
},
|
|
253
|
+
{ key: AI_APPS_ENV.key, value: key, literal: true },
|
|
254
|
+
]));
|
|
255
|
+
await call(() => deps.coolify.restart(app));
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new Error(`${at}: the minter is held but could not be delivered; the next apply reuses it`);
|
|
259
|
+
}
|
|
260
|
+
deps.forget(`${S}_undelivered`);
|
|
261
|
+
deps.record(`${S}_applied`, step.url);
|
|
262
|
+
await deps.persist();
|
|
263
|
+
}
|
|
@@ -37,7 +37,7 @@ export declare const AI_BOULE_PERMISSIONS: readonly ["ai.usage:read", "ai.runs:r
|
|
|
37
37
|
/** What every organisation is offered, and its own key holds, without Boule's reads. */
|
|
38
38
|
export declare const AI_DEFAULT_OFFERS: readonly ["ai.offerings:invoke", "ai.runs:create"];
|
|
39
39
|
/** The organisation's offers, and the permissions its own key is minted with. */
|
|
40
|
-
export declare function aiOffers(bouleReads: boolean): string[];
|
|
40
|
+
export declare function aiOffers(bouleReads: boolean, appsKeys?: boolean): string[];
|
|
41
41
|
/** The offers `ai:organisation` last set, as JSON. */
|
|
42
42
|
export declare const AI_OFFERS_SCOPE_KEY = "ai_offers_applied";
|
|
43
43
|
/** The permissions the recorded org key was minted with, as JSON. Absent: the default offers it was always minted with. */
|
|
@@ -40,8 +40,14 @@ export const AI_BOULE_PERMISSIONS = [
|
|
|
40
40
|
/** What every organisation is offered, and its own key holds, without Boule's reads. */
|
|
41
41
|
export const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
42
42
|
/** The organisation's offers, and the permissions its own key is minted with. */
|
|
43
|
-
export function aiOffers(bouleReads) {
|
|
44
|
-
return
|
|
43
|
+
export function aiOffers(bouleReads, appsKeys = false) {
|
|
44
|
+
return [
|
|
45
|
+
...new Set([
|
|
46
|
+
...AI_DEFAULT_OFFERS,
|
|
47
|
+
...(bouleReads ? AI_BOULE_PERMISSIONS : []),
|
|
48
|
+
...(appsKeys ? ['ai.keys:mint', 'ai.keys:revoke', 'ai.usage:read'] : []),
|
|
49
|
+
]),
|
|
50
|
+
];
|
|
45
51
|
}
|
|
46
52
|
/** The offers `ai:organisation` last set, as JSON. */
|
|
47
53
|
export const AI_OFFERS_SCOPE_KEY = 'ai_offers_applied';
|
|
@@ -139,7 +145,8 @@ export function planAiBouleKey(org, recordedIds, plannedSteps, readiness, now, b
|
|
|
139
145
|
}
|
|
140
146
|
const orgKey = recordedIds[`ai:key:${org.id}`];
|
|
141
147
|
const orgKeyPlanned = planned('ai', 'key', org.id);
|
|
142
|
-
const orgKeyHolds = Boolean(orgKey) &&
|
|
148
|
+
const orgKeyHolds = Boolean(orgKey) &&
|
|
149
|
+
aiOffers(true).every((permission) => parseIds(aiKeyPermissions(recordedIds)).includes(permission));
|
|
143
150
|
if (!orgKeyHolds && !orgKeyPlanned) {
|
|
144
151
|
block("the org's own ai key does not hold Boule's permissions yet, and is not being minted with them");
|
|
145
152
|
return { steps, blocked };
|
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { isFoundryOwnedBrokerEntryName, signsInBoth, signsInCustomers, } from '../org.js';
|
|
3
3
|
import { entryClientIdScopeKey } from './agora.js';
|
|
4
4
|
import { MCP_DATABASE_URL_ENV, MCP_OAUTH_SECRET_ENV } from './broker-mcp.js';
|
|
5
|
-
import { BROKER_WORKSPACES_DATABASE_ENV, wantedWorkspaces, workspacesUrl, } from './entry-workspaces.js';
|
|
5
|
+
import { BROKER_WORKSPACES_DATABASE_ENV, serverWorkspaces, wantedWorkspaces, workspacesUrl, } from './entry-workspaces.js';
|
|
6
6
|
import { entryHostnames } from './hostnames.js';
|
|
7
7
|
import { loginHostnameFor } from './login-host.js';
|
|
8
8
|
import { PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, parseBrokerDeployRecord, } from './platform-diff.js';
|
|
@@ -96,7 +96,7 @@ export function entryAuthEnv(org, entry, recorded, issuer) {
|
|
|
96
96
|
}
|
|
97
97
|
: {};
|
|
98
98
|
Object.assign(values, mcpValues);
|
|
99
|
-
if (
|
|
99
|
+
if (serverWorkspaces(entry))
|
|
100
100
|
Object.assign(values, { WORKSPACES_URL: workspacesUrl(org) });
|
|
101
101
|
return Object.fromEntries(Object.entries(values).filter(([, value]) => Boolean(value)));
|
|
102
102
|
}
|
|
@@ -126,14 +126,14 @@ export function wantedBrokerEntry(org, entry, recorded) {
|
|
|
126
126
|
missing.push({ what: 'its MCP audience project id', producedBy: ['zitadel', 'mcp-project'] });
|
|
127
127
|
}
|
|
128
128
|
const workspaces = wantedWorkspaces(org, entry);
|
|
129
|
-
if (
|
|
129
|
+
if (serverWorkspaces(entry) && !workspaces) {
|
|
130
130
|
missing.push({ what: 'its public address', producedBy: ['coolify', 'app'] });
|
|
131
131
|
}
|
|
132
132
|
if (!organizationId ||
|
|
133
133
|
!clientId ||
|
|
134
134
|
!mailDomain ||
|
|
135
135
|
(auth.mcp && !mcp) ||
|
|
136
|
-
(
|
|
136
|
+
(serverWorkspaces(entry) && !workspaces)) {
|
|
137
137
|
return { missing };
|
|
138
138
|
}
|
|
139
139
|
return {
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface PlanOrgOptions {
|
|
|
92
92
|
reportingPort?: boolean;
|
|
93
93
|
/** foundry#1119: the executor's jobs scheduler port mints and revokes project keys; without it Boule's key per `uses.jobs` entry is blocked. */
|
|
94
94
|
jobsProjectKeys?: boolean;
|
|
95
|
+
/** Host confirms the scheduler supports company-scoped app project provisioning. */
|
|
96
|
+
jobsAppProjects?: boolean;
|
|
95
97
|
/** The Files port can set and remove each live app's owner space alert. */
|
|
96
98
|
serviceAlertMail?: boolean;
|
|
97
99
|
serviceAlertMailCurrent?: Partial<Record<'files' | 'jobs', boolean | 'unreadable'>>;
|
|
@@ -103,6 +105,10 @@ export interface PlanOrgOptions {
|
|
|
103
105
|
* it Boule's key is blocked.
|
|
104
106
|
*/
|
|
105
107
|
aiBouleReads?: boolean;
|
|
108
|
+
/** Host guarantees deployed ordinary scoped AI revocation, purpose-aware issuance and held root authority. Off until released. */
|
|
109
|
+
aiAppsKeys?: boolean;
|
|
110
|
+
aiAppsRootKeyHeld?: boolean;
|
|
111
|
+
aiAppsMinterHeld?: boolean;
|
|
106
112
|
/** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
|
|
107
113
|
workerAppDatabase?: boolean;
|
|
108
114
|
/** True when that port has `deletableTables`; a `workerApp.database.noDelete` is blocked without it. */
|