@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/plan/diff.js CHANGED
@@ -3,6 +3,7 @@ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
3
3
  import { APP_ENTRY_KEY, MAIL_APP_KEY_ENV, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, sendsMail, signsInBoth, signsInCustomers, } from '../org.js';
4
4
  import { entryHasDatabase } from '../worker-app-config.js';
5
5
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, entryClientIdScopeKey, withRecordedMessagingProject, } from './agora.js';
6
+ import { planAiAppsMinter } from './ai-apps-minter.js';
6
7
  import { AI_OFFERS_SCOPE_KEY, aiKeyPermissions, aiOffers, planAiBouleKey } from './ai-boule-key.js';
7
8
  import { planAnalyticsBoule } from './analytics-boule.js';
8
9
  import { analyticsEnvDrift, analyticsWanted, desiredAnalyticsEnv } from './analytics.js';
@@ -25,17 +26,19 @@ import { planEntryAudit } from './entry-audit.js';
25
26
  import { authEventsAppliedScopeKey, authEventsBlocked, authEventsMarker, } from './entry-auth-events.js';
26
27
  import { CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsShapeMatches, customerGrantsToDrop, wantedCustomerGrants, } from './entry-customer-grants.js';
27
28
  import { planEdgeReservedLabels, planEdgeSlug, planEntryEdgeKeys } from './entry-edge-key.js';
28
- import { planFilesKeys } from './entry-files-key.js';
29
+ import { planFilesKeys, usesServerFiles } from './entry-files-key.js';
29
30
  import { planEntryJobsBouleKeys } from './entry-jobs-boule-key.js';
30
31
  import { planEntryJobs } from './entry-jobs.js';
31
32
  import { planEntryRepoKeys } from './entry-repo-key.js';
32
33
  import { planEntryReportingKeys } from './entry-reporting-key.js';
33
- import { BROKER_WORKSPACES_DATABASE_ENV, workspacesDatabaseRef } from './entry-workspaces.js';
34
+ import { BROKER_WORKSPACES_DATABASE_ENV, serverWorkspaces, workspacesDatabaseRef, } from './entry-workspaces.js';
35
+ import { planFilesAppsMinter } from './files-apps-minter.js';
34
36
  import { planFilesBouleKey } from './files-boule-key.js';
35
37
  import { planFilesSpaceAlerts } from './files-space-alert.js';
36
38
  import { gateIdRecordKey, hasWildcardGate, planGates } from './gates.js';
37
39
  import { githubSourceResourceKey, planGithubSource } from './github-source.js';
38
40
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
41
+ import { planJobsAppsKey } from './jobs-apps-key.js';
39
42
  import { planJobsProjectSettings } from './jobs-project-settings.js';
40
43
  import { planKeysRegistrar } from './keys-registrar.js';
41
44
  import { desiredKosmosEnv, kosmosEnvDrift } from './kosmos.js';
@@ -457,13 +460,13 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
457
460
  if (filesOrganisation.step)
458
461
  plannedSteps.push(filesOrganisation.step);
459
462
  const aiBouleReads = options.aiBouleReads ?? false;
460
- const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader, recordedIds, aiBouleReads);
463
+ const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader, recordedIds, aiBouleReads, options.aiAppsKeys ?? false);
461
464
  if (aiOrganisation.step)
462
465
  plannedSteps.push(aiOrganisation.step);
463
466
  const aiBudget = planAiBudget(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
464
467
  if (aiBudget.step)
465
468
  plannedSteps.push(aiBudget.step);
466
- const aiKey = planAiKey(org, recordedIds, platformReadiness.aiReady, aiBouleReads);
469
+ const aiKey = planAiKey(org, recordedIds, platformReadiness.aiReady, aiBouleReads, options.aiAppsKeys ?? false);
467
470
  if (aiKey.step)
468
471
  plannedSteps.push(aiKey.step);
469
472
  // Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
@@ -557,7 +560,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
557
560
  plannedSteps.push(...app.steps);
558
561
  // The broker's workspaces database, before any broker entry that needs it: made once, when none is held or set.
559
562
  const workspacesDatabasePlanned = declaredOrg.hosting.enabled &&
560
- declaredOrg.hosting.entries.some((e) => e.uses?.auth?.workspaces) &&
563
+ declaredOrg.hosting.entries.some((e) => serverWorkspaces(e)) &&
561
564
  (options.broker ?? false) &&
562
565
  !(options.brokerWorkspacesDatabase ?? false) &&
563
566
  (options.provisionWorkspacesDatabase ?? false);
@@ -753,6 +756,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
753
756
  // foundry#1121: Boule's files key, after Boule and after the org's own files key it is minted below.
754
757
  const filesBouleKey = planFilesBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
755
758
  plannedSteps.push(...filesBouleKey.steps);
759
+ // Files for Boule's own apps: one key that mints each app's files key, while apps are on.
760
+ const filesAppsMinter = planFilesAppsMinter(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
761
+ plannedSteps.push(...filesAppsMinter.steps);
756
762
  const filesSpaceAlerts = planFilesSpaceAlerts(org, recordedIds, platformReadiness.filesReady && !filesOrganisation.blocked, options.filesSpaceAlerts ?? false);
757
763
  plannedSteps.push(...filesSpaceAlerts.steps);
758
764
  const serviceAlertMail = planServiceAlertMail(org, platformReadiness, options.serviceAlertMail ?? false, options.ownMailKeyHeld ?? false, options.serviceAlertMailCurrent);
@@ -760,6 +766,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
760
766
  // foundry#1122: Boule's ai key, after Boule and after the org's own ai key it is minted below.
761
767
  const aiBouleKey = planAiBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)(), aiBouleReads);
