@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
@@ -0,0 +1,228 @@
1
+ import { appsOn } from '../org.js';
2
+ export const JOBS_APPS_PROJECT = 'jobs_apps_project';
3
+ export const JOBS_APPS_KEY = 'jobs_apps_key';
4
+ export const JOBS_APPS_UNDELIVERED = 'jobs_apps_key_undelivered';
5
+ export const JOBS_APPS_APPLIED = 'jobs_apps_key_applied';
6
+ export const JOBS_APPS_EXPIRES = 'jobs_apps_key_expires';
7
+ export const jobsAppsRef = (org) => `jobs:apps-key:${org}`;
8
+ export const jobsAppsProject = (org) => `boule-apps-${org}`;
9
+ const DAY = 86_400_000;
10
+ export function planJobsAppsKey(org, ids, steps, ready, usable, capable, now) {
11
+ const key = `jobs:apps-key:${org.id}`;
12
+ const block = (description) => ({
13
+ steps: [],
14
+ blocked: [{ key, description: `${org.slug}'s apps jobs key is blocked: ${description}` }],
15
+ });
16
+ if (!appsOn(org)) {
17
+ if ((ids[JOBS_APPS_KEY] ||
18
+ ids[JOBS_APPS_UNDELIVERED] ||
19
+ ids[JOBS_APPS_APPLIED] ||
20
+ ids.jobs_apps_key_withdrawing) &&
21
+ (!capable || !usable))
22
+ return block('the scheduler capability must be available to withdraw its provisioning key');
23
+ return ids[JOBS_APPS_KEY] ||
24
+ ids[JOBS_APPS_UNDELIVERED] ||
25
+ ids[JOBS_APPS_APPLIED] ||
26
+ ids.jobs_apps_key_withdrawing
27
+ ? {
28
+ steps: [
29
+ {
30
+ action: {
31
+ system: 'jobs',
32
+ kind: 'apps-key',
33
+ key: org.id,
34
+ desired: { mode: 'retire' },
35
+ },
36
+ step: {
37
+ description: `revoke ${org.slug}'s Jobs provisioning key, remove JOBS_APPS_* from Boule and restart it; keep existing app projects, runtime credentials, schedules and history`,
38
+ destroys: true,
39
+ migrates: false,
40
+ },
41
+ },
42
+ ],
43
+ blocked: [],
44
+ }
45
+ : { steps: [], blocked: [] };
46
+ }
47
+ if (!capable)
48
+ return block('the host has not enabled company-scoped project provisioning on its scheduler');
49
+ if (!usable || !ready.jobsPublicUrl)
50
+ return block('the scheduler and its public route are not ready');
51
+ if (!ready.canHoldSecrets)
52
+ return block('the host cannot hold credentials');
53
+ if (!ids[`coolify:boule:${org.id}`] &&
54
+ !steps.some((s) => s.action.system === 'coolify' && s.action.kind === 'boule' && s.action.key === org.id))
55
+ return block('Boule is not recorded or planned');
56
+ if (ids[JOBS_APPS_UNDELIVERED] && ids[JOBS_APPS_UNDELIVERED] !== ids[JOBS_APPS_KEY])
57
+ return block(`key ${ids[JOBS_APPS_UNDELIVERED]} was not held; inspect and revoke the undelivered key before retrying`);
58
+ if (ids[JOBS_APPS_KEY] &&
59
+ ids[JOBS_APPS_APPLIED] === ready.jobsPublicUrl &&
60
+ Date.parse(ids[JOBS_APPS_EXPIRES] ?? '') - now > 30 * DAY &&
61
+ !ids[JOBS_APPS_UNDELIVERED])
62
+ return { steps: [], blocked: [] };
63
+ return {
64
+ steps: [
65
+ {
66
+ action: {
67
+ system: 'jobs',
68
+ kind: 'apps-key',
69
+ key: org.id,
70
+ desired: {
71
+ mode: 'ensure',
72
+ url: ready.jobsPublicUrl,
73
+ expiresAt: new Date(now + 365 * DAY).toISOString(),
74
+ },
75
+ },
76
+ step: {
77
+ description: `give ${org.slug}'s Boule a company-scoped jobs project provisioner and restart Boule`,
78
+ destroys: false,
79
+ migrates: false,
80
+ },
81
+ },
82
+ ],
83
+ blocked: [],
84
+ };
85
+ }
86
+ /** Every scheduler/held-key/Coolify call is bounded, and no provider error text can carry a secret out. */
87
+ export async function applyJobsAppsKey(deps, desired) {
88
+ const at = `jobs:apps-key for ${deps.org.slug}`;
89
+ const call = async (work) => {
90
+ let timer;
91
+ try {
92
+ return await Promise.race([
93
+ work(),
94
+ new Promise((_, reject) => {
95
+ timer = setTimeout(() => reject(new Error('timeout')), deps.timeoutMs ?? 30_000);
96
+ }),
97
+ ]);
98
+ }
99
+ catch {
100
+ throw new Error(`${at}: a scheduler, held-key or Boule call did not finish; retry after checking the recorded custody state`);
101
+ }
102
+ finally {
103
+ clearTimeout(timer);
104
+ }
105
+ };
106
+ const id = deps.bouleAppId;
107
+ const project = jobsAppsProject(deps.org.id);
108
+ const mint = deps.scheduler.mintProjectKey?.bind(deps.scheduler);
109
+ const revoke = deps.scheduler.revokeProjectKey?.bind(deps.scheduler);
110
+ if (!mint || !revoke)
111
+ throw new Error(`${at}: the scheduler cannot mint and revoke scoped keys`);
112
+ const revokeOne = (key) => call(async () => {
113
+ try {
114
+ await revoke(project, key);
115
+ }
116
+ catch (error) {
117
+ if (error?.status !== 404)
118
+ throw error;
119
+ }
120
+ });
121
+ const ids = deps.recorded();
122
+ const old = ids[JOBS_APPS_KEY];
123
+ const undelivered = ids[JOBS_APPS_UNDELIVERED];
124
+ if (desired.mode === 'retire') {
125
+ deps.record('jobs_apps_key_withdrawing', '1');
126
+ deps.record(JOBS_APPS_PROJECT, project);
127
+ if (!ids.jobs_apps_project_base_url && ids[JOBS_APPS_APPLIED])
128
+ deps.record('jobs_apps_project_base_url', ids[JOBS_APPS_APPLIED]);
129
+ await deps.persist();
130
+ const revoked = new Set([old, undelivered, ids.jobs_apps_key_retiring].filter((key) => Boolean(key) && key !== 'pending'));
131
+ if (undelivered === 'pending') {
132
+ const list = deps.scheduler.listProjectKeys?.bind(deps.scheduler);
133
+ if (!list)
134
+ throw new Error(`${at}: the scheduler cannot inspect the ambiguous provisioning mint; no custody was cleared`);
135
+ for (const key of await call(() => list(project)))
136
+ if (!key.revoked && key.principal === 'boule' && key.scopes.includes('projects:manage'))
137
+ revoked.add(key.id);
138
+ if (revoked.size > 8)
139
+ throw new Error(`${at}: too many management keys to withdraw in one bounded apply; inspect this namespace`);
140
+ }
141
+ for (const key of revoked)
142
+ await revokeOne(key);
143
+ // Record withdrawal before a restart: re-enabling Apps after a failed env cleanup must mint anew.
144
+ for (const field of [
145
+ JOBS_APPS_KEY,
146
+ JOBS_APPS_UNDELIVERED,
147
+ JOBS_APPS_EXPIRES,
148
+ 'jobs_apps_key_retiring',
149
+ ])
150
+ deps.forget(field);
151
+ await call(() => deps.heldKeys.put(jobsAppsRef(deps.org.id), ''));
152
+ await deps.persist();
153
+ if (id) {
154
+ const rows = await call(() => deps.coolify.listEnvs(id));
155
+ for (const row of rows)
156
+ if (row.key.startsWith('JOBS_APPS_') && row.uuid)
157
+ await call(() => deps.coolify.deleteEnv(id, row.uuid));
158
+ await call(() => deps.coolify.restart(id));
159
+ }
160
+ deps.forget(JOBS_APPS_APPLIED);
161
+ deps.forget('jobs_apps_key_withdrawing');
162
+ await deps.persist();
163
+ return;
164
+ }
165
+ if (!id)
166
+ throw new Error(`${at}: Boule is not recorded`);
167
+ if (undelivered && undelivered !== old)
168
+ throw new Error(`${at}: undelivered key ${undelivered}; nothing was minted`);
169
+ let key = old ? await call(() => deps.heldKeys.resolve(jobsAppsRef(deps.org.id))) : null;
170
+ const expired = Date.parse(ids[JOBS_APPS_EXPIRES] ?? '') - Date.now() < 30 * DAY;
171
+ if (!key || expired) {
172
+ deps.record(JOBS_APPS_PROJECT, project);
173
+ await deps.persist();
174
+ const registered = await call(() => deps.scheduler.registerProject({
175
+ name: project,
176
+ baseUrl: ids.jobs_apps_project_base_url ?? desired.url,
177
+ }));
178
+ deps.record('jobs_apps_project_base_url', ids.jobs_apps_project_base_url ?? desired.url);
179
+ await deps.persist();
180
+ if (registered.secret)
181
+ deps.secret(registered.secret);
182
+ if (registered.syncKey)
183
+ deps.secret(registered.syncKey);
184
+ // Persist intent before the non-repeatable mint. An ambiguous timeout never mints twice.
185
+ deps.record(JOBS_APPS_UNDELIVERED, 'pending');
186
+ await deps.persist();
187
+ const issued = await call(() => mint(project, {
188
+ principal: 'boule',
189
+ scopes: ['projects:manage'],
190
+ expiresAt: desired.expiresAt,
191
+ }));
192
+ deps.record(JOBS_APPS_UNDELIVERED, issued.id);
193
+ await deps.persist();
194
+ deps.secret(issued.key);
195
+ let held = false;
196
+ for (let attempt = 0; attempt < 3 && !held; attempt++)
197
+ try {
198
+ await call(() => deps.heldKeys.put(jobsAppsRef(deps.org.id), issued.key));
199
+ held = true;
200
+ }
201
+ catch { }
202
+ if (!held || !issued.key)
203
+ throw new Error(`${at}: key ${issued.id} was not held; nothing else was written`);
204
+ if (old && old !== issued.id)
205
+ deps.record('jobs_apps_key_retiring', old);
206
+ deps.record(JOBS_APPS_KEY, issued.id);
207
+ deps.record(JOBS_APPS_EXPIRES, desired.expiresAt);
208
+ await deps.persist();
209
+ key = issued.key;
210
+ // The namespace belongs to the project, not its key, so renewal can revoke the old manager.
211
+ }
212
+ const retiring = deps.recorded().jobs_apps_key_retiring;
213
+ if (retiring) {
214
+ await revokeOne(retiring);
215
+ deps.forget('jobs_apps_key_retiring');
216
+ await deps.persist();
217
+ }
218
+ deps.secret(key);
219
+ await call(() => deps.coolify.bulkUpsertEnvs(id, [
220
+ { key: 'JOBS_APPS_URL', value: desired.url, literal: true },
221
+ { key: 'JOBS_APPS_KEY', value: key, literal: true },
222
+ ]));
223
+ await call(() => deps.coolify.restart(id));
224
+ deps.forget('jobs_apps_key_withdrawing');
225
+ deps.record(JOBS_APPS_APPLIED, desired.url);
226
+ deps.forget(JOBS_APPS_UNDELIVERED);
227
+ await deps.persist();
228
+ }
@@ -25,6 +25,8 @@ export function planJobsProjectSettings(org, ids, planned, ready, capable) {
25
25
  const steps = [];
26
26
  const blocked = [];
27
27
  for (const entry of org.hosting.entries) {
28
+ if (entry.workerApp)
29
+ continue;
28
30
  const settings = jobsProjectSettings(org, entry.key);
29
31
  if (!settings)
30
32
  continue;
@@ -35,10 +35,17 @@ export declare class HttpJobsSchedulerClient implements JobsSchedulerPort {
35
35
  mintProjectKey(project: string, grant: {
36
36
  principal: string;
37
37
  scopes: readonly string[];
38
+ expiresAt?: string;
38
39
  }): Promise<{
39
40
  id: string;
40
41
  key: string;
41
42
  }>;
43
+ listProjectKeys(project: string): Promise<{
44
+ id: string;
45
+ principal: string;
46
+ scopes: string[];
47
+ revoked: boolean;
48
+ }[]>;
42
49
  /** `DELETE /admin/projects/<name>/keys/<id>`. A refusal throws with the status only (404: no such key). */
43
50
  revokeProjectKey(project: string, keyId: string): Promise<void>;
44
51
  /** `DELETE /admin/projects/<name>`. A refusal throws with the status only. */
@@ -71,7 +71,11 @@ export class HttpJobsSchedulerClient {
71
71
  authorization: `Bearer ${this.adminToken}`,
72
72
  'content-type': 'application/json',
73
73
  },
74
- body: JSON.stringify({ principal: grant.principal, scopes: [...grant.scopes] }),
74
+ body: JSON.stringify({
75
+ principal: grant.principal,
76
+ scopes: [...grant.scopes],
77
+ ...(grant.expiresAt ? { expiresAt: grant.expiresAt } : {}),
78
+ }),
75
79
  signal: AbortSignal.timeout(ADMIN_CALL_TIMEOUT_MS),
76
80
  });
77
81
  const body = await response.json().catch(() => undefined);
@@ -88,6 +92,33 @@ export class HttpJobsSchedulerClient {
88
92
  }
89
93
  return { id: fields.id, key: fields.key };
90
94
  }
