@wtfalch/foundry 0.76.0 → 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 (127) 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/desktop-auth.d.ts +17 -4
  57. package/dist/plan/desktop-auth.js +22 -5
  58. package/dist/plan/diff.d.ts +22 -0
  59. package/dist/plan/diff.js +231 -18
  60. package/dist/plan/entry-files-key.d.ts +39 -3
  61. package/dist/plan/entry-files-key.js +103 -6
  62. package/dist/plan/entry-jobs-boule-key-apply.d.ts +65 -0
  63. package/dist/plan/entry-jobs-boule-key-apply.js +192 -0
  64. package/dist/plan/entry-jobs-boule-key.d.ts +59 -0
  65. package/dist/plan/entry-jobs-boule-key.js +141 -0
  66. package/dist/plan/entry-jobs.d.ts +2 -0
  67. package/dist/plan/entry-reporting-key.d.ts +36 -0
  68. package/dist/plan/entry-reporting-key.js +136 -0
  69. package/dist/plan/executor.d.ts +108 -1
  70. package/dist/plan/executor.js +676 -24
  71. package/dist/plan/files-boule-key-apply.d.ts +73 -0
  72. package/dist/plan/files-boule-key-apply.js +223 -0
  73. package/dist/plan/files-boule-key.d.ts +92 -0
  74. package/dist/plan/files-boule-key.js +175 -0
  75. package/dist/plan/hostnames.js +4 -0
  76. package/dist/plan/index.d.ts +4 -0
  77. package/dist/plan/index.js +2 -0
  78. package/dist/plan/jobs-scheduler-http.d.ts +13 -0
  79. package/dist/plan/jobs-scheduler-http.js +39 -0
  80. package/dist/plan/live.d.ts +1 -1
  81. package/dist/plan/locked-hostnames.d.ts +1 -1
  82. package/dist/plan/locked-hostnames.js +4 -1
  83. package/dist/plan/login-host.js +2 -2
  84. package/dist/plan/org-names.js +3 -0
  85. package/dist/plan/own-diff.js +3 -2
  86. package/dist/plan/own-jobs-route.d.ts +5 -2
  87. package/dist/plan/own-jobs-route.js +6 -2
  88. package/dist/plan/own-limits.d.ts +6 -0
  89. package/dist/plan/own-limits.js +1 -0
  90. package/dist/plan/plan-apply.d.ts +24 -0
  91. package/dist/plan/plan-apply.js +39 -4
  92. package/dist/plan/platform-diff.d.ts +2 -0
  93. package/dist/plan/platform-diff.js +15 -1
  94. package/dist/plan/platform-providers.d.ts +9 -0
  95. package/dist/plan/platform-providers.js +21 -0
  96. package/dist/plan/providers-readers.js +25 -1
  97. package/dist/plan/registered-app-env.d.ts +31 -0
  98. package/dist/plan/registered-app-env.js +63 -0
  99. package/dist/plan/registered-app.d.ts +121 -0
  100. package/dist/plan/registered-app.js +262 -0
  101. package/dist/plan/release-host.js +2 -1
  102. package/dist/plan/reporting-boule.d.ts +18 -0
  103. package/dist/plan/reporting-boule.js +44 -0
  104. package/dist/plan/reporting.d.ts +90 -0
  105. package/dist/plan/reporting.js +135 -0
  106. package/dist/plan/stage-env.js +1 -0
  107. package/dist/plan/stage-isolation.d.ts +3 -1
  108. package/dist/plan/stage-isolation.js +20 -2
  109. package/dist/plan/stage-services.d.ts +3 -1
  110. package/dist/plan/stage-services.js +8 -1
  111. package/dist/plan/stage-sink.d.ts +2 -2
  112. package/dist/plan/stage-sink.js +2 -2
  113. package/dist/plan/workspace-client.d.ts +1 -1
  114. package/dist/plan/workspace-client.js +2 -1
  115. package/dist/plan/workspace-sign-in.d.ts +1 -1
  116. package/dist/plan/workspace-sign-in.js +39 -7
  117. package/dist/platform.d.ts +25 -0
  118. package/dist/platform.js +20 -0
  119. package/dist/service-labels.d.ts +8 -0
  120. package/dist/service-labels.js +8 -0
  121. package/dist/slug.d.ts +1 -1
  122. package/dist/slug.js +5 -2
  123. package/dist/store/postgres.js +13 -0
  124. package/dist/store/schema.d.ts +485 -2
  125. package/dist/store/schema.js +38 -1
  126. package/dist/worker-app-config.js +2 -2
  127. package/package.json +1 -1
@@ -6,17 +6,20 @@ import { coolifyImageFor } from '../bootstrap/versions.js';
6
6
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
7
7
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
8
8
  import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
9
- import { AI_ENTRY_PERMISSIONS, APPS_DOMAIN, APP_ENTRY_KEY, FILES_ENTRY_PERMISSIONS, FULL_COMMIT_SHA, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, filesCollectionEnvName, heldEnvRef, isLiveEntry, isMainEntry, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
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 { ensureAiBouleKey } from './ai-boule-key-apply.js';
13
+ import { AI_KEY_PERMISSIONS_SCOPE_KEY, AI_KEY_PREVIOUS_SCOPE_KEY, AI_OFFERS_SCOPE_KEY, parseIds as parseAiIds, } from './ai-boule-key.js';
12
14
  import { ANALYTICS_BOULE_APPLIED_SCOPE_KEY, ANALYTICS_BOULE_ENV_NAMES, analyticsBouleMarker, } from './analytics-boule.js';
13
- import { ANALYTICS_DATABASE_SCOPE_KEY, ANALYTICS_DEPLOYED_SCOPE_KEY, ANALYTICS_OPERATOR_KEY_ENV, analyticsEnvDrift, analyticsHostnameFor, analyticsUrlFor, desiredAnalyticsEnv, } from './analytics.js';
15
+ import { ANALYTICS_DATABASE_SCOPE_KEY, ANALYTICS_DEPLOYED_SCOPE_KEY, ANALYTICS_OPERATOR_KEY_ENV, ANALYTICS_PERSON_KEY_BYTES, ANALYTICS_PERSON_KEY_ENV, analyticsEnvDrift, analyticsHostnameFor, analyticsUrlFor, desiredAnalyticsEnv, } from './analytics.js';
14
16
  import { APPS_DATABASE_ADMIN_URL_ENV, applyAppsDbRoute, retireAppsDbRoute, } from './apps-db-route.js';
15
17
  import { appsDbAdminUrl, provisionAppsDb } from './apps-db.js';
16
18
  import { APPS_ZONES_APPLIED_SCOPE_KEY, applyAppsZones, retireAppsZones } from './apps-zones.js';
17
19
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, applyWorkspaceServer, } from './blue-green-apply.js';
18
20
  import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, mintPinKey, parseGuards, pinKeyName, splitServers, } from './blue-green.js';
19
21
  import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
22
+ import { registrarUrl } from './boule.js';
20
23
  import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, BOULE_TEST_CUSTOMERS_ORG_ENV, CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_VERSION, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_POLICY_SCOPE_KEY, CUSTOMERS_POLICY_VERSION, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
21
24
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcp, entryMcpProjectScopeKey, mcpCallbackUri, removeBrokerEntry, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
22
25
  import { REGISTRAR_TOKEN_ORGS_SCOPE_KEY, REGISTRAR_TOKEN_USER_NAME, registrarOrgIdsOf, registrarOrgsMarker, } from './broker-registrar-token.js';
@@ -32,7 +35,7 @@ import { LOGIN_COOKIE_DOMAIN_SCOPE_KEY } from './cookie-domain.js';
32
35
  import { previewsOn } from './coolify-previews.js';
33
36
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
34
37
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
35
- import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
38
+ import { DESKTOP_AUTH_APP_SCOPE_KEY, DESKTOP_AUTH_DB_SERVER_SCOPE_KEY, DESKTOP_AUTH_DEPLOYED_SCOPE_KEY, DESKTOP_AUTH_DOCKERFILE, DESKTOP_AUTH_HOME, DESKTOP_AUTH_ORIGIN_SCOPE_KEY, DESKTOP_AUTH_READY_PATH, DESKTOP_AUTH_READY_SCOPE_KEY, DESKTOP_AUTH_REDIRECT_SCOPE_KEY, DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthCallbackUri, desktopAuthEnv, desktopAuthInputs, desktopAuthRedirectUnsupported, desktopAuthRevision, } from './desktop-auth.js';
36
39
  import { desktopCallbackUri, signInCallbackAppliedValue, signInRedirectUris, unionRedirectUris, zitadelAppDesktopCallbackAppliedScopeKey, } from './desktop-callback.js';
