@wtfalch/foundry 0.78.0 → 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.
Files changed (65) hide show
  1. package/dist/catalogue/copy-en.js +5 -0
  2. package/dist/catalogue/integrations.js +5 -0
  3. package/dist/mcp-resources/client.d.ts +16 -0
  4. package/dist/mcp-resources/client.js +69 -0
  5. package/dist/mcp-resources/index.d.ts +3 -0
  6. package/dist/mcp-resources/index.js +3 -0
  7. package/dist/mcp-resources/schema.d.ts +1707 -0
  8. package/dist/mcp-resources/schema.js +253 -0
  9. package/dist/mcp-resources/validation.d.ts +21 -0
  10. package/dist/mcp-resources/validation.js +97 -0
  11. package/dist/modules/ai.js +14 -3
  12. package/dist/modules/customer-grants.js +1 -1
  13. package/dist/modules/email.d.ts +2 -1
  14. package/dist/modules/email.js +3 -2
  15. package/dist/modules/files.js +4 -1
  16. package/dist/modules/index.js +4 -1
  17. package/dist/modules/jobs.js +1 -1
  18. package/dist/modules/mcp-resources.d.ts +2 -0
  19. package/dist/modules/mcp-resources.js +61 -0
  20. package/dist/modules/outputs.js +1 -0
  21. package/dist/modules/repository.d.ts +2 -0
  22. package/dist/modules/repository.js +3 -1
  23. package/dist/modules/workspaces.js +1 -1
  24. package/dist/org.d.ts +3 -0
  25. package/dist/org.js +10 -7
  26. package/dist/plan/ai-apps-minter.d.ts +79 -0
  27. package/dist/plan/ai-apps-minter.js +263 -0
  28. package/dist/plan/ai-boule-key.d.ts +1 -1
  29. package/dist/plan/ai-boule-key.js +10 -3
  30. package/dist/plan/broker-entries.js +4 -4
  31. package/dist/plan/diff.d.ts +6 -0
  32. package/dist/plan/diff.js +33 -13
  33. package/dist/plan/entry-ai-key.d.ts +2 -0
  34. package/dist/plan/entry-ai-key.js +4 -2
  35. package/dist/plan/entry-customer-grants.d.ts +1 -1
  36. package/dist/plan/entry-customer-grants.js +4 -2
  37. package/dist/plan/entry-files-key.d.ts +10 -0
  38. package/dist/plan/entry-files-key.js +6 -1
  39. package/dist/plan/entry-jobs-boule-key.js +1 -1
  40. package/dist/plan/entry-jobs.js +1 -1
  41. package/dist/plan/entry-repo-key.js +2 -2
  42. package/dist/plan/entry-workspaces.d.ts +5 -0
  43. package/dist/plan/entry-workspaces.js +3 -1
  44. package/dist/plan/executor.d.ts +27 -0
  45. package/dist/plan/executor.js +125 -4
  46. package/dist/plan/files-apps-minter.d.ts +131 -0
  47. package/dist/plan/files-apps-minter.js +336 -0
  48. package/dist/plan/files-boule-key.js +2 -2
  49. package/dist/plan/files-space-alert.js +3 -3
  50. package/dist/plan/jobs-apps-key.d.ts +29 -0
  51. package/dist/plan/jobs-apps-key.js +228 -0
  52. package/dist/plan/jobs-project-settings.js +2 -0
  53. package/dist/plan/jobs-scheduler-http.d.ts +7 -0
  54. package/dist/plan/jobs-scheduler-http.js +32 -1
  55. package/dist/plan/own-jobs-route.d.ts +1 -1
  56. package/dist/plan/own-jobs-route.js +1 -1
  57. package/dist/plan/plan-apply.d.ts +6 -0
  58. package/dist/plan/plan-apply.js +45 -3
  59. package/dist/plan/platform-diff.d.ts +2 -1
  60. package/dist/plan/platform-diff.js +11 -4
  61. package/dist/plan/usage-tail-worker.js +6 -2
  62. package/dist/release/state.d.ts +1 -1
  63. package/dist/store/schema.d.ts +2 -2
  64. package/dist/worker-app-config.js +11 -2
  65. 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, a server entry (a Worker app has nowhere to keep the key), roles the
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 has none to clone and is already refused for any use but `uses.auth`), and no `env` entry that
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 already refused for any use but `uses.auth` (`validateEntryWorkerApp`).
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 bouleReads ? [...AI_DEFAULT_OFFERS, ...AI_BOULE_PERMISSIONS] : [...AI_DEFAULT_OFFERS];
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) && aiKeyPermissions(recordedIds) === JSON.stringify(aiOffers(true));
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 (auth.workspaces)
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 (auth.workspaces && !workspaces) {
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
- (auth.workspaces && !workspaces)) {
136
+ (serverWorkspaces(entry) && !workspaces)) {
137
137
  return { missing };
138
138
  }
139
139
  return {
@@ -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. */