95
+ async listProjectKeys(project) {
96
+ const response = await this.fetchImpl(`${this.baseUrl}/admin/projects/${encodeURIComponent(project)}/keys`, {
97
+ headers: { authorization: `Bearer ${this.adminToken}` },
98
+ redirect: 'error',
99
+ signal: AbortSignal.timeout(ADMIN_CALL_TIMEOUT_MS),
100
+ });
101
+ if (response.status !== 200)
102
+ throw new Error('jobs scheduler: project key listing did not answer');
103
+ const body = await response.json();
104
+ if (!body || typeof body !== 'object' || !Array.isArray(body.keys))
105
+ throw new Error('jobs scheduler: invalid key listing');
106
+ return body.keys.map((value) => {
107
+ const key = value;
108
+ if (!key ||
109
+ typeof key.id !== 'string' ||
110
+ typeof key.principal !== 'string' ||
111
+ !Array.isArray(key.scopes) ||
112
+ !key.scopes.every((scope) => typeof scope === 'string'))
113
+ throw new Error('jobs scheduler: invalid key summary');
114
+ return {
115
+ id: key.id,
116
+ principal: key.principal,
117
+ scopes: key.scopes,
118
+ revoked: Boolean(key.revokedAt),
119
+ };
120
+ });
121
+ }
91
122
  /** `DELETE /admin/projects/<name>/keys/<id>`. A refusal throws with the status only (404: no such key). */