37
40
  import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, placedOnPlatform, } from './destinations.js';
38
41
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
@@ -47,10 +50,13 @@ import { applyEntryAudit } from './entry-audit.js';
47
50
  import { AUTH_EVENTS_ENV, authEventsAppliedScopeKey, authEventsMarker, authEventsWanted, ensureAuthEventsSecret, heldAuthEventsSecret, } from './entry-auth-events.js';
48
51
  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';
49
52
  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';
50
- 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_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-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 { ensureJobsBouleKey, retireJobsBouleKey } from './entry-jobs-boule-key-apply.js';
51
55
  import { entryJobsAppliedScopeKey, entryJobsRefs, entryJobsUndeliveredScopeKey, entryJobsWantMarker, } from './entry-jobs.js';
52
56
  import { ensureRepoKey, removeRepoKey } from './entry-repo-key-apply.js';
57
+ import { ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS, entryReportingAppliedScopeKey, entryReportingRetiredScopeKey, entryReportingUndeliveredScopeKey, stageReportingKeyName, } from './entry-reporting-key.js';
53
58
  import { BROKER_WORKSPACES_DATABASE_ENV, ensureWorkspacesKey, heldWorkspacesDatabase, heldWorkspacesKey, workspacesDatabaseRef, workspacesKeyHash, workspacesKeyRef, } from './entry-workspaces.js';
59
+ import { ensureFilesBouleKey, retireFilesBouleKey } from './files-boule-key-apply.js';
54
60
  import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
55
61
  import { GITHUB_SOURCE_INSTALLATION_SCOPE_KEY, GITHUB_SOURCE_TIMEOUT_MS, ensureGithubSource, githubSourceResourceKey, } from './github-source.js';