762
768
  plannedSteps.push(...aiBouleKey.steps);
769
+ const aiAppsMinter = planAiAppsMinter(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)(), options.aiAppsKeys ?? false, options.aiAppsRootKeyHeld ?? false, options.aiAppsMinterHeld ?? true);
770
+ plannedSteps.push(...aiAppsMinter.steps);
763
771
  // foundry#949: the company's kosmos, only once it turns it on. Its fixed ZITADEL client first, the same
764
772
  // reason Boule's and Agora's do: `coolifyKosmos` reads this step's recorded client id.
765
773
  const kosmosClient = planKosmosClient(org, byKey, claimed);
@@ -809,6 +817,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
809
817
  // A company on shared services registers on the scheduler the host supplies (the owner company's); a company
810
818
  // that runs its own services never does, only on its own.
811
819
  const schedulerUsable = (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady;
820
+ const jobsApps = planJobsAppsKey(org, recordedIds, plannedSteps, platformReadiness, schedulerUsable, options.jobsAppProjects ?? false, (options.now ?? Date.now)());
821
+ plannedSteps.push(...jobsApps.steps);
812
822
  // A `uses.jobs` entry registers with the scheduler after its app, whose env the step writes.
813
823
  const entryJobs = planEntryJobs(org, recordedIds, plannedSteps, platformReadiness, schedulerUsable);
814
824
  plannedSteps.push(...entryJobs.steps);
@@ -1095,6 +1105,8 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1095
1105
  unmanaged.push(...workspaces.unmanaged);
1096
1106
  unmanaged.push(...filesKeys.blocked);
1097
1107
  unmanaged.push(...filesBouleKey.blocked);
1108
+ unmanaged.push(...filesAppsMinter.blocked);
1109
+ unmanaged.push(...aiAppsMinter.blocked);
1098
1110
  unmanaged.push(...filesSpaceAlerts.blocked);
1099
1111
  unmanaged.push(...serviceAlertMail.blocked);
1100
1112
  unmanaged.push(...entryAiKeys.blocked);
@@ -1118,6 +1130,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1118
1130
  unmanaged.push(...appsAdmin.blocked);
1119
1131
  unmanaged.push(...appsDbRoute.blocked);
1120
1132
  unmanaged.push(...appsZones.blocked);
1133
+ unmanaged.push(...jobsApps.blocked);
1121
1134
  unmanaged.push(...entryJobs.blocked);
1122
1135
  unmanaged.push(...jobsSettings.blocked);
1123
1136
  unmanaged.push(...entryJobsBouleKeys.blocked);
@@ -2624,7 +2637,7 @@ ownFilesPending = false) {
2624
2637
  blocked: `${org.slug}'s Files organisation is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
2625
2638
  };
2626
2639
  }
2627
- const shareHost = org.domain && org.hosting.entries.some((e) => e.uses?.files?.shareHost)
2640
+ const shareHost = org.domain && org.hosting.entries.some((e) => usesServerFiles(e) && e.uses?.files?.shareHost)
2628
2641
  ? `share.${org.domain}`
2629
2642
  : null;
2630
2643
  return {
@@ -2673,15 +2686,17 @@ ownFilesPending = false) {
2673
2686
  * #177: blocked, not planned, while `aiReady` is false -- the platform's own
2674
2687
  * AI host (#172) is off or not yet applied.
2675
2688
  */
2676
- function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false, recordedIds = {}, bouleReads = false) {
2689
+ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false, recordedIds = {}, bouleReads = false, appsKeys = false) {
2677
2690
  const key = resourceKey('ai', 'organisation', org.id);
2678
2691
  const live = byKey.get(key);
2679
- const offers = aiOffers(bouleReads);
2692
+ const offers = aiOffers(bouleReads, appsKeys);
2680
2693
  if (live) {
2681
2694
  claimed.add(key);
2682
2695
  // foundry#1122: once ai offers Boule's reads, an organisation offered fewer is offered them (both calls
2683
2696
  // are repeatable). Without the host's word nothing changes.
2684
- if (!bouleReads || !aiReady || recordedIds[AI_OFFERS_SCOPE_KEY] === JSON.stringify(offers)) {
2697
+ if ((!bouleReads && !appsKeys) ||
2698
+ !aiReady ||
2699
+ recordedIds[AI_OFFERS_SCOPE_KEY] === JSON.stringify(offers)) {
2685
2700
  return { step: null, blocked: null };
2686
2701
  }
2687
2702
  return {
@@ -2804,14 +2819,15 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
2804
2819
  * organisation to mint a key inside yet, and {@link planAiOrganisation}
2805
2820
  * already reports the platform's AI host as missing once.
2806
2821
  */
2807
- function planAiKey(org, recordedIds, aiReady, bouleReads = false) {
2822
+ function planAiKey(org, recordedIds, aiReady, bouleReads = false, appsKeys = false) {
2808
2823
  const key = resourceKey('ai', 'key', org.id);
2809
- const permissions = aiOffers(bouleReads);
2824
+ const permissions = aiOffers(bouleReads, appsKeys);
2810
2825
  // foundry#1122: a delivered key that holds fewer than the organisation now offers is minted again with them,
2811
2826
  // so Boule's key can be minted below it. Without the host's word a delivered key plans nothing.
2812
2827
  const widen = Boolean(recordedIds[key]) &&
2813
- bouleReads &&
2814
- aiKeyPermissions(recordedIds) !== JSON.stringify(permissions);
2828
+ (bouleReads || appsKeys) &&
2829
+ (aiKeyPermissions(recordedIds) !== JSON.stringify(permissions) ||
2830
+ (appsKeys && recordedIds.ai_key_purpose !== 'company_root'));
2815
2831
  if (recordedIds[key] && !widen)
2816
2832
  return { step: null, blocked: null }; // delivered -- a re-run plans nothing.
2817
2833
  if (!aiReady)
@@ -2838,6 +2854,7 @@ function planAiKey(org, recordedIds, aiReady, bouleReads = false) {
2838
2854
  name: `${org.slug}-app`,
2839
2855
  expiresDays: 365,
2840
2856
  permissions,
2857
+ ...(appsKeys ? { protectedRoot: true } : {}),
2841
2858
  ...(widen ? { mode: 'widen' } : {}),
2842
2859
  },
2843
2860
  },
@@ -4809,6 +4826,9 @@ export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired, b
4809
4826
  // An entry both sign in to is registered (`registered-app.ts`), not an `AUTH_APPS` entry.
4810
4827
  if (!auth || signsInBoth(auth))
4811
4828
  continue;
4829
+ // Boule's registrar owns this copy's workspaces key; AUTH_APPS must not replace it.
4830
+ if (entry.workerApp && auth.workspaces)
4831
+ continue;
4812
4832
  const at = `${org.slug}'s ${entry.key} broker entry`;
4813
4833
  const key = `coolify:broker-entry:${entry.key}`;
4814
4834
  const warnings = recordedIds[brokerEntryWarningsScopeKey(entry.key)];
@@ -9,6 +9,8 @@ import type { OrgPlatformReadiness } from './platform-org.js';
9
9
  export declare const AI_ENTRY_KEY_DAYS = 365;
10
10
  /** A key with fewer days left than this is renewed. */
11
11
  export declare const AI_ENTRY_RENEW_WINDOW_DAYS = 30;
12
+ /** Worker app keys are delivered by Boule; the server plan must never mint or write their env. */
13
+ export declare const usesServerAi: (entry: Pick<HostingEntry, "uses" | "workerApp">) => boolean;
12
14
  /** Flat scope ids (no `:`) per entry. Entry keys hold neither `:` nor `.`. */
13
15
  export declare const entryAiUndeliveredScopeKey: (entryKey: string) => string;
14
16
  /** The ISO time the entry's live key expires. */
@@ -9,6 +9,8 @@ import { appStepKey } from './platform-diff.js';
9
9
  export const AI_ENTRY_KEY_DAYS = 365;
10
10
  /** A key with fewer days left than this is renewed. */
11
11
  export const AI_ENTRY_RENEW_WINDOW_DAYS = 30;
12
+ /** Worker app keys are delivered by Boule; the server plan must never mint or write their env. */
13
+ export const usesServerAi = (entry) => Boolean(entry.uses?.ai) && !entry.workerApp;
12
14
  const DAY_MS = 24 * 60 * 60 * 1000;
13
15
  /** Flat scope ids (no `:`) per entry. Entry keys hold neither `:` nor `.`. */
14
16
  export const entryAiUndeliveredScopeKey = (entryKey) => `ai_key_undelivered_${entryKey}`;
@@ -74,7 +76,7 @@ export function planEntryAiKeys(org, recordedIds, plannedSteps, readiness, now)
74
76
  });