92
123
  async revokeProjectKey(project, keyId) {
93
124
  const response = await this.fetchImpl(`${this.baseUrl}/admin/projects/${encodeURIComponent(project)}/keys/${encodeURIComponent(keyId)}`, {
@@ -20,7 +20,7 @@ export declare const OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY = "coolify_own_jobs_
20
20
  * `/deliveries`) and what Boule reads and presses for one project with that project's key (`/project/*`,
21
21
  * jobs#79, foundry#1119). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
22
22
  */
23
- export declare const JOBS_ENTRY_PATH_PATTERN = "^/(sync$|timers(/|$)|deliveries$|project/)";
23
+ export declare const JOBS_ENTRY_PATH_PATTERN = "^/(sync$|timers(/|$)|deliveries$|project/|projects/)";
24
24
  /** The path pattern the route's ingress was last written with; a different pattern plans the route again. */
25
25
  export declare const OWN_JOBS_ROUTE_PATHS_SCOPE_KEY = "own_jobs_route_paths";
26
26
  export declare const JOBS_ROUTE_CALL_TIMEOUT_MS = 60000;
@@ -21,7 +21,7 @@ export const OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY = 'coolify_own_jobs_connecto
21
21
  * `/deliveries`) and what Boule reads and presses for one project with that project's key (`/project/*`,
22
22
  * jobs#79, foundry#1119). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
23
23
  */
24
- export const JOBS_ENTRY_PATH_PATTERN = '^/(sync$|timers(/|$)|deliveries$|project/)';
24
+ export const JOBS_ENTRY_PATH_PATTERN = '^/(sync$|timers(/|$)|deliveries$|project/|projects/)';
25
25
  /** The path pattern the route's ingress was last written with; a different pattern plans the route again. */
26
26
  export const OWN_JOBS_ROUTE_PATHS_SCOPE_KEY = 'own_jobs_route_paths';
27
27
  export const JOBS_ROUTE_CALL_TIMEOUT_MS = 60_000;
@@ -628,6 +628,7 @@ export interface FoundryPlanApplyOptions {
628
628
  * `uses.jobs` entry's Boule key is reported blocked.
629
629
  */
630
630
  jobsProjectKeys?: boolean;
631
+ jobsAppProjects?: boolean;
631
632
  /** The host can deliver per-app Files space alerts. */
632
633
  serviceAlertMail?: boolean;
633
634
  filesSpaceAlerts?: boolean;
@@ -639,6 +640,8 @@ export interface FoundryPlanApplyOptions {
639
640
  * below it. Without it Boule's ai key is reported blocked.
640
641
  */
641
642
  aiBouleReads?: boolean;
643
+ /** Off until AI scoped revocation and the host adapter are deployed. */
644
+ aiAppsKeys?: boolean;
642
645
  /** C2: true when the host's executor was built with a `WorkerAppDatabasePort`. */
643
646
  workerAppDatabase?: boolean;
644
647
  /** True when that port has `deletableTables`. A `workerApp.database.noDelete` is blocked without it. */
@@ -807,10 +810,12 @@ export declare class FoundryPlanApply implements PlanApply {
807
810
  private readonly analyticsPort;
808
811
  private readonly reportingPort;
809
812
  private readonly jobsProjectKeys;
813
+ private readonly jobsAppProjects;
810
814
  private readonly serviceAlertMail;
811
815
  private readonly filesSpaceAlerts;
812
816
  private readonly jobsProjectSettings;
813
817
  private readonly aiBouleReads;
818
+ private readonly aiAppsKeys;
814
819
  private readonly workerAppDatabase;
815
820
  private readonly workerAppNoDeleteCheck;
816
821
  private readonly workerAppRegistryAccountId;
@@ -876,6 +881,7 @@ export declare class FoundryPlanApply implements PlanApply {
876
881
  * Throws {@link InvalidMigrationConfirmationError} for a confirmation that is not valid.
877
882
  */
878
883
  confirmOrgPostgresMigration(orgId: OrgId, confirmation: OrgPostgresMigrationConfirmation): Promise<void>;
884
+ private aiHeld;
879
885
  private computeAdopt;
880
886
  apply(plan: Plan, confirmedOrgName: string, options?: FoundryApplyOptions): Promise<AppliedState>;
881
887
  /**
@@ -23,7 +23,9 @@ import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
23
23
  import { BOULE_EDGE_ENTRY_KEY, bouleEdgeKeyWanted, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
24
24
  import { FILES_BOULE_KEY_SCOPE_KEY, FILES_BOULE_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
25
25
  import { probeRepoKeys, repoKeyProbeTargets, repoKeysInPlay, } from './entry-repo-key.js';
26
+ import { FILES_APPS_MINTER_SCOPE_KEY, FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY, } from './files-apps-minter.js';
26
27
  import { PLATFORM_GITHUB_APP_REFS } from './held-key-refs.js';
28
+ import { JOBS_APPS_KEY, JOBS_APPS_PROJECT, JOBS_APPS_UNDELIVERED, jobsAppsProject, } from './jobs-apps-key.js';
27
29
  import { KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, } from './keys-registrar.js';
28
30
  import { readLivePlatformResources, readLiveResources, } from './live.js';
29
31
  import { hostingNames } from './locked-hostnames.js';
@@ -195,10 +197,12 @@ export class FoundryPlanApply {
195
197
  analyticsPort;
196
198
  reportingPort;
197
199
  jobsProjectKeys;
200
+ jobsAppProjects;
198
201
  serviceAlertMail;
199
202
  filesSpaceAlerts;
200
203
  jobsProjectSettings;
201
204
  aiBouleReads;
205
+ aiAppsKeys;
202
206
  workerAppDatabase;
203
207
  workerAppNoDeleteCheck;
204
208
  workerAppRegistryAccountId;
@@ -248,10 +252,12 @@ export class FoundryPlanApply {
248
252
  this.analyticsPort = options.analyticsPort ?? false;
249
253
  this.reportingPort = options.reportingPort ?? false;
250
254
  this.jobsProjectKeys = options.jobsProjectKeys ?? false;
255
+ this.jobsAppProjects = options.jobsAppProjects ?? false;
251
256
  this.filesSpaceAlerts = options.filesSpaceAlerts ?? false;
252
257
  this.serviceAlertMail = options.serviceAlertMail ?? false;
253
258
  this.jobsProjectSettings = options.jobsProjectSettings ?? false;
254
259
  this.aiBouleReads = options.aiBouleReads ?? false;
260
+ this.aiAppsKeys = options.aiAppsKeys ?? false;
255
261
  this.workerAppDatabase = options.workerAppDatabase ?? false;
256
262
  this.workerAppNoDeleteCheck = options.workerAppNoDeleteCheck ?? false;
257
263
  this.workerAppRegistryAccountId = options.workerAppRegistryAccountId;
@@ -480,6 +486,23 @@ export class FoundryPlanApply {
480
486
  await record('recorded');
481
487
  }));
482
488
  }
489
+ async aiHeld(ref) {
490
+ let timer;
491
+ try {
492
+ return Boolean(await Promise.race([
493
+ this.platformDeps?.heldKeys?.resolve(ref),
494
+ new Promise((_, reject) => {
495
+ timer = setTimeout(() => reject(new Error('timeout')), 15_000);
496
+ }),
497
+ ]));
498
+ }
499
+ catch {
500
+ return false;
501
+ }
502
+ finally {
503
+ clearTimeout(timer);
504
+ }
505
+ }
483
506
  async computeAdopt(id, input) {
484
507
  const org = await this.store.get(id);
485
508
  if (!org)
@@ -1727,11 +1750,17 @@ export class FoundryPlanApply {
1727
1750
  analyticsPort: this.analyticsPort,
1728
1751
  reportingPort: this.reportingPort,
1729
1752
  jobsProjectKeys: this.jobsProjectKeys,
1753
+ jobsAppProjects: this.jobsAppProjects,
1730
1754
  filesSpaceAlerts: this.filesSpaceAlerts,
1731
1755
  serviceAlertMail: this.serviceAlertMail,
1732
1756
  serviceAlertMailCurrent: await this.readServiceAlertMail(org, platformReadiness, recorded),
1733
1757
  jobsProjectSettings: this.jobsProjectSettings,
1734
1758
  aiBouleReads: this.aiBouleReads,
1759
+ aiAppsKeys: this.aiAppsKeys,
1760
+ aiAppsMinterHeld: this.aiAppsKeys && recorded.ai_apps_minter
1761
+ ? await this.aiHeld(`ai:apps-minter:${org.id}`)
1762
+ : true,
1763
+ aiAppsRootKeyHeld: this.aiAppsKeys ? await this.aiHeld(`ai:key:${org.slug}`) : false,
1735
1764
  workerAppDatabase: this.workerAppDatabase,
1736
1765
  workerAppNoDeleteCheck: this.workerAppNoDeleteCheck,
1737
1766
  workerAppRegistryAccountId: this.workerAppRegistryAccountId,
@@ -2520,6 +2549,11 @@ function destroyTargets(serviceIds, orgId) {
2520
2549
  'edge:key:',
2521
2550
  ];
2522
2551
  const targets = [];
2552
+ // Remove the provisioner project before its scheduler host. The service cascades to managed apps.
2553
+ if (serviceIds[JOBS_APPS_PROJECT] ||
2554
+ serviceIds[JOBS_APPS_KEY] ||
2555
+ serviceIds[JOBS_APPS_UNDELIVERED])
2556
+ targets.push({ system: 'jobs', kind: 'apps-projects', key: orgId, id: jobsAppsProject(orgId) });
2523
2557
  const adopted = adoptedResourceIds(serviceIds);
2524
2558
  for (const prefix of ORDERED_PREFIXES) {
2525
2559
  if (prefix === 'cloudflare:worker:') {
@@ -2678,7 +2712,9 @@ function keptDescriptions(serviceIds) {
2678
2712
  }
2679
2713
  function destroyStepDescriptions(targets) {
2680
2714
  return [
2681
- ...targets.map((t) => `delete ${t.system} ${t.kind} ${t.key}`),
2715
+ ...targets.map((t) => t.system === 'jobs' && t.kind === 'apps-projects'
2716
+ ? `delete Jobs namespace ${t.id}, including its app projects, schedules, history and keys`
2717
+ : `delete ${t.system} ${t.kind} ${t.key}`),
2682
2718
  'delete the declared org',
2683
2719
  ];
2684
2720
  }
@@ -2812,6 +2848,8 @@ const STEP_FIELDS = {
2812
2848
  'coolify:reporting': 'hosting.entries.uses.reporting',
2813
2849
  'coolify:reporting-boule': 'boule.apps',
2814
2850
  'coolify:org-apps-admin': 'boule.apps',
2851
+ 'files:apps-minter': 'boule.apps',
2852
+ 'jobs:apps-key': 'boule.apps',
2815
2853
  'cloudflare:apps-db-route': 'boule.apps',
2816
2854
  'cloudflare:apps-zones': 'boule.apps',
2817
2855
  };
@@ -2938,8 +2976,12 @@ function failedFields(org, detail, completed, serviceIds) {
2938
2976
  // F7: a registrar key minted and never delivered stays unresolved until a later apply revokes it.
2939
2977
  if (serviceIds[KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY])
2940
2978
  failed.add('boule.apps');
2941
- // F4 (#925): the same for an apps admin role or a route service token minted and never delivered.
2942
- if (serviceIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY] ||
2979
+ // F4 (#925): the same for an apps admin role or a route service token minted and never delivered, and
2980
+ // Boule's apps files key minted and not yet held or written (a marker that differs from its guard).
2981
+ if ((serviceIds[FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY] &&
2982
+ serviceIds[FILES_APPS_MINTER_UNDELIVERED_SCOPE_KEY] !==
2983
+ serviceIds[FILES_APPS_MINTER_SCOPE_KEY]) ||
2984
+ serviceIds[ORG_APPS_ADMIN_UNDELIVERED_SCOPE_KEY] ||
2943
2985
  serviceIds[remoteDatabaseKeys(APPS_DB_ENTRY_KEY).undelivered]) {
2944
2986
  failed.add('boule.apps');
2945
2987
  }
@@ -107,7 +107,7 @@ export declare const PLATFORM_AUTH_MCP_SCOPE_KEY = "auth_mcp_env_applied";
107
107
  * broker applied under `'1'` plans its sign-in step once more and gets the key without a hand step.
108
108
  */
109
109
  export declare const AUTH_MCP_ENV_VERSION = "2";
110
- /** True when a hosting entry of the org declares `uses.auth.mcp`: its broker needs the MCP database and secret. */
110
+ /** Legacy and configurable MCP integrations both need the broker's MCP database and secret. */
111
111
  export declare function orgDeclaresMcp(org: Pick<DeclaredOrg, 'hosting'>): boolean;
112
112
  /**
113
113
  * The `gitRef` the platform's login, in-app login and broker apps were last built from (value: the ref). A
@@ -1262,6 +1262,7 @@ export declare const PLATFORM_USAGE_TAIL_SCRIPT_NAME = "platform-usage-tail";
1262
1262
  export declare const PLATFORM_USAGE_TAIL_KEY_REF = "usage-tail:worker:key";
1263
1263
  export declare const PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY = "usage_tail_key_undelivered";
1264
1264
  export declare const PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY = "usage_tail_key_set";
1265
+ export declare const PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY = "usage_tail_applied_url";
1265
1266
  export declare const PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY = "usage_tail_applied_tag";
1266
1267
  export declare const PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY = "usage_tail_applied_sha256";
1267
1268
  export declare const PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY = "usage_tail_removed";
@@ -117,9 +117,10 @@ export const PLATFORM_AUTH_MCP_SCOPE_KEY = 'auth_mcp_env_applied';
117
117
  * broker applied under `'1'` plans its sign-in step once more and gets the key without a hand step.
118
118
  */
119
119
  export const AUTH_MCP_ENV_VERSION = '2';
120
- /** True when a hosting entry of the org declares `uses.auth.mcp`: its broker needs the MCP database and secret. */
120
+ /** Legacy and configurable MCP integrations both need the broker's MCP database and secret. */
121
121
  export function orgDeclaresMcp(org) {
122
- return org.hosting.enabled && org.hosting.entries.some((e) => Boolean(e.uses?.auth?.mcp));
122
+ return (org.hosting.enabled &&
123
+ org.hosting.entries.some((e) => Boolean(e.uses?.auth?.mcp || e.uses?.auth?.mcpResources)));
123
124
  }
124
125
  /**
125
126
  * The `gitRef` the platform's login, in-app login and broker apps were last built from (value: the ref). A
@@ -1461,6 +1462,7 @@ export const PLATFORM_USAGE_TAIL_SCRIPT_NAME = 'platform-usage-tail';
1461
1462
  export const PLATFORM_USAGE_TAIL_KEY_REF = 'usage-tail:worker:key';
1462
1463
  export const PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY = 'usage_tail_key_undelivered';
1463
1464
  export const PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY = 'usage_tail_key_set';
1465
+ export const PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY = 'usage_tail_applied_url';
1464
1466
  export const PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY = 'usage_tail_applied_tag';
1465
1467
  export const PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY = 'usage_tail_applied_sha256';
1466
1468
  export const PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY = 'usage_tail_removed';
@@ -3488,12 +3490,17 @@ function planUsageTail(platform, recordedIds, executorBlocked, edgeApplied) {
3488
3490
  const applied = deployed &&
3489
3491
  Boolean(recordedIds[PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY]) &&
3490
3492
  recordedIds[PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY] === usageTail.tag &&
3491
- recordedIds[PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY] === usageTail.sha256;
3493
+ recordedIds[PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY] === usageTail.sha256 &&
3494
+ recordedIds[PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY] === `https://${platform.edge.hostname}`;
3492
3495
  if (applied)
3493
3496
  return { step: null, blocked: [] };
3494
3497
  return {
3495
3498
  step: {
3496
- action: action({ gitRef: usageTail.tag, sha256: usageTail.sha256 }),
3499
+ action: action({
3500
+ gitRef: usageTail.tag,
3501
+ sha256: usageTail.sha256,
3502
+ url: `https://${platform.edge.hostname}`,
3503
+ }),
3497
3504
  step: {
3498
3505
  description: `deploy the usage tail Worker ${PLATFORM_USAGE_TAIL_SCRIPT_NAME} from edge release ${usageTail.tag}, with its held key`,
3499
3506
  destroys: false,
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { usageTailArtifactUrl } from '../platform.js';
3
- import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
3
+ import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
4
4
  /** The date Cloudflare's Workers runtime compatibility is pinned to; no Workers feature here needs a later one. */
5
5
  const USAGE_TAIL_COMPATIBILITY_DATE = '2026-10-01';
6
6
  /**
@@ -28,6 +28,7 @@ export async function provisionPlatformUsageTail(context, deps) {
28
28
  PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY,
29
29
  PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY,
30
30
  PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY,
31
+ PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY,
31
32
  PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY,
32
33
  ]) {
33
34
  context.recordScopeId(key, '');
@@ -64,7 +65,9 @@ export async function provisionPlatformUsageTail(context, deps) {
64
65
  artifactUrl: usageTailArtifactUrl(usageTail.tag),
65
66
  artifactSha256: usageTail.sha256,
66
67
  compatibilityDate: USAGE_TAIL_COMPATIBILITY_DATE,
67
- bindings: [{ type: 'plain_text', name: 'EDGE_USAGE_URL', text: edgeUrl }],
68
+ bindings: [
69
+ { type: 'plain_text', name: 'EDGE_USAGE_URL', text: `https://${platform.edge.hostname}` },
70
+ ],
68
71
  });
69
72
  // The script is foundry's from here on. Recorded now, not at the end: after
70
73
  // a later failure the next plan must repair this Worker, not take it for a
@@ -84,6 +87,7 @@ export async function provisionPlatformUsageTail(context, deps) {
84
87
  // Last, after every write the applied values depend on.
85
88
  context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, usageTail.tag);
86
89
  context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, usageTail.sha256);
90
+ context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_URL_SCOPE_KEY, `https://${platform.edge.hostname}`);
87
91
  return { resourceId: PLATFORM_USAGE_TAIL_SCRIPT_NAME };
88
92
  }
89
93
  /** Rule 1: delivery is tried up to `deliverAttempts` times; the error names the ref only, never the cause. */
@@ -137,8 +137,8 @@ export declare const releaseEventPayload: z.ZodObject<{
137
137
  }, z.core.$strip>;
138
138
  export declare const releaseEventKind: z.ZodEnum<{
139
139
  step: "step";
140
- open: "open";
141
140
  check: "check";
141
+ open: "open";
142
142
  passed: "passed";
143
143
  hard_fail: "hard_fail";
144
144
  promote: "promote";
@@ -1556,7 +1556,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1556
1556
  tableName: "foundry_audit_events";
1557
1557
  dataType: "string";
1558
1558
  columnType: "PgText";
1559
- data: "applied" | "failed" | "refused" | "destroyed";
1559
+ data: "applied" | "refused" | "failed" | "destroyed";
1560
1560
  driverParam: string;
1561
1561
  notNull: false;
1562
1562
  hasDefault: false;
@@ -1568,7 +1568,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
1568
1568
  identity: undefined;
1569
1569
  generated: undefined;
1570
1570
  }, {}, {
1571
- $type: "applied" | "failed" | "refused" | "destroyed";
1571
+ $type: "applied" | "refused" | "failed" | "destroyed";
1572
1572
  }>;
1573
1573
  reason: import("drizzle-orm/pg-core").PgColumn<{
1574
1574
  name: "reason";
@@ -196,9 +196,18 @@ export function validateEntryWorkerApp(entry, hosting, names) {
196
196
  if (Object.keys(entry.env).length > 0) {
197
197
  return `${at} has no Coolify app, so env does not apply: use workerApp.vars or workerApp.secrets`;
198
198
  }
199
- const unsupported = Object.keys(entry.uses ?? {}).find((use) => use !== 'auth' && use !== 'audit' && use !== 'analytics' && use !== 'reporting');
199
+ const unsupported = Object.keys(entry.uses ?? {}).find((use) => use !== 'auth' &&
200
+ use !== 'audit' &&
201
+ use !== 'analytics' &&
202
+ use !== 'reporting' &&
203
+ use !== 'mail' &&
204
+ use !== 'files' &&
205
+ use !== 'customerGrants' &&
206
+ use !== 'repo' &&
207
+ use !== 'jobs' &&
208
+ use !== 'ai');
200
209
  if (unsupported)
201
- return `${at} supports uses.auth, uses.audit, uses.analytics and uses.reporting only, not uses.${unsupported}`;
210
+ return `${at} supports uses.auth, uses.audit, uses.analytics, uses.reporting, uses.mail, uses.files, uses.jobs, uses.customerGrants, uses.repo and uses.ai only, not uses.${unsupported}`;
202
211
  if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
203
212
  return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "${WORKER_APP_NAME_SUFFIX}") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
204
213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,10 @@
19
19
  "types": "./dist/modules/index.d.ts",
20
20
  "default": "./dist/modules/index.js"
21
21
  },
22
+ "./mcp-resources": {
23
+ "types": "./dist/mcp-resources/index.d.ts",
24
+ "default": "./dist/mcp-resources/index.js"
25
+ },
22
26
  "./catalogue": {
23
27
  "types": "./dist/catalogue/index.d.ts",
24
28
  "default": "./dist/catalogue/index.js"