56
62
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
@@ -78,8 +84,12 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
78
84
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_ORG_NAME, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
79
85
  import { companyAppPostLogoutUris, postLogoutAppliedValue, postLogoutUrisFor, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
80
86
  import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
87
+ import { liveRegisteredClientIds, registeredAppAppliedScopeKey, registeredAppEnvScopeKey, } from './registered-app-env.js';
88
+ import { REGISTERED_APP_KIND, copyClientKey, registeredCopies, sendRegistration, wantedRegisteredApp, } from './registered-app.js';
81
89
  import { STAGE_DEPLOY_ATTEMPTS, STAGE_DEPLOY_INTERVAL_MS, createReleaseHost, } from './release-host.js';
82
90
  import { TUNNEL_HTTP_INGRESS_SCOPE_KEY, applyRemoteDatabase, mergeTcpIngress, recordedTcpRoutes, } from './remote-database.js';
91
+ import { REPORTING_BOULE_APPLIED_SCOPE_KEY, REPORTING_BOULE_ENV_NAMES, reportingBouleMarker, } from './reporting-boule.js';
92
+ import { REPORTING_DATABASE_SCOPE_KEY, REPORTING_DEPLOYED_SCOPE_KEY, REPORTING_OPERATOR_KEY_ENV, desiredReportingEnv, reportingEnvDrift, reportingHostnameFor, reportingUrlFor, } from './reporting.js';
83
93
  import { editSignInBindings } from './sign-in-bindings.js';
84
94
  import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
85
95
  import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
@@ -416,6 +426,13 @@ export const KOSMOS_PORT = 3000;
416
426
  export const ANALYTICS_REPO = 'wtfalch/analytics';
417
427
  export const ANALYTICS_APP_DOCKERFILE = '/Dockerfile';
418
428
  export const ANALYTICS_PORT = 3000;
429
+ /**
430
+ * The company's reporting backend (https://github.com/wtfalch/reporting, `apps/host`): root Dockerfile, serves
431
+ * on 3000 and answers `/api/health`, migrates in its own start script, as the analytics host does.
432
+ */
433
+ export const REPORTING_REPO = 'wtfalch/reporting';
434
+ export const REPORTING_APP_DOCKERFILE = '/Dockerfile';
435
+ export const REPORTING_PORT = 3000;
419
436
  /**
420
437
  * Every app foundry deploys keeps its own logs across a redeploy, so a
421
438
  * database outage does not also erase the only other copy of the evidence
@@ -965,6 +982,7 @@ function limitProfileOf(action) {
965
982
  return undefined;
966
983
  case 'coolify:broker-entry':
967
984
  case 'coolify:workspace-broker-entry':
985
+ case `coolify:${REGISTERED_APP_KIND}`:
968
986
  case 'coolify:workspaces-database':
969
987
  return LIMITS.brokerEntry;
970
988
  case 'cms:site':
@@ -985,6 +1003,8 @@ function limitProfileOf(action) {
985
1003
  return LIMITS.kosmos;
986
1004
  case 'coolify:analytics':
987
1005
  return LIMITS.analytics;
1006
+ case 'coolify:reporting':
1007
+ return LIMITS.reporting;
988
1008
  case 'coolify:broker-registrar':
989
1009
  return LIMITS.brokerEntry;
990
1010
  case 'coolify:keys-registrar':
@@ -1372,6 +1392,8 @@ export class FoundryStepExecutor {
1372
1392
  return this.coolifyBrokerEntry(action, org, context);
1373
1393
  case 'coolify:workspace-broker-entry':
1374
1394
  return this.coolifyWorkspaceBrokerEntry(action, org, context);
1395
+ case `coolify:${REGISTERED_APP_KIND}`:
1396
+ return this.coolifyRegisteredApp(action, org, context);
1375
1397
  case 'coolify:workspaces-database':
1376
1398
  return this.coolifyWorkspacesDatabase(org, context);
1377
1399
  case 'coolify:database':
@@ -1417,13 +1439,17 @@ export class FoundryStepExecutor {
1417
1439
  }
1418
1440
  case 'files:organisation':
1419
1441
  return this.filesOrganisation(action, org, context);
1442
+ case 'files:boule-key':
1443
+ return this.filesBouleKey(action, org, context);
1420
1444
  case 'files:key':
1421
1445
  // The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6b).
1422
1446
  return action.key.includes(':')
1423
1447
  ? this.filesEntryKey(action, org, context)
1424
1448
  : this.filesRootKey(action, org, context);
1425
1449
  case 'ai:organisation':
1426
- return this.aiOrganisation(action, org);
1450
+ return this.aiOrganisation(action, org, context);
1451
+ case 'ai:boule-key':
1452
+ return this.aiBouleKey(action, org, context);
1427
1453
  case 'ai:budget':
1428
1454
  return this.aiBudget(action, org, context);
1429
1455
  case 'ai:key':
@@ -1439,6 +1465,8 @@ export class FoundryStepExecutor {
1439
1465
  return this.edgeSlug(action, org, context);
1440
1466
  case 'jobs:project':
1441
1467
  return this.jobsProject(action, org, context);
1468
+ case 'jobs:boule-key':
1469
+ return this.jobsBouleKey(action, org, context);
1442
1470
  case 'github:deploy-key':
1443
1471
  return this.githubDeployKey(action, org, context);
1444
1472
  case 'audit:ledger':
@@ -1469,6 +1497,12 @@ export class FoundryStepExecutor {
1469
1497
  return this.analyticsBoule(action, org, context);
1470
1498
  case 'analytics:key':
1471
1499
  return this.analyticsEntryKey(action, org, context);
1500
+ case 'coolify:reporting':
1501
+ return this.coolifyReporting(action, org, context);
1502
+ case 'coolify:reporting-boule':
1503
+ return this.reportingBoule(action, org, context);
1504
+ case 'reporting:key':
1505
+ return this.reportingEntryKey(action, org, context);
1472
1506
  case 'coolify:broker-registrar':
1473
1507
  return this.brokerRegistrar(org, context);
1474
1508
  case 'coolify:keys-registrar':
@@ -3460,7 +3494,7 @@ export class FoundryStepExecutor {
3460
3494
  isRunning: isRunningOrStarting,
3461
3495
  stageGuards: this.stageGuards(org, subject, recorded(), action.desired.accessProviderId, context),
3462
3496
  guardInputs: parseGuards(action.desired.guards),
3463
- stageSink: { slug: org.slug },
3497
+ stageSink: stageSinkFor(org, subject.builtin ? undefined : subject.id),
3464
3498
  };
3465
3499
  if (action.kind === 'bluegreen-lb') {
3466
3500
  await applyBlueGreenWeights(deps, subject, { uuid: serverUuid, primaryUuid });
@@ -3520,7 +3554,7 @@ export class FoundryStepExecutor {
3520
3554
  logDir: LOG_DIR,
3521
3555
  isRunning: isRunningOrStarting,
3522
3556
  stageGuards: guards,
3523
- stageSink: { slug: org.slug },
3557
+ stageSink: stageSinkFor(org, entry.key),
3524
3558
  stageAppUrl: `https://${host}`,
3525
3559
  ...(signIn ? { signIn } : {}),
3526
3560
  }, subject, serverUuid);
@@ -3599,13 +3633,14 @@ export class FoundryStepExecutor {
3599
3633
  const liveMcp = org.hosting.entries.filter((live) => live.uses?.auth?.mcp);
3600
3634
  return {
3601
3635
  env,
3602
- customers: auth.users === 'customers',
3636
+ customers: signsInCustomers(auth),
3603
3637
  redirectUri: `https://${workspaceClientHosts(org, entry)[0]}/auth/callback`,
3604
3638
  live: {
3605
3639
  ...(recorded[`zitadel:customers:${org.id}`]
3606
3640
  ? { customersOrgId: recorded[`zitadel:customers:${org.id}`] }
3607
3641
  : {}),
3608
3642
  clientIds: liveClientIds(org, recorded),
3643
+ teamClientIds: liveRegisteredClientIds(org, recorded).teamClientIds,
3609
3644
  workspacesKeys,
3610
3645
  mcpAudiences: liveMcp.flatMap((m) => recorded[entryMcpProjectScopeKey(m.key)] ?? []),
3611
3646
  mcpResources: liveMcp.flatMap((m) => entryMcp(org, m, recorded)?.resource ?? []),
@@ -3646,6 +3681,101 @@ export class FoundryStepExecutor {
3646
3681
  });
3647
3682
  return {};
3648
3683
  }
3684
+ /**
3685
+ * `coolify:registered-app` (`plan/registered-app.ts`, ADR 0003): one copy of an entry whose customers and team
3686
+ * both sign in, registered at the sign-in broker through the company's registrar with
3687
+ * `users: ['customers', 'staff']`. The answer (the customers' and the team's clients, both organisations and a
3688
+ * new `AUTH_APP_KEY`) goes to the copy's app (the entry's app, or the workspace's stage), which restarts. The
3689
+ * ids are recorded after the env is written, the want last, so a failure anywhere registers again on the next
3690
+ * apply (the broker keeps the key it replaced valid for a window). The registrar key and the app key are
3691
+ * registered for redaction and never recorded; errors name the copy and a status at most.
3692
+ */
3693
+ async coolifyRegisteredApp(action, org, context) {
3694
+ const at = `coolify:${REGISTERED_APP_KIND} for ${org.slug}`;
3695
+ const api = this.ports.registrarApi;
3696
+ if (!api)
3697
+ throw new Error(`${at}: the registrar API port is not wired`);
3698
+ const copy = action.desired.copy;
3699
+ const entry = org.hosting.entries.find((e) => e.key === String(action.desired.entry ?? '') && signsInBoth(e.uses?.auth));
3700
+ if (!entry || !registeredCopies(entry).includes(copy)) {
3701
+ throw new Error(`${at}: no entry both sign in to for ${action.key}`);
3702
+ }
3703
+ const authIssuer = action.desired.authIssuer ?? null;
3704
+ if (!authIssuer)
3705
+ throw new Error(`${at}: the sign-in issuer is not known yet`);
3706
+ const recorded = context.recordedIds();
3707
+ const wanted = wantedRegisteredApp(org, entry, copy, recorded);
3708
+ if ('missing' in wanted) {
3709
+ throw new Error(`${at}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
3710
+ }
3711
+ const clientKey = copyClientKey(entry, copy);
3712
+ let appId;
3713
+ if (copy === 'live') {
3714
+ appId = recorded[`coolify:app:${appStepKey(org, entry.key)}`];
3715
+ }
3716
+ else {
3717
+ const stagePrefix = `bg.${entry.key}.app.`;
3718
+ appId = Object.entries(recorded).find(([key, id]) => id && key.startsWith(stagePrefix) && key.endsWith('.stage'))?.[1];
3719
+ }
3720
+ if (!appId)
3721
+ throw new Error(`${at}: the app of ${clientKey} is not recorded yet`);
3722
+ const held = this.ports.heldKeys;
3723
+ const ref = registrarRef(org.id);
3724
+ const registrarKey = held
3725
+ ? await withLabelledTimeout('held key read', HELD_KEY_READ_TIMEOUT_MS, () => held.resolve(ref)).catch(() => {
3726
+ throw new Error(`${at}: the registrar key could not be read (ref ${ref})`);
3727
+ })
3728
+ : null;
3729
+ if (!registrarKey)
3730
+ throw new Error(`${at}: the registrar key is not held (ref ${ref})`);
3731
+ this.handledSecrets.add(registrarKey);
3732
+ const { env, appKey } = await sendRegistration({
3733
+ want: wanted.want,
3734
+ registrarUrl: registrarUrl(authIssuer),
3735
+ registrarKey,
3736
+ api,
3737
+ secret: (value) => void this.handledSecrets.add(value),
3738
+ // A workspace copy must hold none of the live app's clients.
3739
+ refusedClientIds: copy === 'workspace' ? liveClientIds(org, recorded) : [],
3740
+ label: `${at} (${clientKey})`,
3741
+ });
3742
+ const envs = [
3743
+ { key: 'AUTH_ISSUER', value: authIssuer },
3744
+ { key: 'AUTH_ORGANIZATION_ID', value: env.AUTH_ORGANIZATION_ID },
3745
+ { key: 'AUTH_CLIENT_ID', value: env.AUTH_CLIENT_ID },
3746
+ { key: 'AUTH_TEAM_CLIENT_ID', value: env.AUTH_TEAM_CLIENT_ID },
3747
+ { key: 'AUTH_TEAM_ORGANIZATION_ID', value: env.AUTH_TEAM_ORGANIZATION_ID },
3748
+ { key: 'AUTH_APP_KEY', value: appKey },
3749
+ ];
3750
+ let written = false;
3751
+ for (let attempt = 0; attempt < 3 && !written; attempt++) {
3752
+ try {
3753
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
3754
+ written = true;
3755
+ }
3756
+ catch {
3757
+ // Tried again below; the error that leaves names the copy only.
3758
+ }
3759
+ }
3760
+ if (!written) {
3761
+ throw new Error(`${at}: the sign-in env of ${clientKey} could not be written; the next apply registers it again`);
3762
+ }
3763
+ await this.ports.coolify.restart(appId);
3764
+ context.recordScopeId(registeredAppEnvScopeKey(clientKey), JSON.stringify(env));
3765
+ // What the app step and the workspace step compare against, so neither pushes the same env again.
3766
+ const now = context.recordedIds();
3767
+ if (copy === 'live') {
3768
+ context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), JSON.stringify(entryAuthEnv(org, entry, now, authIssuer)));
3769
+ }
3770
+ else {
3771
+ const marker = workspaceSignInMarker(org, entry, now, authIssuer);
3772
+ if (marker)
3773
+ context.recordScopeId(wsKey.signIn(entry.key), marker);
3774
+ }
3775
+ context.recordScopeId(registeredAppAppliedScopeKey(clientKey), JSON.stringify(wanted.want));
3776
+ await context.persistScopeIds();
3777
+ return {};
3778
+ }
3649
3779
  /**
3650
3780
  * `coolify:workspaces-database` (`plan/broker-workspaces-database.ts`): the broker's workspaces database, made
3651
3781
  * beside the broker (the platform's project and network, or the company's own for an org that owns its services)
@@ -3984,8 +4114,43 @@ export class FoundryStepExecutor {
3984
4114
  };
3985
4115
  },
3986
4116
  };
4117
+ const reporting = {
4118
+ held: held('reporting'),
4119
+ available: async () => canHold &&
4120
+ Boolean(this.ports.reporting) &&
4121
+ Boolean(context.recordedIds()[`coolify:reporting:${org.id}`]) &&
4122
+ entry?.uses?.reporting === true,
4123
+ create: async () => {
4124
+ const port = this.ports.reporting;
4125
+ if (!port)
4126
+ throw new Error('no reporting port');
4127
+ const operatorKey = await this.reportingOperatorKey(org, context);
4128
+ const name = stageReportingKeyName(org, subject.id);
4129
+ // Site = the entry key, as the live key's: Boule reads the workspace's errors with environment `stage`.
4130
+ const minted = await port.mintSiteKey({
4131
+ baseUrl: reportingUrlFor(org),
4132
+ operatorKey,
4133
+ name,
4134
+ site: subject.id,
4135
+ environment: 'stage',
4136
+ });
4137
+ return {
4138
+ id: minted.id,
4139
+ name,
4140
+ keyIds: [minted.id],
4141
+ secrets: [{ env: 'REPORTING_SITE_KEY', value: minted.secret }],
4142
+ };
4143
+ },
4144
+ };
3987
4145
  const prodJobsProjectId = context.recordedIds()[`jobs:registration:${org.id}`];
3988
- return { files, ai, jobs, analytics, ...(prodJobsProjectId ? { prodJobsProjectId } : {}) };
4146
+ return {
4147
+ files,
4148
+ ai,
4149
+ jobs,
4150
+ analytics,
4151
+ reporting,
4152
+ ...(prodJobsProjectId ? { prodJobsProjectId } : {}),
4153
+ };
3989
4154
  }
3990
4155
  /**
3991
4156
  * Stage's own sign-in (`plan/stage-signin.ts`): a client named `<production client>-stage` on production's
@@ -5027,6 +5192,11 @@ export class FoundryStepExecutor {
5027
5192
  // #75 "What belongs where") once `ensureLogVolume` has mounted the
5028
5193
  // matching volume.
5029
5194
  { key: 'LOG_DIR', value: LOG_DIR },
5195
+ // An entry that sends mail tags each send with its key (`@wtfalch/mailer` 0.10.0), so Archon's mail reads
5196
+ // split mail per app. Not in the appEnv marker: `planApp` plans it from the live env names, once.
5197
+ ...(sendsMail(entry)
5198
+ ? [{ key: MAIL_APP_KEY_ENV, value: mailAppKeyOf(entry.key, 'live') }]
5199
+ : []),
5030
5200
  ];
5031
5201
  // An entry with `uses.auth` signs in one organisation, with its own client
5032
5202
  // and the issuer; any other entry keeps the org's staff organisation and
@@ -5035,7 +5205,7 @@ export class FoundryStepExecutor {
5035
5205
  if (entry.uses?.auth) {
5036
5206
  // A customers entry never falls back to the staff organisation's id.
5037
5207
  if (!authEnv.AUTH_ORGANIZATION_ID) {
5038
- throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth.users}, whose organisation id is not recorded yet`);
5208
+ throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${signsInCustomers(entry.uses.auth) ? 'customers' : 'staff'}, whose organisation id is not recorded yet`);
5039
5209
  }
5040
5210
  for (const [key, value] of Object.entries(authEnv))
5041
5211
  envs.push({ key, value });
@@ -5135,7 +5305,14 @@ export class FoundryStepExecutor {
5135
5305
  const live = await this.ports.coolify.listEnvs(appId);
5136
5306
  heldChanged = heldEnvs.some(({ key, value }) => !live.some((e) => e.key === key && !e.is_preview && e.value === value));
5137
5307
  }
5308
+ // `MAIL_APP_KEY` is not in the marker either: a running app that lacks it, or holds another, restarts once.
5309
+ let mailKeyChanged = false;
5310
+ if (sendsMail(entry)) {
5311
+ const want = mailAppKeyOf(entry.key, 'live');
5312
+ mailKeyChanged = !(await this.ports.coolify.listEnvs(appId)).some((e) => e.key === MAIL_APP_KEY_ENV && !e.is_preview && e.value === want);
5313
+ }
5138
5314
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
5315
+ await this.dropUnsetSettingsEnv(appId, entry, recorded, resolved.marker);
5139
5316
  // `coolifyApp` records `applied` once the restart succeeded, so `planApp`
5140
5317
  // replans this step when the declared map changes (rule 3). Ids and a
5141
5318
  // domain, never a secret.
@@ -5146,6 +5323,7 @@ export class FoundryStepExecutor {
5146
5323
  return {
5147
5324
  changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
5148
5325
  heldChanged ||
5326
+ mailKeyChanged ||
5149
5327
  (Boolean(entry.uses?.auth) &&
5150
5328
  (authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}') ||
5151
5329
  authEventsMarker(entry) !== (recorded[authEventsAppliedScopeKey(entry.key)] ?? ''))),
@@ -5154,6 +5332,37 @@ export class FoundryStepExecutor {
5154
5332
  eventsApplied: authEventsMarker(entry),
5155
5333
  };
5156
5334
  }
5335
+ /**
5336
+ * #1116: takes a module setting's env name (`MAIL_REPLY_TO`, ...) out of the app's env once the setting is
5337
+ * unset or its module is off. Only a name the last applied env wrote (the recorded marker) and the entry's
5338
+ * own `env` does not declare, so a value the company set by hand stays.
5339
+ */
5340
+ async dropUnsetSettingsEnv(appId, entry, recorded, marker) {
5341
+ let last = {};
5342
+ try {
5343
+ const parsed = JSON.parse(recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}');
5344
+ if (parsed && typeof parsed === 'object')
5345
+ last = parsed;
5346
+ }
5347
+ catch {
5348
+ return;
5349
+ }
5350
+ const gone = [...FILES_SETTINGS_ENV_NAMES, ...MAIL_SETTINGS_ENV_NAMES].filter((name) => name in last && !(name in marker) && !(name in (entry.env ?? {})));
5351
+ if (gone.length === 0)
5352
+ return;
5353
+ const rows = await this.ports.coolify.listEnvs(appId);
5354
+ for (const row of rows) {
5355
+ if (!gone.includes(row.key) || !row.uuid)
5356
+ continue;
5357
+ try {
5358
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
5359
+ }
5360
+ catch (error) {
5361
+ if (!isNotFoundError(error))
5362
+ throw error;
5363
+ }
5364
+ }
5365
+ }
5157
5366
  /** Takes `AUTH_EVENTS_SECRET` out of the app's env once the entry stops declaring events. The held value stays: the held store has no delete. */
5158
5367
  async dropAuthEventsEnv(appId) {
5159
5368
  const rows = await this.ports.coolify.listEnvs(appId);
@@ -5416,12 +5625,12 @@ export class FoundryStepExecutor {
5416
5625
  * third, bare copy of this check (foundry#970, thermo note 1 in #957). Omitted, the three existing
5417
5626
  * callers below keep their original behaviour.
5418
5627
  */
5419
- async mintIfAbsent(appId, key, bytes, minLength) {
5628
+ async mintIfAbsent(appId, key, bytes, minLength, encoding = 'base64url') {
5420
5629
  const envs = await this.ports.coolify.listEnvs(appId);
5421
5630
  const already = envs.some((e) => e.key === key &&
5422
5631
  !e.is_preview &&
5423
5632
  (minLength === undefined || (e.value ?? '').length >= minLength));
5424
- return already ? null : randomBytes(bytes).toString('base64url');
5633
+ return already ? null : randomBytes(bytes).toString(encoding);
5425
5634
  }
5426
5635
  /**
5427
5636
  * foundry#987: nothing reads `NATIVE_BUILD_KEY` any more -- Boule calls Archon's org build
@@ -5629,6 +5838,8 @@ export class FoundryStepExecutor {
5629
5838
  add(recorded[`coolify:app:${appStepKey(org, entry.key)}`]);
5630
5839
  }
5631
5840
  }
5841
+ // The company's reporting backend sends its alerts with the same token (foundry#1128).
5842
+ add(recorded[`coolify:reporting:${org.id}`]);
5632
5843
  add(self.id);
5633
5844
  const envsOf = (id) => id === self.id
5634
5845
  ? this.ports.coolify.listEnvs(id)
@@ -5669,6 +5880,7 @@ export class FoundryStepExecutor {
5669
5880
  allProjects = [...projects, created.project];
5670
5881
  token = created.token;
5671
5882
  }
5883
+ this.handledSecrets.add(token);
5672
5884
  // A healthy company: this step writes only its own env; a peer that lacks
5673
5885
  // the token gets it in its own step. A re-mint kills the old token, so it
5674
5886
  // reaches every live consumer here.
@@ -7747,6 +7959,12 @@ export class FoundryStepExecutor {
7747
7959
  await this.addDesktopAuthCallback(org, context, desktopAuthCallbackUri(inputs.origin));
7748
7960
  context.recordScopeId(DESKTOP_AUTH_ORIGIN_SCOPE_KEY, inputs.origin);
7749
7961
  context.recordScopeId(DESKTOP_AUTH_REVISION_SCOPE_KEY, desktopAuthRevision(env));
7962
+ // foundry#1112: the database host itself is not part of `env`'s revision (it comes from a live
7963
+ // Coolify read, not from `desktopAuthEnv`'s inputs), so the server this env was built against is
7964
+ // tracked here instead -- a later change replans this step even when nothing else drifted.
7965
+ const dbServerId = recorded[ORG_POSTGRES_SERVER_SCOPE_KEY];
7966
+ if (dbServerId)
7967
+ context.recordScopeId(DESKTOP_AUTH_DB_SERVER_SCOPE_KEY, dbServerId);
7750
7968
  return { resourceId: app.appId };
7751
7969
  }
7752
7970
  /** Whether the org's running Agora carries `AGORA_DESKTOP_AUTH_UPSTREAM`, read from its live env. */
@@ -8070,7 +8288,9 @@ export class FoundryStepExecutor {
8070
8288
  *
8071
8289
  * `ANALYTICS_OPERATOR_KEY` is made once here (`mintIfAbsent`) and written only to this app's env. No outside
8072
8290
  * service shows it once, so no undelivered marker is needed: a failed write leaves nothing live to mint twice.
8073
- * Steps that call the backend's operator API read it back from this env.
8291
+ * Steps that call the backend's operator API read it back from this env. `ANALYTICS_PERSON_KEY` (64 hex
8292
+ * characters, analytics#109: the per-site key that counts a signed-in person once) is made the same way and
8293
+ * never rotated: a value already in the env, made here or by hand, is kept.
8074
8294
  *
8075
8295
  * A brand new app is deployed once ({@link ANALYTICS_DEPLOYED_SCOPE_KEY}), as for Boule (#504).
8076
8296
  */
@@ -8116,13 +8336,20 @@ export class FoundryStepExecutor {
8116
8336
  this.handledSecrets.add(operatorKey);
8117
8337
  envs.push({ key: ANALYTICS_OPERATOR_KEY_ENV, value: operatorKey });
8118
8338
  }
8339
+ // foundry#1137: rotating this key would count every signed-in person twice, so a held value is kept.
8340
+ const personKey = await this.mintIfAbsent(app.appId, ANALYTICS_PERSON_KEY_ENV, ANALYTICS_PERSON_KEY_BYTES, undefined, 'hex');
8341
+ if (personKey) {
8342
+ this.handledSecrets.add(personKey);
8343
+ envs.push({ key: ANALYTICS_PERSON_KEY_ENV, value: personKey });
8344
+ }
8345
+ const minted = [operatorKey, personKey].filter((k) => k !== null);
8119
8346
  try {
8120
8347
  await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
8121
8348
  }
8122
8349
  catch (error) {
8123
- if (!operatorKey)
8350
+ if (minted.length === 0)
8124
8351
  throw error;
8125
- throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), [operatorKey]));
8352
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
8126
8353
  }
8127
8354
  const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
8128
8355
  if (!dbPush.pushed) {
@@ -8134,7 +8361,7 @@ export class FoundryStepExecutor {
8134
8361
  await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'analytics' }, (key, value) => context.recordScopeId(key, value));
8135
8362
  context.recordScopeId(ANALYTICS_DEPLOYED_SCOPE_KEY, '1');
8136
8363
  }
8137
- else if (app.existed && (envChanged || operatorKey !== null || dbPush.changed)) {
8364
+ else if (app.existed && (envChanged || minted.length > 0 || dbPush.changed)) {
8138
8365
  await this.ports.coolify.restart(app.appId);
8139
8366
  }
8140
8367
  return { resourceId: app.appId };
@@ -8308,6 +8535,264 @@ export class FoundryStepExecutor {
8308
8535
  }
8309
8536
  throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'})`);
8310
8537
  }
8538
+ // ---- reporting backend (wtfalch/reporting ADR 0002) ----------------------
8539
+ /**
8540
+ * `plan/diff.ts`'s `planReportingBackend`: the company's reporting backend as one more Coolify application
8541
+ * from https://github.com/wtfalch/reporting's root Dockerfile ({@link REPORTING_REPO}), with a database of its
8542
+ * own, a copy of {@link coolifyAnalytics}. Its start script migrates as the owner role and then serves as the
8543
+ * runtime role, so `pushDatabaseUrl` gives it both URLs. It needs no sign-in client: it reads only
8544
+ * `AUTH_ORGANIZATION_ID` (the tenant id derives from it), `APP_URL` and the operator key.
8545
+ *
8546
+ * `REPORTING_OPERATOR_KEY` is made once here (`mintIfAbsent`) and written only to this app's env. No outside
8547
+ * service shows it once, so no undelivered marker is needed: a failed write leaves nothing live to mint twice.
8548
+ * Steps that call the backend's operator API read it back from this env.
8549
+ *
8550
+ * A brand new app is deployed once ({@link REPORTING_DEPLOYED_SCOPE_KEY}), as for Boule (#504).
8551
+ */
8552
+ async coolifyReporting(action, org, context) {
8553
+ // Mail only when the plan showed it (`desired.mail`), from the recorded domain.
8554
+ const mailDomain = action.desired.mail === true ? (context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY] ?? null) : null;
8555
+ const { env, missing } = desiredReportingEnv(org, context.recordedIds(), mailDomain);
8556
+ if (missing.length > 0) {
8557
+ throw new Error(`the reporting backend for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
8558
+ }
8559
+ const projectUuid = await this.ensureCoolifyProject(org, context);
8560
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
8561
+ const name = `${org.slug}-reporting`;
8562
+ const dbName = `${org.slug}-reporting-db`.replace(/-/g, '_');
8563
+ const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-reporting-db`, dbName, REPORTING_DATABASE_SCOPE_KEY);
8564
+ context.recordScopeId(REPORTING_DATABASE_SCOPE_KEY, dbId);
8565
+ const server = await this.resolveServer(org, context);
8566
+ const githubApp = await this.ports.coolify.requireGithubApp();
8567
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
8568
+ is_preview_deployments_enabled: false,
8569
+ project_uuid: projectUuid,
8570
+ server_uuid: server.uuid,
8571
+ environment_name: environment.name,
8572
+ environment_uuid: environment.uuid,
8573
+ github_app_uuid: githubApp.uuid,
8574
+ git_repository: REPORTING_REPO,
8575
+ git_branch: 'main',
8576
+ build_pack: 'dockerfile',
8577
+ name,
8578
+ domains: coolifyTunnelDomains([reportingHostnameFor(org)]),
8579
+ dockerfile_location: REPORTING_APP_DOCKERFILE,
8580
+ }));
8581
+ await this.ensureLogVolume(app.appId, org, app.existed);
8582
+ await this.ports.coolify.updateApplication(app.appId, {
8583
+ ...healthCheckSettings('/api/health', REPORTING_PORT),
8584
+ ...(await this.routedLabels(name, reportingHostnameFor(org), REPORTING_PORT)),
8585
+ });
8586
+ const liveEnv = Object.fromEntries((await this.ports.coolify.listEnvs(app.appId))
8587
+ .filter((e) => !e.is_preview)
8588
+ .map((e) => [e.key, e.value]));
8589
+ const envChanged = reportingEnvDrift(env, liveEnv).length > 0;
8590
+ const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
8591
+ const operatorKey = await this.mintIfAbsent(app.appId, REPORTING_OPERATOR_KEY_ENV, 32);
8592
+ if (operatorKey) {
8593
+ this.handledSecrets.add(operatorKey);
8594
+ envs.push({ key: REPORTING_OPERATOR_KEY_ENV, value: operatorKey });
8595
+ }
8596
+ try {
8597
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
8598
+ }
8599
+ catch (error) {
8600
+ if (!operatorKey)
8601
+ throw error;
8602
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), [operatorKey]));
8603
+ }
8604
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
8605
+ if (!dbPush.pushed) {
8606
+ throw new Error(`the reporting backend for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
8607
+ }
8608
+ // The company's `<slug>-app` Lettermint token, joined as a consumer before the first deploy, so the
8609
+ // backend starts with it. `MAIL_FROM` went in with the env above.
8610
+ const mail = mailDomain
8611
+ ? await this.ensureBouleMail(app.appId, org, context, action, app.existed && Boolean(context.recordedIds()[REPORTING_DEPLOYED_SCOPE_KEY]), 'reporting')
8612
+ : { changed: false, restarted: false };
8613
+ if (!context.recordedIds()[REPORTING_DEPLOYED_SCOPE_KEY]) {
8614
+ const runtime = this.ports.runtime ?? {};
8615
+ const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), reportingHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:reporting', `${org.slug}'s reporting backend stays at its previous deploy`);
8616
+ await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'reporting' }, (key, value) => context.recordScopeId(key, value));
8617
+ context.recordScopeId(REPORTING_DEPLOYED_SCOPE_KEY, '1');
8618
+ }
8619
+ else if (app.existed &&
8620
+ !mail.restarted &&
8621
+ (envChanged || operatorKey !== null || dbPush.changed || mail.changed)) {
8622
+ await this.ports.coolify.restart(app.appId);
8623
+ }
8624
+ return { resourceId: app.appId };
8625
+ }
8626
+ /** The backend's operator key, read from its own env. Fixed text when the backend or the key is not there. */
8627
+ async reportingOperatorKey(org, context) {
8628
+ const appId = context.recordedIds()[`coolify:reporting:${org.id}`];
8629
+ if (!appId)
8630
+ throw new Error(`the reporting backend for ${org.slug} is not recorded yet`);
8631
+ const rows = await this.ports.coolify.listEnvs(appId);
8632
+ const key = rows.find((e) => e.key === REPORTING_OPERATOR_KEY_ENV && !e.is_preview)?.value;
8633
+ if (!key)
8634
+ throw new Error(`the reporting backend for ${org.slug} has no operator key yet`);
8635
+ this.handledSecrets.add(key);
8636
+ return key;
8637
+ }
8638
+ /**
8639
+ * `coolify:reporting-boule` (`plan/reporting-boule.ts`): the backend's address and operator key into
8640
+ * Boule's env, then Boule restarts. `reporting_boule_applied` is recorded last. Retire takes both names away.
8641
+ */
8642
+ async reportingBoule(action, org, context) {
8643
+ const at = `coolify:reporting-boule for ${org.slug}`;
8644
+ const recorded = context.recordedIds();
8645
+ const bouleId = recorded[`coolify:boule:${org.id}`];
8646
+ if (!bouleId)
8647
+ throw new Error(`${at}: Boule is not recorded yet`);
8648
+ if (action.desired.mode === 'retire') {
8649
+ const rows = await this.ports.coolify.listEnvs(bouleId);
8650
+ for (const name of REPORTING_BOULE_ENV_NAMES) {
8651
+ const row = rows.find((e) => e.key === name && !e.is_preview);
8652
+ if (row?.uuid) {
8653
+ try {
8654
+ await this.ports.coolify.deleteEnv(bouleId, row.uuid);
8655
+ }
8656
+ catch (error) {
8657
+ if (!isNotFoundError(error))
8658
+ throw error;
8659
+ }
8660
+ }
8661
+ }
8662
+ await this.ports.coolify.restart(bouleId);
8663
+ context.recordScopeId(REPORTING_BOULE_APPLIED_SCOPE_KEY, '');
8664
+ return {};
8665
+ }
8666
+ if (!appsOn(org))
8667
+ throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
8668
+ const backendId = recorded[`coolify:reporting:${org.id}`];
8669
+ if (!backendId)
8670
+ throw new Error(`${at}: the reporting backend is not recorded yet`);
8671
+ const operatorKey = await this.reportingOperatorKey(org, context);
8672
+ try {
8673
+ await this.ports.coolify.bulkUpsertEnvs(bouleId, [
8674
+ { key: 'REPORTING_URL', value: reportingUrlFor(org), literal: true },
8675
+ { key: REPORTING_OPERATOR_KEY_ENV, value: operatorKey, literal: true },
8676
+ ]);
8677
+ await this.ports.coolify.restart(bouleId);
8678
+ }
8679
+ catch (error) {
8680
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), [operatorKey]));
8681
+ }
8682
+ context.recordScopeId(REPORTING_BOULE_APPLIED_SCOPE_KEY, reportingBouleMarker(org, backendId));
8683
+ return {};
8684
+ }
8685
+ /**
8686
+ * `plan/entry-reporting-key.ts`: one server entry's site key (CLAUDE.md "Platform steps" rule 1). Mint: the
8687
+ * key's id is recorded as undelivered and persisted before anything is written; the env write is tried 3
8688
+ * times with the same secret; the applied address and the cleared marker come last; every try failing leaves
8689
+ * the marker, and the error names the id only. `url` rewrites the address. Retire revokes the key and the
8690
+ * env names. The operator key is read from the backend's env and never recorded.
8691
+ */
8692
+ async reportingEntryKey(action, org, context) {
8693
+ const entryKey = action.key.slice(org.id.length + 1);
8694
+ const at = `reporting:key for ${org.slug} entry '${entryKey}'`;
8695
+ const reporting = this.ports.reporting;
8696
+ if (!reporting)
8697
+ throw new Error(`${at}: this executor was built without an reporting port`);
8698
+ const desired = action.desired;
8699
+ const recorded = context.recordedIds();
8700
+ const appId = recorded[`coolify:app:${action.key}`];
8701
+ const baseUrl = reportingUrlFor(org);
8702
+ const tolerate = async (call, gone) => {
8703
+ try {
8704
+ return await call();
8705
+ }
8706
+ catch (error) {
8707
+ if (!isNotFoundError(error))
8708
+ throw error;
8709
+ return gone;
8710
+ }
8711
+ };
8712
+ if (desired.mode === 'retire') {
8713
+ const mainId = recorded[`reporting:key:${action.key}`];
8714
+ const undelivered = recorded[entryReportingUndeliveredScopeKey(entryKey)];
8715
+ const ids = [...new Set([mainId, undelivered].filter((id) => Boolean(id)))];
8716
+ if (ids.length > 0) {
8717
+ const operatorKey = await this.reportingOperatorKey(org, context);
8718
+ for (const keyId of ids) {
8719
+ await tolerate(() => reporting.revokeSiteKey({ baseUrl, operatorKey, keyId }), undefined);
8720
+ }
8721
+ }
8722
+ if (appId) {
8723
+ const rows = await tolerate(() => this.ports.coolify.listEnvs(appId), []);
8724
+ let removed = false;
8725
+ for (const name of ['REPORTING_URL', 'REPORTING_SITE_KEY']) {
8726
+ const row = rows.find((e) => e.key === name && !e.is_preview);
8727
+ if (row?.uuid) {
8728
+ const uuid = row.uuid;
8729
+ await tolerate(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
8730
+ removed = true;
8731
+ }
8732
+ }
8733
+ if (removed)
8734
+ await tolerate(() => this.ports.coolify.restart(appId), undefined);
8735
+ }
8736
+ context.recordScopeId(entryReportingUndeliveredScopeKey(entryKey), '');
8737
+ context.recordScopeId(entryReportingAppliedScopeKey(entryKey), '');
8738
+ if (mainId ?? undelivered) {
8739
+ context.recordScopeId(entryReportingRetiredScopeKey(entryKey), (mainId ?? undelivered));
8740
+ }
8741
+ return {};
8742
+ }
8743
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.reporting)) {
8744
+ throw new Error(`${at}: the entry no longer declares uses.reporting`);
8745
+ }
8746
+ if (!appId)
8747
+ throw new Error(`${at}: the entry's app is not recorded yet`);
8748
+ if (desired.mode === 'url') {
8749
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
8750
+ { key: 'REPORTING_URL', value: baseUrl, literal: true },
8751
+ ]);
8752
+ await this.ports.coolify.restart(appId);
8753
+ context.recordScopeId(entryReportingAppliedScopeKey(entryKey), baseUrl);
8754
+ return {};
8755
+ }
8756
+ if (recorded[entryReportingUndeliveredScopeKey(entryKey)]) {
8757
+ throw new Error(`${at}: a key was minted and never delivered; it is not minted again`);
8758
+ }
8759
+ const operatorKey = await this.reportingOperatorKey(org, context);
8760
+ let minted;
8761
+ try {
8762
+ minted = await reporting.mintSiteKey({
8763
+ baseUrl,
8764
+ operatorKey,
8765
+ name: desired.name,
8766
+ site: entryKey,
8767
+ environment: 'production',
8768
+ });
8769
+ }
8770
+ catch (error) {
8771
+ const status = error?.status;
8772
+ throw new Error(`${at}: the backend did not mint a key${typeof status === 'number' ? ` (status ${status})` : ''}`);
8773
+ }
8774
+ this.handledSecrets.add(minted.secret);
8775
+ context.recordScopeId(entryReportingUndeliveredScopeKey(entryKey), minted.id);
8776
+ await context.persistScopeIds();
8777
+ const rows = [
8778
+ { key: 'REPORTING_URL', value: baseUrl, literal: true },
8779
+ { key: 'REPORTING_SITE_KEY', value: minted.secret, literal: true },
8780
+ ];
8781
+ let lastError;
8782
+ for (let attempt = 1; attempt <= ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS; attempt++) {
8783
+ try {
8784
+ await this.ports.coolify.bulkUpsertEnvs(appId, rows);
8785
+ await this.ports.coolify.restart(appId);
8786
+ context.recordScopeId(entryReportingAppliedScopeKey(entryKey), baseUrl);
8787
+ context.recordScopeId(entryReportingUndeliveredScopeKey(entryKey), '');
8788
+ return { resourceId: minted.id };
8789
+ }
8790
+ catch (error) {
8791
+ lastError = error;
8792
+ }
8793
+ }
8794
+ throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_REPORTING_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'})`);
8795
+ }
8311
8796
  // ---- kosmos (foundry#949) --------------------------------------------------