75
77
  }
76
78
  }
77
- const declared = new Set(org.hosting.entries.filter((e) => e.uses?.ai).map((e) => e.key));
79
+ const declared = new Set(org.hosting.entries.filter(usesServerAi).map((e) => e.key));
78
80
  const keyPrefix = `ai:key:${org.id}:`;
79
81
  // A key whose entry is gone, or no longer declares `uses.ai`, is revoked.
80
82
  if (readiness.aiReady) {
@@ -100,7 +102,7 @@ export function planEntryAiKeys(org, recordedIds, plannedSteps, readiness, now)
100
102
  }
101
103
  }
102
104
  for (const entry of org.hosting.entries) {
103
- if (!entry.uses?.ai)
105
+ if (!usesServerAi(entry))
104
106
  continue;
105
107
  const at = `${org.slug}'s ${entry.key} ai key`;
106
108
  const stepKey = appStepKey(org, entry.key);
@@ -39,7 +39,7 @@ export declare function ensureCustomerGrantsCredential(input: {
39
39
  persist: () => Promise<void>;
40
40
  secret: (value: string) => void;
41
41
  }): Promise<string>;
42
- /** The entries that declare `uses.customerGrants`, in declared order. */
42
+ /** Server entries: Boule provisions Worker credentials privately, never through the static server manifest. */
43
43
  export declare const customerGrantsEntries: (org: Pick<DeclaredOrg, "hosting">) => HostingEntry[];
44
44
  /** Entry keys that were given a credential and no longer declare the flag (or are gone). */
45
45
  export declare function customerGrantsToDrop(org: Pick<DeclaredOrg, 'hosting'>, recorded: Readonly<Record<string, string>>): string[];
@@ -49,8 +49,10 @@ export async function ensureCustomerGrantsCredential(input) {
49
49
  await input.persist();
50
50
  return credential;
51
51
  }
52
- /** The entries that declare `uses.customerGrants`, in declared order. */
53
- export const customerGrantsEntries = (org) => org.hosting.enabled ? org.hosting.entries.filter((e) => e.uses?.customerGrants) : [];
52
+ /** Server entries: Boule provisions Worker credentials privately, never through the static server manifest. */
53
+ export const customerGrantsEntries = (org) => org.hosting.enabled
54
+ ? org.hosting.entries.filter((e) => e.uses?.customerGrants && !e.workerApp)
55
+ : [];
54
56
  /** Entry keys that were given a credential and no longer declare the flag (or are gone). */
55
57
  export function customerGrantsToDrop(org, recorded) {
56
58
  const wanted = new Set(customerGrantsEntries(org).map((e) => e.key));
@@ -7,6 +7,16 @@ import type { OrgPlatformReadiness } from './platform-org.js';
7
7
  * it. A secret goes to `deliverSecret` (the org key) or into the entry's env
8
8
  * (an entry key) and nowhere else.
9
9
  */
10
+ /**
11
+ * An entry foundry mints a files key for: a server entry with `uses.files`. A Worker app's files keys come from
12
+ * its Boule (`files-apps-minter.ts`), so foundry plans nothing for one.
13
+ */
14
+ export declare const usesServerFiles: (entry: {
15
+ uses?: {
16
+ files?: unknown;
17
+ };
18
+ workerApp?: unknown;
19
+ }) => boolean;
10
20
  /** How long a key lives. files refuses more than 365 and anything past its minter's own expiry. */
11
21
  export declare const FILES_KEY_DAYS = 365;
12
22
  /** A key with fewer days left than this is renewed. */
@@ -7,6 +7,11 @@ import { appStepKey } from './platform-diff.js';
7
7
  * it. A secret goes to `deliverSecret` (the org key) or into the entry's env
8
8
  * (an entry key) and nowhere else.
9
9
  */
10
+ /**
11
+ * An entry foundry mints a files key for: a server entry with `uses.files`. A Worker app's files keys come from
12
+ * its Boule (`files-apps-minter.ts`), so foundry plans nothing for one.
13
+ */
14
+ export const usesServerFiles = (entry) => Boolean(entry.uses?.files) && !entry.workerApp;
10
15
  /** How long a key lives. files refuses more than 365 and anything past its minter's own expiry. */
11
16
  export const FILES_KEY_DAYS = 365;
12
17
  /** A key with fewer days left than this is renewed. */
@@ -211,7 +216,7 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
211
216
  key: `files:agora-key:${org.id}`,
212
217
  });
