@wtfalch/foundry 0.76.1 → 0.77.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 (125) hide show
  1. package/dist/app-canary-worker.d.ts +35 -0
  2. package/dist/app-canary-worker.js +56 -0
  3. package/dist/audit/connector.d.ts +6 -2
  4. package/dist/audit/connector.js +3 -2
  5. package/dist/audit/evidence.d.ts +4 -0
  6. package/dist/audit/evidence.js +14 -2
  7. package/dist/audit/host-store.d.ts +58 -0
  8. package/dist/audit/host-store.js +147 -0
  9. package/dist/audit/host.d.ts +53 -0
  10. package/dist/audit/host.js +294 -0
  11. package/dist/audit/index.d.ts +2 -0
  12. package/dist/audit/index.js +2 -0
  13. package/dist/audit/registry.d.ts +14 -0
  14. package/dist/audit/sealer-role.d.ts +12 -0
  15. package/dist/audit/sealer-role.js +26 -2
  16. package/dist/audit/tick.d.ts +10 -2
  17. package/dist/audit/tick.js +15 -2
  18. package/dist/catalogue/copy-en.js +5 -0
  19. package/dist/catalogue/integrations.js +5 -0
  20. package/dist/entry-rules.d.ts +35 -0
  21. package/dist/entry-rules.js +35 -0
  22. package/dist/hosted-label.js +2 -1
  23. package/dist/index.d.ts +11 -3
  24. package/dist/index.js +6 -1
  25. package/dist/migrations/0072_audit_host.sql +46 -0
  26. package/dist/migrations/0074_platform_app_canary.sql +11 -0
  27. package/dist/modules/ai.js +15 -1
  28. package/dist/modules/assistants.js +3 -0
  29. package/dist/modules/email.d.ts +6 -1
  30. package/dist/modules/email.js +88 -5
  31. package/dist/modules/files.js +79 -2
  32. package/dist/modules/index.js +7 -0
  33. package/dist/modules/jobs.d.ts +5 -1
  34. package/dist/modules/jobs.js +74 -4
  35. package/dist/modules/reporting.d.ts +9 -0
  36. package/dist/modules/reporting.js +29 -0
  37. package/dist/modules/sign-in.js +20 -2
  38. package/dist/modules/types.d.ts +2 -0
  39. package/dist/modules/workspaces.js +3 -0
  40. package/dist/org.d.ts +114 -13
  41. package/dist/org.js +233 -20
  42. package/dist/plan/ai-boule-key-apply.d.ts +65 -0
  43. package/dist/plan/ai-boule-key-apply.js +169 -0
  44. package/dist/plan/ai-boule-key.d.ts +89 -0
  45. package/dist/plan/ai-boule-key.js +195 -0
  46. package/dist/plan/analytics.d.ts +8 -1
  47. package/dist/plan/analytics.js +8 -1
  48. package/dist/plan/app-canary-apply.d.ts +49 -0
  49. package/dist/plan/app-canary-apply.js +232 -0
  50. package/dist/plan/app-canary.d.ts +44 -0
  51. package/dist/plan/app-canary.js +110 -0
  52. package/dist/plan/blue-green-apply.d.ts +6 -2
  53. package/dist/plan/blue-green-apply.js +14 -3
  54. package/dist/plan/broker-entries.js +20 -5
  55. package/dist/plan/company-backups.js +2 -0
  56. package/dist/plan/diff.d.ts +22 -0
  57. package/dist/plan/diff.js +231 -18
  58. package/dist/plan/entry-files-key.d.ts +39 -3
  59. package/dist/plan/entry-files-key.js +103 -6
  60. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  61. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  62. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  63. package/dist/plan/entry-jobs-boule-key.js +141 -0
  64. package/dist/plan/entry-jobs.d.ts +2 -0
  65. package/dist/plan/entry-reporting-key.d.ts +36 -0
  66. package/dist/plan/entry-reporting-key.js +136 -0
  67. package/dist/plan/executor.d.ts +108 -1
  68. package/dist/plan/executor.js +669 -23
  69. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  70. package/dist/plan/files-boule-key-apply.js +223 -0
  71. package/dist/plan/files-boule-key.d.ts +92 -0
  72. package/dist/plan/files-boule-key.js +175 -0
  73. package/dist/plan/hostnames.js +4 -0
  74. package/dist/plan/index.d.ts +4 -0
  75. package/dist/plan/index.js +2 -0
  76. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  77. package/dist/plan/jobs-scheduler-http.js +39 -0
  78. package/dist/plan/live.d.ts +1 -1
  79. package/dist/plan/locked-hostnames.d.ts +1 -1
  80. package/dist/plan/locked-hostnames.js +4 -1
  81. package/dist/plan/login-host.js +2 -2
  82. package/dist/plan/org-names.js +3 -0
  83. package/dist/plan/own-diff.js +3 -2
  84. package/dist/plan/own-jobs-route.d.ts +5 -2
  85. package/dist/plan/own-jobs-route.js +6 -2
  86. package/dist/plan/own-limits.d.ts +6 -0
  87. package/dist/plan/own-limits.js +1 -0
  88. package/dist/plan/plan-apply.d.ts +24 -0
  89. package/dist/plan/plan-apply.js +39 -4
  90. package/dist/plan/platform-diff.d.ts +2 -0
  91. package/dist/plan/platform-diff.js +15 -1
  92. package/dist/plan/platform-providers.d.ts +9 -0
  93. package/dist/plan/platform-providers.js +21 -0
  94. package/dist/plan/providers-readers.js +25 -1
  95. package/dist/plan/registered-app-env.d.ts +31 -0
  96. package/dist/plan/registered-app-env.js +63 -0
  97. package/dist/plan/registered-app.d.ts +121 -0
  98. package/dist/plan/registered-app.js +262 -0
  99. package/dist/plan/release-host.js +2 -1
  100. package/dist/plan/reporting-boule.d.ts +18 -0
  101. package/dist/plan/reporting-boule.js +44 -0
  102. package/dist/plan/reporting.d.ts +90 -0
  103. package/dist/plan/reporting.js +135 -0
  104. package/dist/plan/stage-env.js +1 -0
  105. package/dist/plan/stage-isolation.d.ts +3 -1
  106. package/dist/plan/stage-isolation.js +20 -2
  107. package/dist/plan/stage-services.d.ts +3 -1
  108. package/dist/plan/stage-services.js +8 -1
  109. package/dist/plan/stage-sink.d.ts +2 -2
  110. package/dist/plan/stage-sink.js +2 -2
  111. package/dist/plan/workspace-client.d.ts +1 -1
  112. package/dist/plan/workspace-client.js +2 -1
  113. package/dist/plan/workspace-sign-in.d.ts +1 -1
  114. package/dist/plan/workspace-sign-in.js +39 -7
  115. package/dist/platform.d.ts +25 -0
  116. package/dist/platform.js +20 -0
  117. package/dist/service-labels.d.ts +8 -0
  118. package/dist/service-labels.js +8 -0
  119. package/dist/slug.d.ts +1 -1
  120. package/dist/slug.js +5 -2
  121. package/dist/store/postgres.js +13 -0
  122. package/dist/store/schema.d.ts +485 -2
  123. package/dist/store/schema.js +38 -1
  124. package/dist/worker-app-config.js +2 -2
  125. package/package.json +1 -1