8312
8797
  /**
8313
8798
  * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
@@ -8843,8 +9328,11 @@ export class FoundryStepExecutor {
8843
9328
  * Boule's env. Returns whether Boule's token changed, so the caller
8844
9329
  * restarts a running Boule, unless `restarted`: the shared rule then already
8845
9330
  * restarted it (`running`), before it deleted the old project.
9331
+ *
9332
+ * The company's reporting backend (foundry#1128) takes its token the same
9333
+ * way, as `role: 'reporting'`. The token is registered for redaction.
8846
9334
  */
8847
- async ensureBouleMail(appId, org, context, action, running) {
9335
+ async ensureBouleMail(appId, org, context, action, running, role = 'boule') {
8848
9336
  const none = { changed: false, restarted: false };
8849
9337
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
8850
9338
  if (!mailDomain)
@@ -8854,8 +9342,9 @@ export class FoundryStepExecutor {
8854
9342
  if (platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase()) {
8855
9343
  const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
8856
9344
  if (!heldToken) {
8857
- throw new Error(`coolify:boule for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
9345
+ throw new Error(`coolify:${role} for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
8858
9346
  }
9347
+ this.handledSecrets.add(heldToken);
8859
9348
  const current = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
8860
9349
  if (current === heldToken)
8861
9350
  return none;
@@ -8868,7 +9357,7 @@ export class FoundryStepExecutor {
8868
9357
  org,
8869
9358
  context,
8870
9359
  mailDomain,
8871
- self: { role: 'boule', id: appId, restart: running },
9360
+ self: { role, id: appId, restart: running },
8872
9361
  mailFrom: null,
8873
9362
  platformApex,
8874
9363
  platformLettermintProjectId,
@@ -9580,6 +10069,55 @@ export class FoundryStepExecutor {
9580
10069
  context.recordScopeId(entryJobsAppliedScopeKey(entryKey), entryJobsWantMarker(want));
9581
10070
  return { resourceId: want.name };
9582
10071
  }
10072
+ /**
10073
+ * foundry#1119, `plan/entry-jobs-boule-key.ts`: Boule's key for one entry's jobs project, minted under rule 1,
10074
+ * held, and written to Boule's `JOBS_PROJECTS`; or, in the retire form, revoked and taken out. See
10075
+ * `entry-jobs-boule-key-apply.ts`.
10076
+ */
10077
+ async jobsBouleKey(action, org, context) {
10078
+ const entryKey = action.key.slice(org.id.length + 1);
10079
+ const at = `jobs:boule-key for ${org.slug} entry '${entryKey}'`;
10080
+ const desired = action.desired;
10081
+ const port = await this.jobsPort(org);
10082
+ const mint = port.mintProjectKey?.bind(port);
10083
+ const revoke = port.revokeProjectKey?.bind(port);
10084
+ if (!mint || !revoke) {
10085
+ throw new Error(`${at}: the jobs scheduler port cannot mint or revoke project keys`);
10086
+ }
10087
+ const held = this.ports.heldKeys;
10088
+ const put = held?.put?.bind(held);
10089
+ if (!held || !put)
10090
+ throw new Error(`${at}: the held keys cannot hold Boule's key`);
10091
+ const deps = {
10092
+ scheduler: { mintProjectKey: mint, revokeProjectKey: revoke },
10093
+ heldKeys: { resolve: held.resolve.bind(held), put },
10094
+ coolify: this.ports.coolify,
10095
+ org: { id: org.id, slug: org.slug },
10096
+ entryKey,
10097
+ project: desired.name,
10098
+ bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
10099
+ recorded: () => context.recordedIds(),
10100
+ record: (key, value) => context.recordScopeId(key, value),
10101
+ forget: (key) => forgetScopeId(context, key),
10102
+ persist: () => context.persistScopeIds(),
10103
+ secret: (value) => void this.handledSecrets.add(value),
10104
+ };
10105
+ if (desired.mode === 'retire') {
10106
+ if (org.hosting.enabled &&
10107
+ org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs && e.live !== false)) {
10108
+ throw new Error(`${at}: the entry declares uses.jobs again; nothing was revoked`);
10109
+ }
10110
+ await retireJobsBouleKey(deps);
10111
+ return {};
10112
+ }
10113
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.jobs)) {
10114
+ throw new Error(`${at}: the entry no longer declares uses.jobs`);
10115
+ }
10116
+ if (!desired.url)
10117
+ throw new Error(`${at}: the scheduler's public address is not in the step`);
10118
+ await ensureJobsBouleKey(deps, desired.url);
10119
+ return {};
10120
+ }
9583
10121
  /** `plan/entry-audit.ts`: makes the platform signer if none exists and registers one entry's ledger for sealing. */
9584
10122
  async auditLedger(action, org, context) {
9585
10123
  const entryKey = action.key.slice(org.id.length + 1);
@@ -10741,13 +11279,16 @@ export class FoundryStepExecutor {
10741
11279
  * on their own (`AiOrganisationPort`'s doc comment), so both are called
10742
11280
  * every time regardless of whether they already ran.
10743
11281
  */
10744
- async aiOrganisation(action, org) {
11282
+ async aiOrganisation(action, org, context) {
10745
11283
  const ai = await this.aiPort();
10746
11284
  const provisioned = await ai.provisionOrganisation({
10747
11285
  organisationId: org.id,
10748
11286
  limitMicros: action.desired.limitMicros ?? null,
10749
11287
  });
10750
- await ai.setOrganisationOffers(org.id, action.desired.offers ?? []);
11288
+ const offers = action.desired.offers ?? [];
11289
+ await ai.setOrganisationOffers(org.id, offers);
11290
+ // foundry#1122: what is offered now, so a narrower set is offered again (`planAiOrganisation`).
11291
+ context.recordScopeId(AI_OFFERS_SCOPE_KEY, JSON.stringify(offers));
10751
11292
  return { resourceId: provisioned.organisationId };
10752
11293
  }
10753
11294
  async aiBudget(action, org, context) {
@@ -10795,6 +11336,16 @@ export class FoundryStepExecutor {
10795
11336
  for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
10796
11337
  try {
10797
11338
  await this.ports.deliverSecret(`ai:key:${org.slug}`, issued.secret);
11339
+ // foundry#1122: what the key holds, so a narrower one is minted again (`planAiKey`). A key it
11340
+ // replaced stays live until it expires (no revoke route yet, ai#157); the plan lists it.
11341
+ context.recordScopeId(AI_KEY_PERMISSIONS_SCOPE_KEY, JSON.stringify(action.desired.permissions ?? []));
11342
+ const replaced = context.recordedIds()[`ai:key:${org.id}`];
11343
+ if (replaced && replaced !== issued.id) {
11344
+ const previous = parseAiIds(context.recordedIds()[AI_KEY_PREVIOUS_SCOPE_KEY]);
11345
+ if (!previous.includes(replaced))
11346
+ previous.push(replaced);
11347
+ context.recordScopeId(AI_KEY_PREVIOUS_SCOPE_KEY, JSON.stringify(previous));
11348
+ }
10798
11349
  return { resourceId: issued.id };
10799
11350
  }
10800
11351
  catch (e) {
@@ -10910,6 +11461,9 @@ export class FoundryStepExecutor {
10910
11461
  name: d.name,
10911
11462
  expiresDays: d.expiresDays,
10912
11463
  permissions: d.permissions,
11464
+ // foundry#1122: the key's runs belong to this app's live copy (ai#190). A key minted before this
11465
+ // joins its app at its next renewal.
11466
+ app: { key: entryKey, copy: 'live' },
10913
11467
  });
10914
11468
  context.recordScopeId(entryAiUndeliveredScopeKey(entryKey), minted.id);
10915
11469
  context.recordScopeId(entryAiPendingExpiresScopeKey(entryKey), minted.expiresAt);
@@ -10949,6 +11503,35 @@ export class FoundryStepExecutor {
10949
11503
  }
10950
11504
  return { resourceId };
10951
11505
  }
11506
+ /**
11507
+ * foundry#1122, `plan/ai-boule-key.ts`: Boule's ai key, minted below the org's own ai key under rule 1, held,
11508
+ * and written to Boule's `AI_URL`, `AI_ORGANISATION_ID` and `AI_API_KEY`. See `ai-boule-key-apply.ts`.
11509
+ */
11510
+ async aiBouleKey(action, org, context) {
11511
+ const at = `ai:boule-key for ${org.slug}`;
11512
+ const ai = await this.aiPort();
11513
+ const held = this.ports.heldKeys;
11514
+ const put = held?.put?.bind(held);
11515
+ if (!held || !put)
11516
+ throw new Error(`${at}: the held keys cannot hold Boule's key`);
11517
+ await ensureAiBouleKey({
11518
+ ai: {
11519
+ mintNestedKey: ai.mintNestedKey.bind(ai),
11520
+ ...(ai.revokeKey ? { revokeKey: ai.revokeKey.bind(ai) } : {}),
11521
+ },
11522
+ heldKeys: { resolve: held.resolve.bind(held), put },
11523
+ coolify: this.ports.coolify,
11524
+ org: { id: org.id, slug: org.slug },
11525
+ bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
11526
+ recorded: () => context.recordedIds(),
11527
+ record: (key, value) => context.recordScopeId(key, value),
11528
+ forget: (key) => forgetScopeId(context, key),
11529
+ persist: () => context.persistScopeIds(),
11530
+ secret: (value) => void this.handledSecrets.add(value),
11531
+ now: () => context.now?.() ?? Date.now(),
11532
+ }, action.desired);
11533
+ return {};
11534
+ }
10952
11535
  /**
10953
11536
  * REVOKE SEAM (ai#157): revokes through `revokeKey` when the port has one.
10954
11537
  * Without it, or when it fails, the id joins the entry's list of keys left
@@ -11326,7 +11909,8 @@ export class FoundryStepExecutor {
11326
11909
  .every((e) => recorded[`files:key:${org.id}:${e.key}`] &&
11327
11910
  recorded[entryFilesMinterScopeKey(e.key)] === rootId &&
11328
11911
  !recorded[entryFilesUndeliveredScopeKey(e.key)]);
11329
- if (!ready)
11912
+ // foundry#1121: Boule's files key too, or Boule's Files page would lose its key.
11913
+ if (!ready || !bouleKeyBelow(recorded, rootId))
11330
11914
  return {};
11331
11915
  const unrevoked = parseFilesIdList(recorded[FILES_ROOT_UNREVOKED_SCOPE_KEY]);
11332
11916
  for (const id of previous) {
@@ -11368,6 +11952,8 @@ export class FoundryStepExecutor {
11368
11952
  // The expiry and the replaced id follow the delivery: before it, the
11369
11953
  // recorded key is still the one in use.
11370
11954
  context.recordScopeId(FILES_ROOT_EXPIRES_SCOPE_KEY, issued.expiresAt);
11955
+ // foundry#1121: what the key holds, so a narrower one is renewed (`planFilesKeys`).
11956
+ context.recordScopeId(FILES_ROOT_PERMISSIONS_SCOPE_KEY, JSON.stringify(action.desired.permissions));
11371
11957
  const replaced = recorded[`files:key:${org.id}`];
11372
11958
  if (mode === 'renew' && replaced) {
11373
11959
  const previous = parseFilesIdList(recorded[FILES_ROOT_PREVIOUS_SCOPE_KEY]);
@@ -11452,7 +12038,11 @@ export class FoundryStepExecutor {
11452
12038
  { key: 'FILES_ORGANISATION_ID', value: org.id },
11453
12039
  ...(d.shareOrigin ? [{ key: 'FILES_SHARE_ORIGIN', value: d.shareOrigin }] : []),
11454
12040
  ];
11455
- for (const name of d.collections) {
12041
+ // foundry#1121: each app's collections are named apart in files; one the entry already had keeps its
12042
+ // name. The app's env names the label and holds the id, as before.
12043
+ const names = knownCollectionNames(entryKey, recorded);
12044
+ for (const label of d.collections) {
12045
+ const name = d.collectionNames?.[label] ?? names[label] ?? label;
11456
12046
  let collection;
11457
12047
  try {
11458
12048
  collection = await files.ensureCollection(org.id, name);
@@ -11460,7 +12050,12 @@ export class FoundryStepExecutor {
11460
12050
  catch (error) {
11461
12051
  throw new Error(`${at}: collection ${name} could not be ensured (${filesErrorCause(error)})`);
11462
12052
  }
11463
- envs.push({ key: filesCollectionEnvName(name), value: collection.id });
12053
+ names[label] = name;
12054
+ envs.push({ key: filesCollectionEnvName(label), value: collection.id });
12055
+ }
12056
+ if (d.collections.length > 0 || recorded[entryFilesAppliedScopeKey(entryKey)]) {
12057
+ context.recordScopeId(entryFilesCollectionsScopeKey(entryKey), JSON.stringify(names));
12058
+ await context.persistScopeIds();
11464
12059
  }
11465
12060
  if (d.mode !== 'settings') {
11466
12061
  const minterId = recorded[`files:key:${org.id}`];
@@ -11518,12 +12113,59 @@ export class FoundryStepExecutor {
11518
12113
  collections: d.collections,
11519
12114
  filesUrl: d.filesUrl,
11520
12115
  ...(d.shareOrigin ? { shareOrigin: d.shareOrigin } : {}),
12116
+ collectionNames: d.collectionNames ?? {},
11521
12117
  }));
11522
12118
  if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
11523
12119
  await this.revokeFilesEntryKey(files, org, entryKey, previousKeyId, context);
11524
12120
  }
11525
12121
  return { resourceId };
11526
12122
  }
12123
+ /**
12124
+ * foundry#1121, `plan/files-boule-key.ts`: Boule's files key, minted below the org's own files key under rule
12125
+ * 1, held, and written to Boule's `FILES_BOULE_KEYS`; or, in the retire form, revoked and taken out. See
12126
+ * `files-boule-key-apply.ts`.
12127
+ */
12128
+ async filesBouleKey(action, org, context) {
12129
+ const at = `files:boule-key for ${org.slug}`;
12130
+ const files = await this.filesPort();
12131
+ const held = this.ports.heldKeys;
12132
+ const put = held?.put?.bind(held);
12133
+ const desired = action.desired;
12134
+ const deps = {
12135
+ files: {
12136
+ issueKeyBelow: files.issueKeyBelow.bind(files),
12137
+ revokeOrganisationKey: files.revokeOrganisationKey.bind(files),
12138
+ },
12139
+ heldKeys: {
12140
+ resolve: async (ref) => (held ? held.resolve(ref) : null),
12141
+ put: async (ref, value) => {
12142
+ if (!put)
12143
+ throw new Error('the held keys cannot put');
12144
+ await put(ref, value);
12145
+ },
12146
+ },
12147
+ coolify: this.ports.coolify,
12148
+ org: { id: org.id, slug: org.slug },
12149
+ bouleAppId: context.recordedIds()[`coolify:boule:${org.id}`],
12150
+ recorded: () => context.recordedIds(),
12151
+ record: (key, value) => context.recordScopeId(key, value),
12152
+ forget: (key) => forgetScopeId(context, key),
12153
+ persist: () => context.persistScopeIds(),
12154
+ secret: (value) => void this.handledSecrets.add(value),
12155
+ now: () => context.now?.() ?? Date.now(),
12156
+ };
12157
+ if (desired.mode === 'retire') {
12158
+ if (org.hosting.enabled && org.hosting.entries.some((e) => e.uses?.files)) {
12159
+ throw new Error(`${at}: an entry declares uses.files again; nothing was revoked`);
12160
+ }
12161
+ await retireFilesBouleKey(deps);
12162
+ return {};
12163
+ }
12164
+ if (!held || !put)
12165
+ throw new Error(`${at}: the held keys cannot hold Boule's key`);
12166
+ await ensureFilesBouleKey(deps, action.desired);
12167
+ return {};
12168
+ }
11527
12169
  /**
11528
12170
  * Revokes one entry key. When the call fails the id joins the entry's list
11529
12171
  * of keys left live, which the plan shows, and the step goes on: the app
@@ -12090,6 +12732,16 @@ export async function setOidcAppLoginBaseUri(zitadel, orgId, projectId, appId, l
12090
12732
  }
12091
12733
  }
12092
12734
  /** A recorded stage service key (`bg.<id>.stage<service>keys` or `...org`) with its last part swapped. */
12735
+ /**
12736
+ * The stage mail sink of `org`, with the `MAIL_APP_KEY` its stage copy of entry `entryKey` sends as (`<key>-work`)
12737
+ * when that entry sends mail. Boule, Agora and kosmos are not entries: their stage gets the sink alone.
12738
+ */
12739
+ function stageSinkFor(org, entryKey) {
12740
+ const entry = entryKey ? org.hosting.entries.find((e) => e.key === entryKey) : undefined;
12741
+ return entry && sendsMail(entry)
12742
+ ? { slug: org.slug, mailAppKey: mailAppKeyOf(entry.key, 'work') }
12743
+ : { slug: org.slug };
12744
+ }
12093
12745
  function stageServiceKey(key, part) {
12094
12746
  const match = /^(bg\..+\.stage(?:files|ai|jobs))(?:keys|org)$/.exec(key);
12095
12747
  if (!match?.[1])