213
218
  }
214
- const entries = org.hosting.entries.filter((e) => e.uses?.files);
219
+ const entries = org.hosting.entries.filter(usesServerFiles);
215
220
  const declared = new Set(entries.map((e) => e.key));
216
221
  const rootKeyId = recordedIds[`files:key:${org.id}`];
217
222
  // A key whose entry is gone, or no longer declares `uses.files`, is revoked:
@@ -40,7 +40,7 @@ export const JOBS_BOULE_KEY_GRANT = {
40
40
  principal: 'boule',
41
41
  scopes: ['jobs:read', 'jobs:control'],
42
42
  };
43
- const usesJobs = (entry) => entry.uses?.jobs === true;
43
+ const usesJobs = (entry) => !entry.workerApp && Boolean(entry.uses?.jobs);
44
44
  /**
45
45
  * Mint or deliver one step per `uses.jobs` entry whose key is not in Boule's env at the scheduler's current
46
46
  * address; retire one per entry that no longer declares it and still has a key recorded. Blocked, not planned
@@ -40,7 +40,7 @@ export function planEntryJobs(org, recordedIds, plannedSteps, readiness, schedul
40
40
  return { steps, blocked };
41
41
  const appPlanned = (stepKey) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'app' && p.action.key === stepKey);
42
42
  for (const entry of org.hosting.entries) {
43
- if (!entry.uses?.jobs)
43
+ if (!entry.uses?.jobs || entry.workerApp)
44
44
  continue;
45
45
  const stepKey = appStepKey(org, entry.key);
46
46
  const key = `jobs:project:${stepKey}`;
@@ -66,7 +66,7 @@ export function repoKeyTarget(org, repo) {
66
66
  * has no app, so it counts as none: a key it had is removed, and made again when it goes live.
67
67
  */
68
68
  export const repoKeyEntries = (org) => org.hosting.enabled
69
- ? org.hosting.entries.filter((e) => e.uses?.repo && e.repo !== null && e.live !== false)
69
+ ? org.hosting.entries.filter((e) => e.uses?.repo && !e.workerApp && e.repo !== null && e.live !== false)
70
70
  : [];
71
71
  /** Entry keys that were given a key and no longer declare `uses.repo` (or are gone, or hosting is off). */