package/dist/plan/diff.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { VERSIONS } from '../bootstrap/versions.js';
2
2
  import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
3
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
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 { AI_OFFERS_SCOPE_KEY, aiKeyPermissions, aiOffers, planAiBouleKey } from './ai-boule-key.js';
6
7
  import { planAnalyticsBoule } from './analytics-boule.js';
7
8
  import { analyticsEnvDrift, analyticsWanted, desiredAnalyticsEnv } from './analytics.js';
8
9
  import { planAppsDbRoute } from './apps-db-route.js';
@@ -25,9 +26,12 @@ import { authEventsAppliedScopeKey, authEventsBlocked, authEventsMarker, } from
25
26
  import { CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsShapeMatches, customerGrantsToDrop, wantedCustomerGrants, } from './entry-customer-grants.js';
26
27
  import { planEdgeReservedLabels, planEdgeSlug, planEntryEdgeKeys } from './entry-edge-key.js';
27
28
  import { planFilesKeys } from './entry-files-key.js';
29
+ import { planEntryJobsBouleKeys } from './entry-jobs-boule-key.js';
28
30
  import { planEntryJobs } from './entry-jobs.js';
29
31
  import { planEntryRepoKeys } from './entry-repo-key.js';
32
+ import { planEntryReportingKeys } from './entry-reporting-key.js';
30
33
  import { BROKER_WORKSPACES_DATABASE_ENV, workspacesDatabaseRef } from './entry-workspaces.js';
34
+ import { planFilesBouleKey } from './files-boule-key.js';
31
35
  import { gateIdRecordKey, hasWildcardGate, planGates } from './gates.js';
32
36
  import { githubSourceResourceKey, planGithubSource } from './github-source.js';
33
37
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
@@ -53,7 +57,10 @@ import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
53
57
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
54
58
  import { companyAppPostLogoutUris, postLogoutUpdateOwed, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
55
59
  import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, appLabelsAppliedScopeKey, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryRoutes, entryRoutesMarker, } from './proxy-labels.js';
60
+ import { planRegisteredApps } from './registered-app.js';
56
61
  import { planRemoteDatabase } from './remote-database.js';
62
+ import { planReportingBoule } from './reporting-boule.js';
63
+ import { REPORTING_MAIL_TOKEN_ENV, desiredReportingEnv, reportingEnvDrift, reportingWanted, } from './reporting.js';
57
64
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
58
65
  import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, signInLoginNames, } from './sign-in.js';
59
66
  import { withStepSystem } from './step-system.js';
@@ -446,13 +453,14 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
446
453
  const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader, ownsServices(org) && !recordedIds[`coolify:own-files:${org.id}`]);