72
72
  export function repoKeysToRemove(org, recorded) {
@@ -98,7 +98,7 @@ export function planEntryRepoKeys(org, recorded, plannedSteps, readiness) {
98
98
  const appPlanned = (stepKey) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === 'app' && p.action.key === stepKey);
99
99
  for (const entry of org.hosting.enabled ? org.hosting.entries : []) {
100
100
  const use = entry.uses?.repo;
101
- if (!use)
101
+ if (!use || entry.workerApp)
102
102
  continue;
103
103
  const stepKey = appStepKey(org, entry.key);
104
104
  const key = `github:deploy-key:${stepKey}`;
@@ -4,6 +4,11 @@ import { type DeclaredOrg, type HostingEntry } from '../org.js';
4
4
  * entry, holds it, and gives it to two places: the app's env (`WORKSPACES_API_KEY`) and, as a sha256, the
5
5
  * broker entry (`workspacesKey`). The broker never sees the key and the plan never holds it.
6
6
  */
7
+ /** Worker copies register through Boule, whose registrar answer owns the per-copy key. */
8
+ export declare const serverWorkspaces: (entry: HostingEntry) => {
9
+ roles: string[];
10
+ invitePath: string;
11
+ } | undefined;
7
12
  /** The marker that the entry's key was put in the held-key store: its ref, never the key. */
8
13
  export declare const entryWorkspacesMintedScopeKey: (entryKey: string) => string;
9
14
  /** The held-key ref of an entry's workspaces key. At most 123 characters: a UUID and an entry key of up to 63. */
@@ -8,6 +8,8 @@ import { orgTarget, platformTarget } from './own-services.js';
8
8
  * entry, holds it, and gives it to two places: the app's env (`WORKSPACES_API_KEY`) and, as a sha256, the
9
9
  * broker entry (`workspacesKey`). The broker never sees the key and the plan never holds it.
10
10
  */
11
+ /** Worker copies register through Boule, whose registrar answer owns the per-copy key. */
12
+ export const serverWorkspaces = (entry) => entry.workerApp ? undefined : entry.uses?.auth?.workspaces;
11
13
  /** The marker that the entry's key was put in the held-key store: its ref, never the key. */
12
14
  export const entryWorkspacesMintedScopeKey = (entryKey) => `workspaces_key_minted.${entryKey}`;
13
15
  /** The held-key ref of an entry's workspaces key. At most 123 characters: a UUID and an entry key of up to 63. */
@@ -26,7 +28,7 @@ export const workspacesUrl = (org) => `https://${loginHostnameFor(org)}/api`;
26
28
  * broker entry either, so the link never points at a private workspace address.
27
29
  */
28
30
  export function wantedWorkspaces(org, entry) {
29
- const workspaces = entry.uses?.auth?.workspaces;
31
+ const workspaces = serverWorkspaces(entry);
30
32
  const host = entryHostnames(org, entry)[0];
31
33
  if (!workspaces || !host)
32
34
  return null;
@@ -1538,6 +1538,16 @@ export interface AiOrganisationPort {
1538
1538
  id: string;
1539
1539
  secret: string;
1540
1540
  }>;
1541
+ /** Trusted infrastructure-root issuance route; ordinary operator app/user issuance remains ordinary. */
1542
+ issueOrganisationRootKey?(organisationId: string, input: {
1543
+ name: string;
1544
+ expiresDays: number;
1545
+ permissions: string[];
1546
+ }): Promise<{
1547
+ organisationId: string;
1548
+ id: string;
1549
+ secret: string;
1550
+ }>;
1541
1551
  /**
1542
1552
  * F6a: mints an entry's key below the org's own key, matching ai's
1543
1553
  * `POST /v1/keys` (wtfalch/ai#162, SDK `mintKey`), which the management key
@@ -1569,6 +1579,12 @@ export interface AiOrganisationPort {
1569
1579
  secret: string;
1570
1580
  expiresAt: string;
1571
1581
  }>;
1582
+ /** Ordinary descendant revocation authenticated by explicit company parent authority, never the platform token. */
1583
+ revokeDescendantKey?(input: {
1584
+ organisationId: string;
1585
+ credential: string;
1586
+ keyId: string;
1587
+ }): Promise<unknown>;
1572
1588
  /**
1573
1589
  * F6a: registers `address` as the org's webhook, matching `PUT /v1/webhooks`
1574
1590
  * (`setWebhookAddress`). `secret` is present only when ai created the
@@ -1735,10 +1751,18 @@ export interface JobsSchedulerPort {
1735
1751
  mintProjectKey?(project: string, grant: {
1736
1752
  principal: string;
1737
1753
  scopes: readonly string[];
1754
+ expiresAt?: string;
1738
1755
  }): Promise<{
1739
1756
  id: string;
1740
1757
  key: string;
1741
1758
  }>;
1759
+ /** Names only, used to recover an ambiguous one-time provisioning mint without deleting runtime projects. */
1760
+ listProjectKeys?(project: string): Promise<{
1761
+ id: string;
1762
+ principal: string;
1763
+ scopes: string[];
1764
+ revoked: boolean;
1765
+ }[]>;
1742
1766
  /** Revokes one project key (`DELETE /admin/projects/<name>/keys/<id>`); an unknown key throws with `status: 404`. */
1743
1767
  revokeProjectKey?(project: string, keyId: string): Promise<void>;
1744
1768
  }
@@ -4253,6 +4277,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
4253
4277
  * foundry#1122, `plan/ai-boule-key.ts`: Boule's ai key, minted below the org's own ai key under rule 1, held,
4254
4278
  * and written to Boule's `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY`. See `ai-boule-key-apply.ts`.
4255
4279
  */
4280
+ private aiAppsMinter;
4256
4281
  private aiBouleKey;
4257
4282
  /**
4258
4283
  * REVOKE SEAM (ai#157): revokes through `revokeKey` when the port has one.
@@ -4323,6 +4348,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
4323
4348
  * `files-boule-key-apply.ts`.
4324
4349
  */
4325
4350
  private filesBouleKey;
4351
+ /** Files for Boule's own apps (`files-apps-minter.ts`): the key Boule mints each app's files key with. */
4352
+ private filesAppsMinter;
4326
4353
  /**
4327
4354
  * Revokes one entry key. When the call fails the id joins the entry's list
4328
4355
  * of keys left live, which the plan shows, and the step goes on: the app
@@ -9,6 +9,7 @@ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
9
9
  import { AI_ENTRY_PERMISSIONS, APPS_DOMAIN, APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, FILES_SETTINGS_ENV_NAMES, FULL_COMMIT_SHA, MAIL_APP_KEY_ENV, MAIL_SETTINGS_ENV_NAMES, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, filesCollectionEnvName, heldEnvRef, isLiveEntry, isMainEntry, kosmosOn, mailAppKeyOf, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sendsMail, sharedSecretRef, signsInBoth, signsInCustomers, } from '../org.js';
10
10
  import { adoptedKeysUnreadable, parseAdoptSnapshot } from './adopt.js';
11
11
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, DESKTOP_AUTH_PORT, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, desktopAuthInternalName, entryClientIdScopeKey, } from './agora.js';
12
+ import { ensureAiAppsMinter } from './ai-apps-minter.js';
12
13
  import { ensureAiBouleKey } from './ai-boule-key-apply.js';
13
14
  import { AI_KEY_PERMISSIONS_SCOPE_KEY, AI_KEY_PREVIOUS_SCOPE_KEY, AI_OFFERS_SCOPE_KEY, parseIds as parseAiIds, } from './ai-boule-key.js';
14
15
  import { ANALYTICS_BOULE_APPLIED_SCOPE_KEY, ANALYTICS_BOULE_ENV_NAMES, analyticsBouleMarker, } from './analytics-boule.js';
@@ -50,12 +51,13 @@ import { applyEntryAudit } from './entry-audit.js';
50
51
  import { AUTH_EVENTS_ENV, authEventsAppliedScopeKey, authEventsMarker, authEventsWanted, ensureAuthEventsSecret, heldAuthEventsSecret, } from './entry-auth-events.js';
51
52
  import { CUSTOMER_GRANTS_CREDENTIAL_ENV, CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsJson, customerGrantsMintedScopeKey, customerGrantsRef, customerGrantsToDrop, ensureCustomerGrantsCredential, wantedCustomerGrants, } from './entry-customer-grants.js';
52
53
  import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, EDGE_SLUG_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, bouleEdgeKeyWanted, edgeKeyAppRecordKey, edgeKeyDomain, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
53
- import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PERMISSIONS_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, bouleKeyBelow, entryFilesAppliedScopeKey, entryFilesCollectionsScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, knownCollectionNames, parseIdList as parseFilesIdList, } from './entry-files-key.js';
54
+ import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PERMISSIONS_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, bouleKeyBelow, entryFilesAppliedScopeKey, entryFilesCollectionsScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, knownCollectionNames, parseIdList as parseFilesIdList, usesServerFiles, } from './entry-files-key.js';
54
55
  import { ensureJobsBouleKey, retireJobsBouleKey } from './entry-jobs-boule-key-apply.js';
55
56
  import { entryJobsAppliedScopeKey, entryJobsRefs, entryJobsUndeliveredScopeKey, entryJobsWantMarker, } from './entry-jobs.js';
56
57
  import { ensureRepoKey, removeRepoKey } from './entry-repo-key-apply.js';
57
58
  import { ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS, entryReportingAppliedScopeKey, entryReportingRetiredScopeKey, entryReportingUndeliveredScopeKey, stageReportingKeyName, } from './entry-reporting-key.js';
58
59
  import { BROKER_WORKSPACES_DATABASE_ENV, ensureWorkspacesKey, heldWorkspacesDatabase, heldWorkspacesKey, workspacesDatabaseRef, workspacesKeyHash, workspacesKeyRef, } from './entry-workspaces.js';
60
+ import { ensureFilesAppsMinter, retireFilesAppsMinter, } from './files-apps-minter.js';
59
61
  import { ensureFilesBouleKey, retireFilesBouleKey } from './files-boule-key-apply.js';
60
62
  import { applyFilesSpaceAlert, } from './files-space-alert.js';
61
63
  import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
@@ -63,6 +65,7 @@ import { GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, GITHUB_SOURCE_TIMEOUT_MS, ensureG
63
65
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
64
66
  import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
65
67
  import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, workspaceHost, } from './hostnames.js';
68
+ import { applyJobsAppsKey, jobsAppsRef } from './jobs-apps-key.js';
66
69
  import { applyJobsProjectSettings, } from './jobs-project-settings.js';
67
70
  import { KEYS_REGISTRAR_DELIVERY_ATTEMPTS, KEYS_REGISTRAR_KEY_DAYS, KEYS_REGISTRAR_KEY_ENV, KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, KEYS_REGISTRAR_URL_ENV, } from './keys-registrar.js';
68
71
  import { KOSMOS_CLIENT_ID_SCOPE_KEY, KOSMOS_DATABASE_SCOPE_KEY, KOSMOS_DEPLOYED_SCOPE_KEY, desiredKosmosEnv, kosmosEnvDrift, kosmosHostnameFor, } from './kosmos.js';
@@ -1452,6 +1455,10 @@ export class FoundryStepExecutor {
1452
1455
  return this.filesOrganisation(action, org, context);
1453
1456
  case 'files:boule-key':
1454
1457
  return this.filesBouleKey(action, org, context);
1458
+ case 'ai:apps-minter':
1459
+ return this.aiAppsMinter(action, org, context);
1460
+ case 'files:apps-minter':
1461
+ return this.filesAppsMinter(action, org, context);
1455
1462
  case 'coolify:service-alert-mail': {
1456
1463
  await applyServiceAlertMail({
1457
1464
  want: action.desired,
@@ -1523,6 +1530,29 @@ export class FoundryStepExecutor {
1523
1530
  return this.edgeSlug(action, org, context);
1524
1531
  case 'jobs:project':
1525
1532
  return this.jobsProject(action, org, context);
1533
+ case 'jobs:apps-key': {
1534
+ const held = this.ports.heldKeys;
1535
+ if (!held?.put)
1536
+ throw new Error('jobs:apps-key: held keys cannot put');
1537
+ const desired = action.desired;
1538
+ if (desired.mode === 'ensure' && org.boule?.apps !== true)
1539
+ throw new Error('jobs:apps-key: apps are off now');
1540
+ if (desired.mode === 'retire' && org.boule?.apps === true)
1541
+ throw new Error('jobs:apps-key: apps are on again');
1542
+ await applyJobsAppsKey({
1543
+ scheduler: await this.jobsPort(org),
1544
+ heldKeys: { resolve: held.resolve.bind(held), put: held.put.bind(held) },
1545
+ coolify: this.ports.coolify,
1546
+ org,
1547
+ bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
1548
+ recorded: () => context.recordedIds(),
1549
+ record: (key, value) => context.recordScopeId(key, value),
1550
+ forget: (key) => forgetScopeId(context, key),
1551
+ persist: () => context.persistScopeIds(),
1552
+ secret: (value) => void this.handledSecrets.add(value),
1553
+ }, desired);
1554
+ return {};
1555
+ }
1526
1556
  case 'jobs:boule-key':
1527
1557
  return this.jobsBouleKey(action, org, context);
1528
1558
  case 'github:deploy-key':
@@ -5533,6 +5563,9 @@ export class FoundryStepExecutor {
5533
5563
  if (!entry?.uses?.auth) {
5534
5564
  throw new Error(`coolify:broker-entry for ${org.slug}: no uses.auth entry for ${action.key}`);
5535
5565
  }
5566
+ if (entry.workerApp && entry.uses.auth.workspaces) {
5567
+ throw new Error(`coolify:broker-entry for ${org.slug}: Worker workspaces are registered through Boule, not AUTH_APPS`);
5568
+ }
5536
5569
  const wanted = wantedBrokerEntry(org, entry, recorded);
5537
5570
  if ('missing' in wanted) {
5538
5571
  throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
@@ -11377,7 +11410,12 @@ export class FoundryStepExecutor {
11377
11410
  */
11378
11411
  async aiKey(action, org, context) {
11379
11412
  const ai = await this.aiPort();
11380
- const issued = await ai.issueOrganisationKey(org.id, {
11413
+ const issue = action.desired.protectedRoot
11414
+ ? ai.issueOrganisationRootKey?.bind(ai)
11415
+ : ai.issueOrganisationKey.bind(ai);
11416
+ if (!issue)
11417
+ throw new Error(`ai:key for ${org.slug}: the released infrastructure-root adapter is required`);
11418
+ const issued = await issue(org.id, {
11381
11419
  name: action.desired.name,
11382
11420
  expiresDays: action.desired.expiresDays,
11383
11421
  permissions: action.desired.permissions ?? [],
@@ -11397,6 +11435,7 @@ export class FoundryStepExecutor {
11397
11435
  // foundry#1122: what the key holds, so a narrower one is minted again (`planAiKey`). A key it
11398
11436
  // replaced stays live until it expires (no revoke route yet, ai#157); the plan lists it.
11399
11437
  context.recordScopeId(AI_KEY_PERMISSIONS_SCOPE_KEY, JSON.stringify(action.desired.permissions ?? []));
11438
+ context.recordScopeId('ai_key_purpose', action.desired.protectedRoot ? 'company_root' : 'ordinary');
11400
11439
  const replaced = context.recordedIds()[`ai:key:${org.id}`];
11401
11440
  if (replaced && replaced !== issued.id) {
11402
11441
  const previous = parseAiIds(context.recordedIds()[AI_KEY_PREVIOUS_SCOPE_KEY]);
@@ -11565,6 +11604,32 @@ export class FoundryStepExecutor {
11565
11604
  * foundry#1122, `plan/ai-boule-key.ts`: Boule's ai key, minted below the org's own ai key under rule 1, held,
11566
11605
  * and written to Boule's `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY`. See `ai-boule-key-apply.ts`.
11567
11606
  */
11607
+ async aiAppsMinter(action, org, context) {
11608
+ const ai = await this.aiPort();
11609
+ const held = this.ports.heldKeys;
11610
+ if (!ai.revokeDescendantKey || !held?.put)
11611
+ throw new Error(`ai:apps-minter for ${org.slug}: scoped lifecycle and held-key adapters are required`);
11612
+ const step = action.desired;
11613
+ if (step.mode === 'retire' && org.boule?.apps === true)
11614
+ throw new Error(`ai:apps-minter for ${org.slug}: apps are on again; nothing was revoked`);
11615
+ await ensureAiAppsMinter({
11616
+ ai: {
11617
+ mintNestedKey: ai.mintNestedKey.bind(ai),
11618
+ revokeDescendantKey: ai.revokeDescendantKey.bind(ai),
11619
+ },
11620
+ heldKeys: { resolve: held.resolve.bind(held), put: held.put.bind(held) },
11621
+ coolify: this.ports.coolify,
11622
+ org,
11623
+ bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
11624
+ recorded: () => context.recordedIds(),
11625
+ record: (key, value) => context.recordScopeId(key, value),
11626
+ forget: (key) => forgetScopeId(context, key),
11627
+ persist: () => context.persistScopeIds(),
11628
+ secret: (value) => void this.handledSecrets.add(value),
11629
+ now: () => context.now?.() ?? Date.now(),
11630
+ }, step);
11631
+ return {};
11632
+ }
11568
11633
  async aiBouleKey(action, org, context) {
11569
11634
  const at = `ai:boule-key for ${org.slug}`;
11570
11635
  const ai = await this.aiPort();
@@ -11963,7 +12028,7 @@ export class FoundryStepExecutor {
11963
12028
  // Every entry must already hold a key below the new org key; otherwise
11964
12029
  // an app would lose its key. The next plan asks again.
11965
12030
  const ready = org.hosting.entries
11966
- .filter((e) => e.uses?.files)
12031
+ .filter(usesServerFiles)
11967
12032
  .every((e) => recorded[`files:key:${org.id}:${e.key}`] &&
11968
12033
  recorded[entryFilesMinterScopeKey(e.key)] === rootId &&
11969
12034
  !recorded[entryFilesUndeliveredScopeKey(e.key)]);
@@ -12213,7 +12278,7 @@ export class FoundryStepExecutor {
12213
12278
  now: () => context.now?.() ?? Date.now(),
12214
12279
  };
12215
12280
  if (desired.mode === 'retire') {
12216
- if (org.hosting.enabled && org.hosting.entries.some((e) => e.uses?.files)) {
12281
+ if (org.hosting.enabled && org.hosting.entries.some(usesServerFiles)) {
12217
12282
  throw new Error(`${at}: an entry declares uses.files again; nothing was revoked`);
12218
12283
  }
12219
12284
  await retireFilesBouleKey(deps);
@@ -12224,6 +12289,47 @@ export class FoundryStepExecutor {
12224
12289
  await ensureFilesBouleKey(deps, action.desired);
12225
12290
  return {};
12226
12291
  }
12292
+ /** Files for Boule's own apps (`files-apps-minter.ts`): the key Boule mints each app's files key with. */
12293
+ async filesAppsMinter(action, org, context) {
12294
+ const at = `files:apps-minter for ${org.slug}`;
12295
+ const files = await this.filesPort();
12296
+ const held = this.ports.heldKeys;
12297
+ const put = held?.put?.bind(held);
12298
+ const desired = action.desired;
12299
+ const deps = {
12300
+ files: {
12301
+ issueOrganisationKey: files.issueOrganisationKey.bind(files),
12302
+ revokeOrganisationKey: files.revokeOrganisationKey.bind(files),
12303
+ },
12304
+ heldKeys: {
12305
+ resolve: async (ref) => (held ? held.resolve(ref) : null),
12306
+ put: async (ref, value) => {
12307
+ if (!put)
12308
+ throw new Error('the held keys cannot put');
12309
+ await put(ref, value);
12310
+ },
12311
+ },
12312
+ coolify: this.ports.coolify,
12313
+ org: { id: org.id, slug: org.slug },
12314
+ bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
12315
+ recorded: () => context.recordedIds(),
12316
+ record: (key, value) => context.recordScopeId(key, value),
12317
+ forget: (key) => forgetScopeId(context, key),
12318
+ persist: () => context.persistScopeIds(),
12319
+ secret: (value) => void this.handledSecrets.add(value),
12320
+ };
12321
+ if (desired.mode === 'retire') {
12322
+ if (org.boule?.apps === true) {
12323
+ throw new Error(`${at}: apps are on again; nothing was revoked`);
12324
+ }
12325
+ await retireFilesAppsMinter(deps);
12326
+ return {};
12327
+ }
12328
+ if (!held || !put)
12329
+ throw new Error(`${at}: the held keys cannot hold the key`);
12330
+ await ensureFilesAppsMinter(deps, action.desired);
12331
+ return {};
12332
+ }
12227
12333
  /**
12228
12334
  * Revokes one entry key. When the call fails the id joins the entry's list
12229
12335
  * of keys left live, which the plan shows, and the step goes on: the app
@@ -12468,6 +12574,21 @@ export class FoundryStepExecutor {
12468
12574
  await port.deleteOrganisation(stageOrgId);
12469
12575
  return;
12470
12576
  }
12577
+ case 'jobs:apps-projects': {
12578
+ const scheduler = await this.jobsPort(org);
12579
+ if (!scheduler.deleteProject)
12580
+ throw new Error('jobs:apps-projects: project removal is not wired');
12581
+ try {
12582
+ await withTimeout(scheduler.deleteProject(target.id), 30_000);
12583
+ }
12584
+ catch (error) {
12585
+ if (!isNotFoundError(error))
12586
+ throw new Error('jobs:apps-projects: the project could not be removed');
12587
+ }
12588
+ if (this.ports.heldKeys?.put)
12589
+ await withTimeout(this.ports.heldKeys.put(jobsAppsRef(org.id), ''), 30_000);
12590
+ return;
12591
+ }
12471
12592
  case 'jobs:bg-keys':
12472
12593
  // A jobs project's keys go with the project.
12473
12594
  return;