447
454
  if (filesOrganisation.step)
448
455
  plannedSteps.push(filesOrganisation.step);
449
- const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
456
+ const aiBouleReads = options.aiBouleReads ?? false;
457
+ const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader, recordedIds, aiBouleReads);
450
458
  if (aiOrganisation.step)
451
459
  plannedSteps.push(aiOrganisation.step);
452
460
  const aiBudget = planAiBudget(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
453
461
  if (aiBudget.step)
454
462
  plannedSteps.push(aiBudget.step);
455
- const aiKey = planAiKey(org, recordedIds, platformReadiness.aiReady);
463
+ const aiKey = planAiKey(org, recordedIds, platformReadiness.aiReady, aiBouleReads);
456
464
  if (aiKey.step)
457
465
  plannedSteps.push(aiKey.step);
458
466
  // Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
@@ -609,12 +617,14 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
609
617
  const marker = workspaceSignInMarker(declaredOrg, entry, recordedIds, authIssuer);
610
618
  if (marker)
611
619
  return marker;
612
- const customers = entry.uses?.auth?.users === 'customers';
620
+ const customers = signsInCustomers(entry.uses?.auth);
613
621
  const orgKnown = customers
614
622
  ? Boolean(recordedIds[testCustomersOrgKey(org.id)]) ||
615
623
  stepPlanned('zitadel', 'customers-test')
616
624
  : Boolean(recordedIds.zitadel_org);
617
- const clientKnown = Boolean(recordedIds[entryClientIdScopeKey(workspaceClientKey(entry.key))]) ||
625
+ // ADR 0003: the broker makes the clients of a workspace both sign in to, once the stage exists.
626
+ const clientKnown = signsInBoth(entry.uses?.auth) ||
627
+ Boolean(recordedIds[entryClientIdScopeKey(workspaceClientKey(entry.key))]) ||
618
628
  stepPlanned('zitadel', 'app', workspaceClientKey(entry.key));
619
629
  const mcpKnown = !entry.uses?.auth?.mcp ||
620
630
  Boolean(recordedIds[entryMcpProjectScopeKey(workspaceClientKey(entry.key))]) ||
@@ -737,6 +747,12 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
737
747
  }, customerGrantsShape);
738
748
  if (boule.step)
739
749
  plannedSteps.push(boule.step);
750
+ // foundry#1121: Boule's files key, after Boule and after the org's own files key it is minted below.
751
+ const filesBouleKey = planFilesBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
752
+ plannedSteps.push(...filesBouleKey.steps);
753
+ // foundry#1122: Boule's ai key, after Boule and after the org's own ai key it is minted below.
754
+ const aiBouleKey = planAiBouleKey(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)(), aiBouleReads);
755
+ plannedSteps.push(...aiBouleKey.steps);
740
756
  // foundry#949: the company's kosmos, only once it turns it on. Its fixed ZITADEL client first, the same
741
757
  // reason Boule's and Agora's do: `coolifyKosmos` reads this step's recorded client id.
742
758
  const kosmosClient = planKosmosClient(org, byKey, claimed);
@@ -754,6 +770,14 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
754
770
  plannedSteps.push(...planAnalyticsBoule(org, recordedIds, plannedSteps));
755
771
  const entryAnalyticsKeys = planEntryAnalyticsKeys(org, recordedIds, plannedSteps, options.analyticsPort ?? false);
756
772
  plannedSteps.push(...entryAnalyticsKeys.steps);
773
+ // The company's reporting backend, the same way: only while an entry's `uses.reporting` or Boule's apps
774
+ // use it, never parked once made, and before the site keys and Boule's env, which need its operator key.
775
+ const reporting = planReportingBackend(org, byKey, claimed, recordedIds, options.otherSlugs ?? [], platformReadiness);
776
+ if (reporting.step)
777
+ plannedSteps.push(reporting.step);
778
+ plannedSteps.push(...planReportingBoule(org, recordedIds, plannedSteps));
779
+ const entryReportingKeys = planEntryReportingKeys(org, recordedIds, plannedSteps, options.reportingPort ?? false);
780
+ plannedSteps.push(...entryReportingKeys.steps);
757
781
  // Boule's, Agora's and kosmos's blue/green: after their own steps, which make the app it copies.
758
782
  plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'last').map(blueGreenDraft()));
759
783
  // Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
@@ -781,6 +805,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
781
805
  // A `uses.jobs` entry registers with the scheduler after its app, whose env the step writes.
782
806
  const entryJobs = planEntryJobs(org, recordedIds, plannedSteps, platformReadiness, schedulerUsable);
783
807
  plannedSteps.push(...entryJobs.steps);
808
+ // foundry#1119: Boule's key for each entry's project, after the project and after Boule.
809
+ const entryJobsBouleKeys = planEntryJobsBouleKeys(org, recordedIds, plannedSteps, platformReadiness, options.jobsProjectKeys ?? false);
810
+ plannedSteps.push(...entryJobsBouleKeys.steps);
784
811
  // F12: a deploy key for an entry's own repo, after its app (whose env the step writes).
785
812
  const entryRepoKeys = planEntryRepoKeys(org, recordedIds, plannedSteps, platformReadiness);
786
813
  plannedSteps.push(...entryRepoKeys.steps);
@@ -792,6 +819,12 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
792
819
  // The company's registrar record in the broker, after Boule's own broker entry.
793
820
  if (registrar.step)
794
821
  plannedSteps.push(registrar.step);
822
+ // ADR 0003: after the registrar, the apps and the workspaces: the entries both sign in to are registered.
823
+ const registeredApps = planRegisteredApps(declaredOrg, recordedIds, plannedSteps, {
824
+ registrarApi: options.registrarApi ?? false,
825
+ authIssuer,
826
+ });
827
+ plannedSteps.push(...registeredApps.steps);
795
828
  // #440: Agora's desktop sign-in backend, after Agora and Boule: its readiness probe goes through Agora's proxy, and
796
829
  // Agora writes the upstream only once the service ran healthy (so a first apply needs a second to turn it on).
797
830
  const desktopAuth = planDesktopAuth(org, recordedIds, {
@@ -1039,6 +1072,12 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1039
1072
  if (analytics.blocked)
1040
1073
  unmanaged.push({ description: analytics.blocked, key: 'coolify:analytics' });
1041
1074
  unmanaged.push(...entryAnalyticsKeys.blocked);
1075
+ if (reporting.blocked)
1076
+ unmanaged.push({ description: reporting.blocked, key: 'coolify:reporting' });
1077
+ if (reporting.mailBlocked) {
1078
+ unmanaged.push({ description: reporting.mailBlocked, key: 'coolify:reporting:mail' });
1079
+ }
1080
+ unmanaged.push(...entryReportingKeys.blocked);
1042
1081
  // Same for an ai key minted but never delivered: planning a new mint would
1043
1082
  // orphan a second live key.
1044
1083
  if (aiKey.blocked)
@@ -1046,7 +1085,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1046
1085
  unmanaged.push(...blueGreen.unmanaged);
1047
1086
  unmanaged.push(...workspaces.unmanaged);
1048
1087
  unmanaged.push(...filesKeys.blocked);
1088
+ unmanaged.push(...filesBouleKey.blocked);
1049
1089
  unmanaged.push(...entryAiKeys.blocked);
1090
+ unmanaged.push(...aiBouleKey.blocked);
1050
1091
  unmanaged.push(...edgeWorkers.blocked);
1051
1092
  unmanaged.push(...workerApps.blocked);
1052
1093
  if (appsDb.blocked) {
@@ -1067,6 +1108,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1067
1108
  unmanaged.push(...appsDbRoute.blocked);
1068
1109
  unmanaged.push(...appsZones.blocked);
1069
1110
  unmanaged.push(...entryJobs.blocked);
1111
+ unmanaged.push(...entryJobsBouleKeys.blocked);
1070
1112
  unmanaged.push(...authEventsBlocked(org, recordedIds));
1071
1113
  unmanaged.push(...entryAudit.blocked);
1072
1114
  unmanaged.push(...entryRepoKeys.blocked);
@@ -1173,6 +1215,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
1173
1215
  unmanaged.push(...app.blocked);
1174
1216
  unmanaged.push(...brokerEntries.blocked);
1175
1217
  unmanaged.push(...workspaceBrokerEntries.blocked);
1218
+ unmanaged.push(...registeredApps.blocked);
1176
1219
  unmanaged.push(...bouleBroker.blocked);
1177
1220
  // F6: last, so every database step above runs before the backup of its database.
1178
1221
  const backups = planCompanyBackups(org, recordedIds, options.backupStorageUuid);
@@ -2475,7 +2518,6 @@ function planCallsChat(org, recordedIds, upstreamPlanned, upstreamBlocked) {
2475
2518
  * organisation bring its own provider credentials (BYOK) -- a separate,
2476
2519
  * explicit opt-in this default deliberately leaves out.
2477
2520
  */
2478
- const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
2479
2521
  /**
2480
2522
  * #484: the spend cap foundry applies when an org declares no AI limit
2481
2523
  * (`org.ai.limitMicros === null`) -- Archon creates every company that way,
@@ -2619,12 +2661,37 @@ ownFilesPending = false) {
2619
2661
  * #177: blocked, not planned, while `aiReady` is false -- the platform's own
2620
2662
  * AI host (#172) is off or not yet applied.
2621
2663
  */
2622
- function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
2664
+ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false, recordedIds = {}, bouleReads = false) {
2623
2665
  const key = resourceKey('ai', 'organisation', org.id);
2624
2666
  const live = byKey.get(key);
2667
+ const offers = aiOffers(bouleReads);
2625
2668
  if (live) {
2626
2669
  claimed.add(key);
2627
- return { step: null, blocked: null };
2670
+ // foundry#1122: once ai offers Boule's reads, an organisation offered fewer is offered them (both calls
2671
+ // are repeatable). Without the host's word nothing changes.
2672
+ if (!bouleReads || !aiReady || recordedIds[AI_OFFERS_SCOPE_KEY] === JSON.stringify(offers)) {
2673
+ return { step: null, blocked: null };
2674
+ }
2675
+ return {
2676
+ step: {
2677
+ action: {
2678
+ system: 'ai',
2679
+ kind: 'organisation',
2680
+ key: org.id,
2681
+ desired: {
2682
+ provision: true,
2683
+ limitMicros: effectiveAiLimitMicros(org.ai.limitMicros),
2684
+ offers,
2685
+ },
2686
+ },
2687
+ step: {
2688
+ description: `offer ${org.slug}'s AI organisation the read permissions Boule's AI page needs`,
2689
+ destroys: false,
2690
+ migrates: false,
2691
+ },
2692
+ },
2693
+ blocked: null,
2694
+ };
2628
2695
  }
2629
2696
  if (!aiReady) {
2630
2697
  return {
@@ -2641,7 +2708,7 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
2641
2708
  desired: {
2642
2709
  provision: true,
2643
2710
  limitMicros: effectiveAiLimitMicros(org.ai.limitMicros),
2644
- offers: [...AI_DEFAULT_OFFERS],
2711
+ offers,
2645
2712
  },
2646
2713
  },
2647
2714
  step: {
@@ -2725,17 +2792,24 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
2725
2792
  * organisation to mint a key inside yet, and {@link planAiOrganisation}
2726
2793
  * already reports the platform's AI host as missing once.
2727
2794
  */
2728
- function planAiKey(org, recordedIds, aiReady) {
2795
+ function planAiKey(org, recordedIds, aiReady, bouleReads = false) {
2729
2796
  const key = resourceKey('ai', 'key', org.id);
2730
- if (recordedIds[key])
2797
+ const permissions = aiOffers(bouleReads);
2798
+ // foundry#1122: a delivered key that holds fewer than the organisation now offers is minted again with them,
2799
+ // so Boule's key can be minted below it. Without the host's word a delivered key plans nothing.
2800
+ const widen = Boolean(recordedIds[key]) &&
2801
+ bouleReads &&
2802
+ aiKeyPermissions(recordedIds) !== JSON.stringify(permissions);
2803
+ if (recordedIds[key] && !widen)
2731
2804
  return { step: null, blocked: null }; // delivered -- a re-run plans nothing.
2732
2805
  if (!aiReady)
2733
2806
  return { step: null, blocked: null };
2734
2807
  // Minted but never delivered (`executor.ts`'s `aiKey`): nobody holds the
2735
2808
  // secret and ai has no per-key revoke yet, so a new mint would only add
2736
- // another orphan. Reported, not planned.
2809
+ // another orphan. Reported, not planned. (The delivered key's own id stays
2810
+ // recorded as undelivered; it blocks nothing.)
2737
2811
  const undelivered = recordedIds[AI_KEY_UNDELIVERED_SCOPE_KEY];
2738
- if (undelivered) {
2812
+ if (undelivered && undelivered !== recordedIds[key]) {
2739
2813
  return {
2740
2814
  step: null,
2741
2815
  blocked: `${org.slug}'s ai key ${undelivered} was minted but not delivered and must be revoked in ai before a new one is minted (https://github.com/wtfalch/ai/issues/138)`,
@@ -2751,11 +2825,14 @@ function planAiKey(org, recordedIds, aiReady) {
2751
2825
  desired: {
2752
2826
  name: `${org.slug}-app`,
2753
2827
  expiresDays: 365,
2754
- permissions: [...AI_DEFAULT_OFFERS],
2828
+ permissions,
2829
+ ...(widen ? { mode: 'widen' } : {}),
2755
2830
  },
2756
2831
  },
2757
2832
  step: {
2758
- description: `mint ${org.slug}'s own ai key`,
2833
+ description: widen
2834
+ ? `mint ${org.slug}'s own ai key again with the read permissions Boule's key needs; the old one stays live until it expires`
2835
+ : `mint ${org.slug}'s own ai key`,
2759
2836
  destroys: false,
2760
2837
  migrates: false,
2761
2838
  },
@@ -3332,6 +3409,120 @@ function planAnalyticsBackend(org, byKey, claimed, recordedIds, otherSlugs) {
3332
3409
  blocked: null,
3333
3410
  };
3334
3411
  }
3412
+ /**
3413
+ * The company's reporting backend, the same shape as `planAnalyticsBackend`: a per-org singleton keyed by the
3414
+ * org id under its own `reporting` kind, built from https://github.com/wtfalch/reporting's root Dockerfile
3415
+ * (`executor.ts`'s `coolifyReporting`) with a database of its own. Planned only while `reportingWanted(org)`; once live it is kept and never parked. A live
3416
+ * app is planned again when its `APP_URL` or `AUTH_ORGANIZATION_ID` is missing or different. It needs no
3417
+ * sign-in client and no issuer; only the company's sign-in organisation id (recorded by an earlier step).
3418
+ *
3419
+ * Mail (reporting#103, foundry#1128), on the same terms as Boule's: a hosted-mail company whose domain is
3420
+ * recorded gives the backend `MAIL_FROM` (`reportingMailFrom`) and its `<slug>-app` Lettermint token, as a
3421
+ * consumer of the shared mint rule. Blocked, not failing, while the platform cannot supply the token: the
3422
+ * backend is planned without mail and the reason is its own line. The token is compared by presence
3423
+ * (`config.mailTokenHeld`), never by value, and a reader that did not say reads as holding it, so a backend
3424
+ * that lacks either is planned once and converges.
3425
+ */
3426
+ function planReportingBackend(org, byKey, claimed, recordedIds, otherSlugs, readiness = DEFAULT_ORG_PLATFORM_READINESS) {
3427
+ const key = resourceKey('coolify', 'reporting', org.id);
3428
+ const live = byKey.get(key);
3429
+ if (live)
3430
+ claimed.add(key);
3431
+ if (!reportingWanted(org))
3432
+ return { step: null, blocked: null, mailBlocked: null };
3433
+ const tooLong = [
3434
+ [`${org.slug}-reporting`, 'the reporting host label and app name'],
3435
+ [`${org.slug}_reporting_db_rt`, 'the reporting database role'],
3436
+ ].filter(([name]) => name.length > 63);
3437
+ if (tooLong.length > 0) {
3438
+ return {
3439
+ step: null,
3440
+ mailBlocked: null,
3441
+ blocked: `${org.slug}'s reporting backend is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
3442
+ };
3443
+ }
3444
+ if (otherSlugs.includes(`${org.slug}-reporting`)) {
3445
+ return {
3446
+ step: null,
3447
+ mailBlocked: null,
3448
+ blocked: `${org.slug}'s reporting backend is blocked, not planned: another declared org has the slug '${org.slug}-reporting', which is this backend's hostname`,
3449
+ };
3450
+ }
3451
+ const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY]?.toLowerCase();
3452
+ const mailWanted = bouleMailWanted(org, recordedIds) && Boolean(mailDomain);
3453
+ const isApexOrg = Boolean(mailDomain && readiness.platformApex && mailDomain === readiness.platformApex.toLowerCase());
3454
+ let mailBlockedReason = null;
3455
+ if (mailWanted && isApexOrg) {
3456
+ if (!readiness.platformLettermintProjectId) {
3457
+ mailBlockedReason = 'platform.mail.lettermintProjectId is not declared yet';
3458
+ }
3459
+ else if (readiness.mailKeyHeld !== true) {
3460
+ mailBlockedReason = `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`;
3461
+ }
3462
+ }
3463
+ else if (mailWanted) {
3464
+ mailBlockedReason = edgeNeeded(readiness, ['zones', 'dns']);
3465
+ }
3466
+ const mailBlocked = mailBlockedReason
3467
+ ? `${org.slug}'s reporting backend mail is blocked, not planned: ${mailBlockedReason}`
3468
+ : null;
3469
+ const mailOn = mailWanted && !mailBlocked;
3470
+ // `coolifyReporting` writes nothing without the sign-in organisation, so the step waits for it here.
3471
+ const { env, missing } = desiredReportingEnv(org, recordedIds, mailOn ? (recordedIds[MAIL_DOMAIN_SCOPE_KEY] ?? null) : null);
3472
+ if (missing.length > 0) {
3473
+ return {
3474
+ step: null,
3475
+ mailBlocked: null,
3476
+ blocked: `${org.slug}'s reporting backend is blocked, not planned: the company's sign-in organisation is not recorded yet`,
3477
+ };
3478
+ }
3479
+ const action = {
3480
+ system: 'coolify',
3481
+ kind: 'reporting',
3482
+ key: org.id,
3483
+ // Only a backend that gets mail carries the mail fields, so every other plan is unchanged. The platform
3484
+ // values go in like Boule's: the executor writes what the plan showed.
3485
+ desired: mailOn
3486
+ ? {
3487
+ mail: true,
3488
+ platformApex: readiness.platformApex ?? null,
3489
+ platformLettermintProjectId: readiness.platformLettermintProjectId ?? null,
3490
+ }
3491
+ : {},
3492
+ };
3493
+ if (!live) {
3494
+ return {
3495
+ step: {
3496
+ action,
3497
+ step: {
3498
+ description: `provision ${org.slug}'s reporting backend`,
3499
+ destroys: false,
3500
+ migrates: false,
3501
+ },
3502
+ },
3503
+ blocked: null,
3504
+ mailBlocked,
3505
+ };
3506
+ }
3507
+ const drift = reportingEnvDrift(env, (live.config.env ?? {}));
3508
+ // The token by presence only; a reader that did not say reads as holding it.
3509
+ if (mailOn && live.config.mailTokenHeld === false)
3510
+ drift.push(REPORTING_MAIL_TOKEN_ENV);
3511
+ if (drift.length === 0)
3512
+ return { step: null, blocked: null, mailBlocked };
3513
+ return {
3514
+ step: {
3515
+ action,
3516
+ step: {
3517
+ description: `update ${org.slug}'s reporting backend config (${drift.join(', ')} missing or different)`,
3518
+ destroys: false,
3519
+ migrates: false,
3520
+ },
3521
+ },
3522
+ blocked: null,
3523
+ mailBlocked,
3524
+ };
3525
+ }
3335
3526
  /**
3336
3527
  * C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
3337
3528
  * Boule the host can mint it for; the customers directory credential only while `customers.enabled`. Each
@@ -3883,6 +4074,26 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
3883
4074
  blocked,
3884
4075
  };
3885
4076
  }
4077
+ // Rule 4, from live state: an entry that sends mail and whose app has no
4078
+ // `MAIL_APP_KEY` yet gets it pushed once (`pushAppEnv` writes it on every
4079
+ // run). Its value is the entry key, so the name alone says it is there. A
4080
+ // reader that did not list env names reads as compliant, as above.
4081
+ if (entry &&
4082
+ sendsMail(entry) &&
4083
+ Array.isArray(envNames) &&
4084
+ !envNames.includes(MAIL_APP_KEY_ENV)) {
4085
+ return {
4086
+ step: {
4087
+ action,
4088
+ step: {
4089
+ description: `give ${org.slug}'s ${label} its ${MAIL_APP_KEY_ENV} (the key its mail is tagged with) and restart it`,
4090
+ destroys: false,
4091
+ migrates: false,
4092
+ },
4093
+ },
4094
+ blocked,
4095
+ };
4096
+ }
3886
4097
  // #243 (rule 3): a changed entry `env` reaches an app that is already
3887
4098
  // live. `pushAppEnv` records what it pushed, so this plans the same step
3888
4099
  // again, with a redeploy, only once the resolved map differs from that.
@@ -4551,7 +4762,8 @@ function planIssuerApps(org, byKey, claimed) {
4551
4762
  function planEntryClients(org, byKey, claimed) {
4552
4763
  const steps = [];
4553
4764
  for (const entry of org.hosting.entries) {
4554
- if (!entry.uses?.auth)
4765
+ // ADR 0003: the broker makes the clients of an entry both sign in to when it is registered.
4766
+ if (!entry.uses?.auth || signsInBoth(entry.uses.auth))
4555
4767
  continue;
4556
4768
  const key = resourceKey('zitadel', 'app', entry.key);
4557
4769
  if (byKey.has(key)) {
@@ -4582,7 +4794,8 @@ export function planBrokerEntries(org, recordedIds, plannedSteps, brokerWired, b
4582
4794
  const blocked = [];
4583
4795
  for (const entry of org.hosting.entries) {
4584
4796
  const auth = entry.uses?.auth;
4585
- if (!auth)
4797
+ // An entry both sign in to is registered (`registered-app.ts`), not an `AUTH_APPS` entry.
4798
+ if (!auth || signsInBoth(auth))
4586
4799
  continue;
4587
4800
  const at = `${org.slug}'s ${entry.key} broker entry`;
4588
4801
  const key = `coolify:broker-entry:${entry.key}`;
@@ -4739,7 +4952,7 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
4739
4952
  function planCustomers(org, live, claimed, recordedIds, metadataWired, policyAvailable = true) {
4740
4953
  const signUp = org.customers?.signUp === true;
4741
4954
  const wanted = org.customers?.enabled === true ||
4742
- org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers');
4955
+ org.hosting.entries.some((entry) => signsInCustomers(entry.uses?.auth));
4743
4956
  // The sign-in page reads this from the customers organisation: any entry with an emailed code turns it on.
4744
4957
  const wantedSignIn = wanted && org.hosting.entries.some((entry) => entry.uses?.auth?.signIn === 'email-code')
4745
4958
  ? 'email-code'
@@ -11,8 +11,11 @@ import type { OrgPlatformReadiness } from './platform-org.js';
11
11
  export declare const FILES_KEY_DAYS = 365;
12
12
  /** A key with fewer days left than this is renewed. */
13
13
  export declare const FILES_KEY_RENEW_WINDOW_DAYS = 30;
14
- /** What the org's own key holds: it mints and revokes, and holds what its entries may hold. */
15
- export declare const FILES_ROOT_PERMISSIONS: readonly ["credentials:create", "credentials:revoke", "files.collections:read", "files.files:read", "files.files:download", "files.files:upload", "files.files:replace", "files.files:delete"];
14
+ /**
15
+ * What the org's own key holds: it mints and revokes, and holds what its entries and Boule's key may hold
16
+ * (foundry#1121: `files.usage:read`, `files.shares:read` and `files.files:share` for Boule's Files page).
17
+ */
18
+ export declare const FILES_ROOT_PERMISSIONS: readonly ["credentials:create", "credentials:revoke", "files.collections:read", "files.files:read", "files.files:download", "files.files:upload", "files.files:replace", "files.files:delete", "files.usage:read", "files.shares:read", "files.files:share"];
16
19
  /**
17
20
  * Flat scope ids (no `:`). The org's own key: the id minted last (it blocks
18
21
  * only while it differs from the recorded key, which means it was never
@@ -20,6 +23,12 @@ export declare const FILES_ROOT_PERMISSIONS: readonly ["credentials:create", "cr
20
23
  */
21
24
  export declare const FILES_ROOT_UNDELIVERED_SCOPE_KEY = "files_key_undelivered";
22
25
  export declare const FILES_ROOT_EXPIRES_SCOPE_KEY = "files_key_expires";
26
+ /**
27
+ * foundry#1121: the permissions the recorded org key was minted with, as JSON. A key minted before this was
28
+ * recorded, or with fewer permissions than {@link FILES_ROOT_PERMISSIONS}, is renewed: a key below it can
29
+ * hold only what it holds.
30
+ */
31
+ export declare const FILES_ROOT_PERMISSIONS_SCOPE_KEY = "files_key_permissions";
23
32
  /** Ids of org keys replaced by a renewal, as a JSON array, revoked once every entry holds a key below the new one. */
24
33
  export declare const FILES_ROOT_PREVIOUS_SCOPE_KEY = "files_key_previous";
25
34
  /** Ids of replaced org keys whose revoke failed, as a JSON array. They expire on their own; the plan lists each. */
@@ -47,6 +56,26 @@ export declare const entryFilesRetiredScopeKey: (entryKey: string) => string;
47
56
  export declare const entryFilesUnrevokedScopeKey: (entryKey: string) => string;
48
57
  export declare const ENTRY_FILES_UNREVOKED_PREFIX = "files_entry_unrevoked_";
49
58
  export declare const ENTRY_FILES_UNDELIVERED_PREFIX = "files_entry_undelivered_";
59
+ /** foundry#1121 (`files-boule-key.ts`). Boule's key is minted, not held yet: its id, never the key. */
60
+ export declare const FILES_BOULE_UNDELIVERED_SCOPE_KEY = "files_boule_key_undelivered";
61
+ /** The id of Boule's key once it is held. */
62
+ export declare const FILES_BOULE_KEY_SCOPE_KEY = "files_boule_key";
63
+ /** The id of the org's own files key Boule's key was minted below. */
64
+ export declare const FILES_BOULE_MINTER_SCOPE_KEY = "files_boule_key_minter";
65
+ /** Ids of Boule keys a renewal replaced, as a JSON array, revoked once the new one is in Boule's env. */
66
+ export declare const FILES_BOULE_PREVIOUS_SCOPE_KEY = "files_boule_key_previous";
67
+ /** Per entry: label to collection name in files, every name the entry ever had. */
68
+ export declare const entryFilesCollectionsScopeKey: (entryKey: string) => string;
69
+ /** The name a new collection of an entry gets in files. */
70
+ export declare const entryCollectionName: (entryKey: string, label: string) => string;
71
+ /**
72
+ * Every collection name the entry has had, label to name: the recorded map, or, before one is recorded, each
73
+ * label of the entry's last applied files marker under its own name (the name files was given before
74
+ * collections were named per app).
75
+ */
76
+ export declare function knownCollectionNames(entryKey: string, recordedIds: Readonly<Record<string, string>>): Record<string, string>;
77
+ /** The files name of each collection the entry declares now, label to name, in the declared order. */
78
+ export declare function entryCollectionNames(entry: Pick<HostingEntry, 'key' | 'uses'>, recordedIds: Readonly<Record<string, string>>): Record<string, string>;
50
79
  export declare function parseIdList(raw: string | undefined): string[];
51
80
  export declare const filesRootKeyName: (org: DeclaredOrg) => string;
52
81
  export declare const entryFilesKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
@@ -58,8 +87,15 @@ export interface EntryFilesWant {
58
87
  filesUrl: string;
59
88
  /** `https://share.<domain>`, set only when the entry declares `shareHost`. */
60
89
  shareOrigin?: string;
90
+ /** foundry#1121: each collection's name in files, label to name (`files-boule-key.ts`). */
91
+ collectionNames: Record<string, string>;
61
92
  }
62
- export declare function wantedEntryFiles(entry: HostingEntry, filesUrl: string, shareHostname?: string | null): EntryFilesWant;
93
+ export declare function wantedEntryFiles(entry: HostingEntry, filesUrl: string, shareHostname?: string | null, recordedIds?: Readonly<Record<string, string>>): EntryFilesWant;
94
+ /**
95
+ * foundry#1121: Boule's files key, when it has one, is below `rootKeyId` and delivered, so revoking the org
96
+ * key it replaced revokes nothing Boule still uses.
97
+ */
98
+ export declare function bouleKeyBelow(recordedIds: Readonly<Record<string, string>>, rootKeyId: string | undefined): boolean;
63
99
  export type EntryFilesMode = 'mint' | 'renew' | 'settings' | 'finish' | 'retire';
64
100
  type Blocked = Array<{
65
101
  description: string;