@wtfalch/foundry 0.76.1 → 0.77.1

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 +24 -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
@@ -50,6 +50,45 @@ export class HttpJobsSchedulerClient {
50
50
  syncKey: typeof fields.syncKey === 'string' ? fields.syncKey : undefined,
51
51
  };
52
52
  }
53
+ /**
54
+ * foundry#1119: `POST /admin/projects/<name>/keys` (jobs#79). Answers `201 { id, key, ... }`, the key shown
55
+ * once. A refusal throws with the status only: no response text reaches the error.
56
+ */
57
+ async mintProjectKey(project, grant) {
58
+ const response = await this.fetchImpl(`${this.baseUrl}/admin/projects/${encodeURIComponent(project)}/keys`, {
59
+ method: 'POST',
60
+ headers: {
61
+ authorization: `Bearer ${this.adminToken}`,
62
+ 'content-type': 'application/json',
63
+ },
64
+ body: JSON.stringify({ principal: grant.principal, scopes: [...grant.scopes] }),
65
+ signal: AbortSignal.timeout(ADMIN_CALL_TIMEOUT_MS),
66
+ });
67
+ const body = await response.json().catch(() => undefined);
68
+ if (response.status !== 201) {
69
+ throw Object.assign(new Error(`jobs scheduler: POST /admin/projects/<name>/keys answered ${response.status}`), { status: response.status });
70
+ }
71
+ const fields = typeof body === 'object' && body !== null ? body : {};
72
+ if (typeof fields.id !== 'string' || fields.id.length === 0) {
73
+ throw new Error('jobs scheduler: POST /admin/projects/<name>/keys answered 201 with no key id');
74
+ }
75
+ if (typeof fields.key !== 'string' || fields.key.length === 0) {
76
+ // The id is all the caller can act on: the key is shown once.
77
+ return { id: fields.id, key: '' };
78
+ }
79
+ return { id: fields.id, key: fields.key };
80
+ }
81
+ /** `DELETE /admin/projects/<name>/keys/<id>`. A refusal throws with the status only (404: no such key). */
82
+ async revokeProjectKey(project, keyId) {
83
+ const response = await this.fetchImpl(`${this.baseUrl}/admin/projects/${encodeURIComponent(project)}/keys/${encodeURIComponent(keyId)}`, {
84
+ method: 'DELETE',
85
+ headers: { authorization: `Bearer ${this.adminToken}` },
86
+ signal: AbortSignal.timeout(ADMIN_CALL_TIMEOUT_MS),
87
+ });
88
+ if (response.status === 200 || response.status === 204)
89
+ return;
90
+ throw Object.assign(new Error(`jobs scheduler: DELETE /admin/projects/<name>/keys/<id> answered ${response.status}`), { status: response.status });
91
+ }
53
92
  /** `DELETE /admin/projects/<name>`. A refusal throws with the status only. */
54
93
  async deleteProject(name) {
55
94
  const response = await this.fetchImpl(`${this.baseUrl}/admin/projects/${encodeURIComponent(name)}`, {
@@ -42,7 +42,7 @@ export interface LiveResource {
42
42
  * see that function's own doc comment for why "always planned" broke
43
43
  * `bootstrapPlatform`.
44
44
  */
45
- system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats' | 'github' | 'audit' | 'analytics';
45
+ system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats' | 'github' | 'audit' | 'analytics' | 'reporting';
46
46
  kind: string;
47
47
  key: string;
48
48
  config: Record<string, unknown>;
@@ -3,7 +3,7 @@ import { OWN_SERVICE_LABELS } from '../service-labels.js';
3
3
  export { OWN_SERVICE_LABELS };
4
4
  /**
5
5
  * The names under the org's domain that foundry owns, so no hosting entry may
6
- * take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
6
+ * take one: Agora, Boule, the analytics and reporting backends, the cms studio when the site is on, calls when it uses the
7
7
  * domain, the sign-in host when the issuer does, `auth.`, `mail.`, `files.` and
8
8
  * `sfu.` for an org that runs its own services, and every platform service
9
9
  * hostname that falls under the domain. An own org with no domain holds its
@@ -7,10 +7,11 @@ import { cmsStudioHostnameFor, siteHostnameFor } from './hostnames.js';
7
7
  import { kosmosHostnameFor } from './kosmos.js';
8
8
  import { loginHostnameFor } from './login-host.js';
9
9
  import { ownHostnames } from './own-hostnames.js';
10
+ import { reportingHostnameFor } from './reporting.js';
10
11
  export { OWN_SERVICE_LABELS };
11
12
  /**
12
13
  * The names under the org's domain that foundry owns, so no hosting entry may
13
- * take one: Agora, Boule, the cms studio when the site is on, calls when it uses the
14
+ * take one: Agora, Boule, the analytics and reporting backends, the cms studio when the site is on, calls when it uses the
14
15
  * domain, the sign-in host when the issuer does, `auth.`, `mail.`, `files.` and
15
16
  * `sfu.` for an org that runs its own services, and every platform service
16
17
  * hostname that falls under the domain. An own org with no domain holds its
@@ -35,6 +36,8 @@ export function lockedHostnames(org, platformHostnames) {
35
36
  locked.add(bouleHostnameFor(org));
36
37
  // Reserved always, planned only while something uses analytics (`analyticsWanted`).
37
38
  locked.add(analyticsHostnameFor(org));
39
+ // And the reporting backend's, the same way (`reportingWanted`).
40
+ locked.add(reportingHostnameFor(org));
38
41
  if (kosmosOn(org))
39
42
  locked.add(kosmosHostnameFor(org));
40
43
  if (org.site.enabled)
@@ -1,4 +1,4 @@
1
- import { ownsServices } from '../org.js';
1
+ import { ownsServices, signsInCustomers } from '../org.js';
2
2
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, } from './agora.js';
3
3
  import { BOULE_CLIENT_ID_SCOPE_KEY, bouleHostnameFor } from './boule.js';
4
4
  import { SIGN_IN_IN_APP_SCOPE_KEY, inAppSignIn, inAppStatePairs, } from './sign-in.js';
@@ -170,7 +170,7 @@ export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
170
170
  */
171
171
  export function customersEntryKeys(org) {
172
172
  return new Set([
173
- ...org.hosting.entries.filter((e) => e.uses?.auth?.users === 'customers').map((e) => e.key),
173
+ ...org.hosting.entries.filter((e) => signsInCustomers(e.uses?.auth)).map((e) => e.key),
174
174
  // A workspace's customers client signs in the test customers organisation, on the shared host as well.
175
175
  ...testCustomersEntries(org).map((e) => workspaceClientKey(e.key)),
176
176
  ]);
@@ -5,6 +5,7 @@ import { chatInternalNames, chatRekeyedInternalNames } from './chat-matrix.js';
5
5
  import { workerAppHostname, workspaceHost, workspaceInboxHost } from './hostnames.js';
6
6
  import { orgTarget, platformTarget } from './own-services.js';
7
7
  import { appNameFor } from './platform-diff.js';
8
+ import { reportingWanted } from './reporting.js';
8
9
  /**
9
10
  * The names foundry gives the services every company can run beside its own
10
11
  * hosting entries: Agora, Boule, the tunnel and its connector, and chat,
@@ -23,6 +24,8 @@ export function companyServiceNames(org) {
23
24
  ];
24
25
  if (analyticsWanted(org))
25
26
  names.push(`${s}-analytics`, `${s}-analytics-db`);
27
+ if (reportingWanted(org))
28
+ names.push(`${s}-reporting`, `${s}-reporting-db`);
26
29
  if (kosmosOn(org))
27
30
  names.push(`${s}-kosmos`, `${s}-kosmos-db`);
28
31
  if (org.chat?.enabled) {
@@ -10,7 +10,7 @@ import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-hos
10
10
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, } from './mail-relay.js';
11
11
  import { mailStoreIsLegacy, mailStoreSwitchDescription } from './mail-store.js';
12
12
  import { ownHostnames, passkeyRpIdFor } from './own-hostnames.js';
13
- import { JOBS_PUBLIC_URL_SCOPE_KEY, OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY } from './own-jobs-route.js';
13
+ import { JOBS_ENTRY_PATH_PATTERN, JOBS_PUBLIC_URL_SCOPE_KEY, OWN_JOBS_ROUTE_PATHS_SCOPE_KEY, OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY, } from './own-jobs-route.js';
14
14
  import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
15
15
  import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_NODE_IP_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
16
16
  import { AUTH_ISSUER_SOURCE_ENV_VERSION, AUTH_MCP_ENV_VERSION, AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ISSUER_SOURCE_ENV_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, authLoginEnvMarker, orgDeclaresMcp, platformMailMxHost, } from './platform-diff.js';
@@ -92,7 +92,8 @@ export function planOwnJobsRoute(options) {
92
92
  if (!schedulerApplied && !options.schedulerPlanned)
93
93
  return { step: null, blocked: null };
94
94
  if (recordedIds[JOBS_PUBLIC_URL_SCOPE_KEY] === `https://${hostname}` &&
95
- recordedIds[OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY]) {
95
+ recordedIds[OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY] &&
96
+ recordedIds[OWN_JOBS_ROUTE_PATHS_SCOPE_KEY] === JOBS_ENTRY_PATH_PATTERN) {
96
97
  return { step: null, blocked: null };
97
98
  }
98
99
  const waiting = (reason) => ({
@@ -17,9 +17,12 @@ export declare const OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY = "cloudflare_own_jobs_tunn
17
17
  export declare const OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY = "coolify_own_jobs_connector_app";
18
18
  /**
19
19
  * The only paths the public route serves: what a project's app calls (`/sync`, `/timers`, `/timers/<name>`,
20
- * `/deliveries`). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
20
+ * `/deliveries`) and what Boule reads and presses for one project with that project's key (`/project/*`,
21
+ * jobs#79, foundry#1119). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
21
22
  */
22
- export declare const JOBS_ENTRY_PATH_PATTERN = "^/(sync$|timers(/|$)|deliveries$)";
23
+ export declare const JOBS_ENTRY_PATH_PATTERN = "^/(sync$|timers(/|$)|deliveries$|project/)";
24
+ /** The path pattern the route's ingress was last written with; a different pattern plans the route again. */
25
+ export declare const OWN_JOBS_ROUTE_PATHS_SCOPE_KEY = "own_jobs_route_paths";
23
26
  export declare const JOBS_ROUTE_CALL_TIMEOUT_MS = 60000;
24
27
  /** The tunnel's name and the connector app's, one pair per company. */
25
28
  export declare const jobsRouteNames: (target: Pick<ServiceTarget, "id">) => {
@@ -18,9 +18,12 @@ export const OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY = 'cloudflare_own_jobs_tunnel';
18
18
  export const OWN_JOBS_ROUTE_CONNECTOR_APP_SCOPE_KEY = 'coolify_own_jobs_connector_app';
19
19
  /**
20
20
  * The only paths the public route serves: what a project's app calls (`/sync`, `/timers`, `/timers/<name>`,
21
- * `/deliveries`). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
21
+ * `/deliveries`) and what Boule reads and presses for one project with that project's key (`/project/*`,
22
+ * jobs#79, foundry#1119). `/admin/*` is not on the internet; foundry reaches it on the Coolify network.
22
23
  */
23
- export const JOBS_ENTRY_PATH_PATTERN = '^/(sync$|timers(/|$)|deliveries$)';
24
+ export const JOBS_ENTRY_PATH_PATTERN = '^/(sync$|timers(/|$)|deliveries$|project/)';
25
+ /** The path pattern the route's ingress was last written with; a different pattern plans the route again. */
26
+ export const OWN_JOBS_ROUTE_PATHS_SCOPE_KEY = 'own_jobs_route_paths';
24
27
  export const JOBS_ROUTE_CALL_TIMEOUT_MS = 60_000;
25
28
  /** The tunnel's name and the connector app's, one pair per company. */
26
29
  export const jobsRouteNames = (target) => ({
@@ -55,6 +58,7 @@ export async function provisionJobsRoute(scope, ports, target, input) {
55
58
  { service: 'http_status:404' },
56
59
  ],
57
60
  }));
61
+ await record(OWN_JOBS_ROUTE_PATHS_SCOPE_KEY, JOBS_ENTRY_PATH_PATTERN);
58
62
  await withTimeout(`${at}: connector app`, ms * 5, () => ensureCloudflaredConnector({
59
63
  coolify: ports.coolify,
60
64
  tunnels,
@@ -119,6 +119,12 @@ export declare const LIMITS: {
119
119
  autoDeploy: false | "declared";
120
120
  capabilities: CapabilityTier;
121
121
  };
122
+ readonly reporting: {
123
+ memory: string;
124
+ dbMemory: string;
125
+ autoDeploy: false | "declared";
126
+ capabilities: CapabilityTier;
127
+ };
122
128
  readonly companyOther: {
123
129
  memory: string;
124
130
  dbMemory: string;
@@ -31,6 +31,7 @@ export const LIMITS = {
31
31
  boule: profile('512m'),
32
32
  kosmos: profile('512m'),
33
33
  analytics: profile('512m', '512m'),
34
+ reporting: profile('512m', '512m'),
34
35
  companyOther: profile('1g', '512m'),
35
36
  platformArchon: profile('1g'),
36
37
  platformAi: profile('1g'),
@@ -596,6 +596,11 @@ export interface FoundryPlanApplyOptions {
596
596
  * reported blocked instead of planned.
597
597
  */
598
598
  broker?: boolean;
599
+ /**
600
+ * ADR 0003: true when the host's executor was built with a `registrarApi` port. Optional, and false without
601
+ * it: an entry whose customers and team both sign in is reported blocked instead of registered.
602
+ */
603
+ registrarApi?: boolean;
599
604
  /**
600
605
  * #318: true when the host's executor was built with a `WorkersPort`.
601
606
  * Optional, and false without it: each entry's `worker` Worker is then
@@ -613,6 +618,21 @@ export interface FoundryPlanApplyOptions {
613
618
  keysRegistrarUrl?: string;
614
619
  /** True when the host's executor was built with an `analytics` port. A `uses.analytics` key is blocked without it. */
615
620
  analyticsPort?: boolean;
621
+ /** True when the host's executor was built with a `reporting` port. A `uses.reporting` key is blocked without it. */
622
+ reportingPort?: boolean;
623
+ /**
624
+ * foundry#1119: true when the host's jobs scheduler port (`FoundryStepExecutorPorts.jobsScheduler`, or the
625
+ * platform's `HttpJobsSchedulerClient`) has `mintProjectKey` and `revokeProjectKey`. Without it each
626
+ * `uses.jobs` entry's Boule key is reported blocked.
627
+ */
628
+ jobsProjectKeys?: boolean;
629
+ /**
630
+ * foundry#1122: true once ai offers a company Boule's five read permissions (`ai.usage:read`,
631
+ * `ai.runs:read`, `ai.budgets:read`, `ai.budgets:update`, `ai.offerings:read`; all `offered: false` today).
632
+ * With it each organisation is offered them, its own ai key is minted again with them, and Boule gets a key
633
+ * below it. Without it Boule's ai key is reported blocked.
634
+ */
635
+ aiBouleReads?: boolean;
616
636
  /** C2: true when the host's executor was built with a `WorkerAppDatabasePort`. */
617
637
  workerAppDatabase?: boolean;
618
638
  /** True when that port has `deletableTables`. A `workerApp.database.noDelete` is blocked without it. */
@@ -773,11 +793,15 @@ export declare class FoundryPlanApply implements PlanApply {
773
793
  private readonly zitadelMcp;
774
794
  private readonly zitadelOrgMetadata;
775
795
  private readonly broker;
796
+ private readonly registrarApi;
776
797
  private readonly edgeWorkers;
777
798
  private readonly workerApps;
778
799
  private readonly workerAppKeysUrl;
779
800
  private readonly keysRegistrarUrl;
780
801
  private readonly analyticsPort;
802
+ private readonly reportingPort;
803
+ private readonly jobsProjectKeys;
804
+ private readonly aiBouleReads;
781
805
  private readonly workerAppDatabase;
782
806
  private readonly workerAppNoDeleteCheck;
783
807
  private readonly workerAppRegistryAccountId;
@@ -11,6 +11,7 @@ import { StaleLeaseError, } from '../store.js';
11
11
  import { entryHasDatabase } from '../worker-app-config.js';
12
12
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
13
13
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
14
+ import { AI_BOULE_KEY_SCOPE_KEY, AI_BOULE_UNDELIVERED_SCOPE_KEY } from './ai-boule-key.js';
14
15
  import { APPS_DB_ENTRY_KEY } from './apps-db-route.js';
15
16
  import { NetworkMoveBackupMissingError, OrgPostgresMigrationBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForOrgPostgresMigration, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
16
17
  import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, blueGreenSubjects, declaresBlueGreen, lbOrigin, } from './blue-green.js';
@@ -20,7 +21,7 @@ import { unconfirmedUpgrades } from './confirm-upgrade.js';
20
21
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
21
22
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
22
23
  import { BOULE_EDGE_ENTRY_KEY, bouleEdgeKeyWanted, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
23
- import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
24
+ import { FILES_BOULE_KEY_SCOPE_KEY, FILES_BOULE_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
24
25
  import { probeRepoKeys, repoKeyProbeTargets, repoKeysInPlay, } from './entry-repo-key.js';
25
26
  import { PLATFORM_GITHUB_APP_REFS } from './held-key-refs.js';
26
27
  import { KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, } from './keys-registrar.js';
@@ -185,11 +186,15 @@ export class FoundryPlanApply {
185
186
  zitadelMcp;
186
187
  zitadelOrgMetadata;
187
188
  broker;
189
+ registrarApi;
188
190
  edgeWorkers;
189
191
  workerApps;
190
192
  workerAppKeysUrl;
191
193
  keysRegistrarUrl;
192
194
  analyticsPort;
195
+ reportingPort;
196
+ jobsProjectKeys;
197
+ aiBouleReads;
193
198
  workerAppDatabase;
194
199
  workerAppNoDeleteCheck;
195
200
  workerAppRegistryAccountId;
@@ -231,11 +236,15 @@ export class FoundryPlanApply {
231
236
  this.zitadelMcp = options.zitadelMcp ?? false;
232
237
  this.zitadelOrgMetadata = options.zitadelOrgMetadata ?? false;
233
238
  this.broker = options.broker ?? false;
239
+ this.registrarApi = options.registrarApi ?? false;
234
240
  this.edgeWorkers = options.edgeWorkers ?? false;
235
241
  this.workerApps = options.workerApps ?? false;
236
242
  this.workerAppKeysUrl = options.workerAppKeysUrl;
237
243
  this.keysRegistrarUrl = options.keysRegistrarUrl;
238
244
  this.analyticsPort = options.analyticsPort ?? false;
245
+ this.reportingPort = options.reportingPort ?? false;
246
+ this.jobsProjectKeys = options.jobsProjectKeys ?? false;
247
+ this.aiBouleReads = options.aiBouleReads ?? false;
239
248
  this.workerAppDatabase = options.workerAppDatabase ?? false;
240
249
  this.workerAppNoDeleteCheck = options.workerAppNoDeleteCheck ?? false;
241
250
  this.workerAppRegistryAccountId = options.workerAppRegistryAccountId;
@@ -1703,11 +1712,15 @@ export class FoundryPlanApply {
1703
1712
  broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
1704
1713
  ? await this.executor.ownBrokerPort(org, recorded)
1705
1714
  : this.broker,
1715
+ registrarApi: this.registrarApi,
1706
1716
  edgeWorkers: this.edgeWorkers,
1707
1717
  workerApps: this.workerApps,
1708
1718
  workerAppKeysUrl: this.workerAppKeysUrl,
1709
1719
  keysRegistrarUrl: this.keysRegistrarUrl,
1710
1720
  analyticsPort: this.analyticsPort,
1721
+ reportingPort: this.reportingPort,
1722
+ jobsProjectKeys: this.jobsProjectKeys,
1723
+ aiBouleReads: this.aiBouleReads,
1711
1724
  workerAppDatabase: this.workerAppDatabase,
1712
1725
  workerAppNoDeleteCheck: this.workerAppNoDeleteCheck,
1713
1726
  workerAppRegistryAccountId: this.workerAppRegistryAccountId,
@@ -2358,7 +2371,7 @@ export function stepEntryKey(step, orgId, entries = []) {
2358
2371
  }
2359
2372
  const entryOwned = (system === 'coolify' && kind === 'app') ||
2360
2373
  (system === 'cloudflare' && (kind === 'worker' || kind === 'worker-app' || kind === 'gate')) ||
2361
- (system === 'jobs' && kind === 'project') ||
2374
+ (system === 'jobs' && (kind === 'project' || kind === 'boule-key')) ||
2362
2375
  (system === 'github' && kind === 'deploy-key') ||
2363
2376
  (system === 'audit' && kind === 'ledger') ||
2364
2377
  (kind === 'key' && (system === 'ai' || system === 'edge' || system === 'files'));
@@ -2770,6 +2783,8 @@ const STEP_FIELDS = {
2770
2783
  // F4 (#925): both hang off the company's Apps switch.
2771
2784
  'coolify:analytics': 'hosting.entries.uses.analytics',
2772
2785
  'coolify:analytics-boule': 'boule.apps',
2786
+ 'coolify:reporting': 'hosting.entries.uses.reporting',
2787
+ 'coolify:reporting-boule': 'boule.apps',
2773
2788
  'coolify:org-apps-admin': 'boule.apps',
2774
2789
  'cloudflare:apps-db-route': 'boule.apps',
2775
2790
  'cloudflare:apps-zones': 'boule.apps',
@@ -2780,6 +2795,9 @@ function stepField(action) {
2780
2795
  (action.desired.entry === 'boule' || action.desired.entry === 'agora')) {
2781
2796
  return `${action.desired.entry}.blueGreen`;
2782
2797
  }
2798
+ // Boule's ai key (foundry#1122) belongs to the org's ai, like the org's own key.
2799
+ if (action.system === 'ai' && action.kind === 'boule-key')
2800
+ return 'ai';
2783
2801
  // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
2784
2802
  if (action.system === 'ai' && action.kind === 'key' && action.key.includes(':')) {
2785
2803
  return 'hosting.entries.uses.ai';
@@ -2787,16 +2805,23 @@ function stepField(action) {
2787
2805
  // The org's own files key (F6b) is keyed `<orgId>`, an entry's `<orgId>:<entry>`.
2788
2806
  if (action.system === 'files' && action.kind === 'key')
2789
2807
  return 'hosting.entries.uses.files';
2808
+ // Boule's files key (foundry#1121) is keyed `<orgId>` and follows the entries' `uses.files`.
2809
+ if (action.system === 'files' && action.kind === 'boule-key')
2810
+ return 'hosting.entries.uses.files';
2790
2811
  // An entry's edge key (F7): always `<orgId>:<entry>`, edge has no org-root key.
2791
2812
  if (action.system === 'edge' && action.kind === 'key')
2792
2813
  return 'hosting.entries.uses.edge';
2793
2814
  // An entry's jobs project: always `<orgId>:<entry>`.
2794
- if (action.system === 'jobs' && action.kind === 'project')
2815
+ if (action.system === 'jobs' && (action.kind === 'project' || action.kind === 'boule-key'))
2795
2816
  return 'hosting.entries.uses.jobs';
2796
2817
  // An entry's analytics site key: always `<orgId>:<entry>`.
2797
2818
  if (action.system === 'analytics' && action.kind === 'key') {
2798
2819
  return 'hosting.entries.uses.analytics';
2799
2820
  }
2821
+ // An entry's reporting site key: always `<orgId>:<entry>`.
2822
+ if (action.system === 'reporting' && action.kind === 'key') {
2823
+ return 'hosting.entries.uses.reporting';
2824
+ }
2800
2825
  // An entry's audit ledger registration: always `<orgId>:<entry>`.
2801
2826
  if (action.system === 'audit' && action.kind === 'ledger')
2802
2827
  return 'hosting.entries.uses.audit';
@@ -2839,7 +2864,12 @@ function failedFields(org, detail, completed, serviceIds) {
2839
2864
  // `planAiKey` plans no step for a key minted but never delivered, so no
2840
2865
  // step is left to fail. Without this, the next apply would record the org
2841
2866
  // as converged with no working key.
2842
- if (serviceIds[AI_KEY_UNDELIVERED_SCOPE_KEY] && !serviceIds[`ai:key:${org.id}`])
2867
+ if (serviceIds[AI_KEY_UNDELIVERED_SCOPE_KEY] &&
2868
+ serviceIds[AI_KEY_UNDELIVERED_SCOPE_KEY] !== serviceIds[`ai:key:${org.id}`])
2869
+ failed.add('ai');
2870
+ // Boule's ai key (foundry#1122): minted, never held, so no step is planned.
2871
+ if (serviceIds[AI_BOULE_UNDELIVERED_SCOPE_KEY] &&
2872
+ serviceIds[AI_BOULE_UNDELIVERED_SCOPE_KEY] !== serviceIds[AI_BOULE_KEY_SCOPE_KEY])
2843
2873
  failed.add('ai');
2844
2874
  // The same for an entry's ai key (F6a): minted, never delivered, so no step is planned.
2845
2875
  for (const entry of org.hosting.entries) {
@@ -2858,6 +2888,11 @@ function failedFields(org, detail, completed, serviceIds) {
2858
2888
  failed.add('hosting.entries.uses.files');
2859
2889
  }
2860
2890
  }
2891
+ // Boule's files key (foundry#1121): minted, never held, so no step is planned.
2892
+ if (serviceIds[FILES_BOULE_UNDELIVERED_SCOPE_KEY] &&
2893
+ serviceIds[FILES_BOULE_UNDELIVERED_SCOPE_KEY] !== serviceIds[FILES_BOULE_KEY_SCOPE_KEY]) {
2894
+ failed.add('hosting.entries.uses.files');
2895
+ }
2861
2896
  }
2862
2897
  // The same for an entry's edge key (F7): minted, never delivered is no
2863
2898
  // longer blocked forever on its own (the next apply revokes it and
@@ -1424,6 +1424,8 @@ export interface PlanPlatformOptions {
1424
1424
  };
1425
1425
  /** foundry#1024: why the usage tail Worker is blocked, or absent when it is not. */
1426
1426
  usageTail?: string;
1427
+ /** foundry#1133: why the app-layer canary is blocked (`appCanaryBlockedReason`), or absent when it is not. */
1428
+ appCanary?: string;
1427
1429
  /** Why the organisation's release webhook step cannot run (the token is not held), or absent. */
1428
1430
  githubReleaseHook?: string;
1429
1431
  /**
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { VERSIONS } from '../bootstrap/versions.js';
3
3
  import { APP_ENTRY_KEY } from '../org.js';
4
- import { heldEnvRef } from '../org.js';
4
+ import { entrySettingsEnv, heldEnvRef } from '../org.js';
5
5
  import { VERSION_CHANGE_KIND, } from '../plan.js';
6
+ import { planAppCanary } from './app-canary.js';
6
7
  import { ARCHON_REPO, planArchonBlueGreen } from './blue-green.js';
7
8
  import { buildDriftNotes } from './build-record.js';
8
9
  import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
@@ -958,6 +959,12 @@ export function resolveAppEnv(org, entry, recorded, available = { sharedSecret:
958
959
  else if (typeof declared !== 'string')
959
960
  unresolved.push([name, declared.from]);
960
961
  }
962
+ // #1116: the module settings that go to the app (Files' share links, Email's sender name and reply-to).
963
+ // A name the entry's own `env` declares is refused by the org validator, so these never overwrite one.
964
+ for (const [name, value] of Object.entries(entrySettingsEnv(entry))) {
965
+ values[name] = value;
966
+ marker[name] = value;
967
+ }
961
968
  // F13: the entry's customer-grants credential is held and written by the app step. The marker names it,
962
969
  // never its value, so adding or removing the flag replans the step.
963
970
  if (entry.uses?.customerGrants && available.sharedSecret) {
@@ -1541,6 +1548,10 @@ export function planPlatform(platform, live, options = {}) {
1541
1548
  const usageTail = planUsageTail(platform, recordedIds, blocked.usageTail, edgeApplied);
1542
1549
  if (usageTail.step)
1543
1550
  plannedSteps.push(usageTail.step);
1551
+ // foundry#1133: after edge too; its Worker, route and record all go through it.
1552
+ const appCanary = planAppCanary(platform, recordedIds, blocked.appCanary, edgeApplied);
1553
+ if (appCanary.step)
1554
+ plannedSteps.push(appCanary.step);
1544
1555
  const dnsBlocked = edgeApplied || edge.step
1545
1556
  ? undefined
1546
1557
  : (service) => `the platform's ${service} is blocked, not planned: every DNS record is written through edge, and edge is neither applied nor planned (${platform.edge.enabled
@@ -1813,6 +1824,9 @@ export function planPlatform(platform, live, options = {}) {
1813
1824
  for (const description of usageTail.blocked) {
1814
1825
  unmanaged.push({ description, key: 'usage-tail' });
1815
1826
  }
1827
+ for (const description of appCanary.blocked) {
1828
+ unmanaged.push({ description, key: 'app-canary' });
1829
+ }
1816
1830
  if (releaseHook.blocked) {
1817
1831
  unmanaged.push({ description: releaseHook.blocked, key: 'github-release-hook' });
1818
1832
  }
@@ -25,6 +25,7 @@ import { type PlatformRegistryDeps } from './platform-registry.js';
25
25
  import type { SigninDbBackupPort } from './signin-db-backup.js';
26
26
  import { type PlatformZitadelMailBranding } from './signin-mail.js';
27
27
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
28
+ import { type PlatformAppCanaryDeps } from './app-canary-apply.js';
28
29
  import { type PlatformGithubReleaseHookDeps } from './github-release-hook.js';
29
30
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
30
31
  import { type PlatformUptimeDeps } from './uptime-worker.js';
@@ -343,6 +344,12 @@ export interface FoundryPlatformStepExecutorOptions {
343
344
  * for the same reason as `auth`; without it the step is reported blocked.
344
345
  */
345
346
  usageTail?: PlatformUsageTailDeps;
347
+ /**
348
+ * foundry#1133: dependencies for the app-layer canary step
349
+ * (`cloudflare:worker` keyed {@link PLATFORM_APP_CANARY_KEY}) -- optional
350
+ * for the same reason as `auth`; without it the step is reported blocked.
351
+ */
352
+ appCanary?: PlatformAppCanaryDeps;
346
353
  /**
347
354
  * #604: dependencies for the `cloudflare:access-provider` step -- optional
348
355
  * for the same reason as `auth`; edge and sign-in applied with this unset
@@ -380,6 +387,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
380
387
  private readonly heartbeats?;
381
388
  private readonly uptime?;
382
389
  private readonly usageTail?;
390
+ private readonly appCanary?;
383
391
  private readonly accessProvider?;
384
392
  private readonly oikosIngress?;
385
393
  private readonly githubReleaseHook?;
@@ -448,6 +456,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
448
456
  blocked?: string;
449
457
  };
450
458
  usageTail?: string;
459
+ appCanary?: string;
451
460
  githubReleaseHook?: string;
452
461
  githubReleaseHookReady?: boolean;
453
462
  githubReleaseHookHash?: string;
@@ -45,6 +45,8 @@ import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
45
45
  import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, signinDbDsn, waitHealthy, withCallTimeout, } from './signin-db.js';
46
46
  import { PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY, applySigninMail, signinMailMarker, signinMailName, } from './signin-mail.js';
47
47
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
48
+ import { appCanaryBlockedReason, provisionPlatformAppCanary, } from './app-canary-apply.js';
49
+ import { PLATFORM_APP_CANARY_KEY } from './app-canary.js';
48
50
  import { githubReleaseHookPlanInputs, provisionGithubReleaseHook, } from './github-release-hook.js';
49
51
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
50
52
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
@@ -155,6 +157,7 @@ export class FoundryPlatformStepExecutor {
155
157
  heartbeats;
156
158
  uptime;
157
159
  usageTail;
160
+ appCanary;
158
161
  accessProvider;
159
162
  oikosIngress;
160
163
  githubReleaseHook;
@@ -178,6 +181,7 @@ export class FoundryPlatformStepExecutor {
178
181
  this.heartbeats = options.heartbeats;
179
182
  this.uptime = options.uptime;
180
183
  this.usageTail = options.usageTail;
184
+ this.appCanary = options.appCanary;
181
185
  this.accessProvider = options.accessProvider;
182
186
  this.oikosIngress = options.oikosIngress;
183
187
  this.githubReleaseHook = options.githubReleaseHook;
@@ -623,6 +627,9 @@ export class FoundryPlatformStepExecutor {
623
627
  }
624
628
  await this.uptimeBlocked(platform, recordedIds, blocked, owner);
625
629
  await this.usageTailBlocked(platform, recordedIds, blocked);
630
+ const appCanaryBlocked = await appCanaryBlockedReason(this.appCanary, platform, recordedIds);
631
+ if (appCanaryBlocked)
632
+ blocked.appCanary = appCanaryBlocked;
626
633
  if (platform.archon.enabled && !this.githubReleaseHook) {
627
634
  blocked.githubReleaseHook = GITHUB_RELEASE_HOOK_DEPS_MISSING;
628
635
  }
@@ -1217,6 +1224,14 @@ export class FoundryPlatformStepExecutor {
1217
1224
  }
1218
1225
  return provisionPlatformUsageTail(context, this.usageTail);
1219
1226
  }
1227
+ if (action.system === 'cloudflare' &&
1228
+ action.kind === 'worker' &&
1229
+ action.key === PLATFORM_APP_CANARY_KEY) {
1230
+ if (!this.appCanary) {
1231
+ throw new Error('platform:app-canary needs FoundryPlatformStepExecutor to be constructed with its app-canary dependencies (foundry#1133)');
1232
+ }
1233
+ return provisionPlatformAppCanary(context, this.appCanary);
1234
+ }
1220
1235
  if (action.system === 'coolify' &&
1221
1236
  action.kind === 'app' &&
1222
1237
  action.key === PLATFORM_OIKOS_INGRESS_KEY) {
@@ -4410,10 +4425,19 @@ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
4410
4425
  { name: 'org-changes', path: '/api/orgs/*/changes' },
4411
4426
  { name: 'org-settings', path: '/api/orgs/*/settings' },
4412
4427
  { name: 'org-apps', path: '/api/orgs/*/apps' },
4428
+ // Archon authenticates each image-app request with the company's scoped bearer credential.
4429
+ { name: 'org-image-apps', path: '/api/orgs/*/image-apps' },
4430
+ { name: 'org-image-apps-sub', path: '/api/orgs/*/image-apps/*' },
4413
4431
  { name: 'org-requests', path: '/api/orgs/*/requests' },
4414
4432
  { name: 'github-releases', path: '/api/github/releases' },
4415
4433
  { name: 'github-install-callback', path: '/api/github/install/callback' },
4416
4434
  { name: 'org-github', path: '/api/orgs/*/github' },
4435
+ // archon#808: the company's mail, read by Boule's Email page; Archon checks `orgs:read` itself.
4436
+ { name: 'org-mail', path: '/api/orgs/*/mail' },
4437
+ { name: 'org-mail-sub', path: '/api/orgs/*/mail/*' },
4438
+ // The audit host (foundry#1110): Archon mounts `createAuditHostHandler` here; every route below it checks a
4439
+ // company's bearer key itself. Boule's `AUDIT_HOST_URL` is `https://<archon host>/api/audit`.
4440
+ { name: 'audit-host', path: '/api/audit' },
4417
4441
  ];
4418
4442
  const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
4419
4443
  const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
@@ -5,6 +5,7 @@ import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
5
5
  import { previewsOn } from './coolify-previews.js';
6
6
  import { KOSMOS_MANAGED_ENV_KEYS } from './kosmos.js';
7
7
  import { APP_GONE_KIND, } from './live.js';
8
+ import { REPORTING_MAIL_TOKEN_ENV, REPORTING_MANAGED_ENV_KEYS } from './reporting.js';
8
9
  import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
9
10
  /**
10
11
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
@@ -334,6 +335,28 @@ async function withAnalyticsEnvConfig(client, resource, recorded) {
334
335
  }
335
336
  return { ...resource, config: { ...resource.config, env } };
336
337
  }
338
+ /**
339
+ * The recorded reporting backend's live env (`APP_URL`, `AUTH_ORGANIZATION_ID`, `MAIL_FROM`) as `config.env`,
340
+ * read like {@link withAnalyticsEnvConfig}, and `config.mailTokenHeld`: whether it holds a Lettermint token.
341
+ */
342
+ async function withReportingEnvConfig(client, resource, recorded) {
343
+ if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('reporting:')) {
344
+ return resource;
345
+ }
346
+ const managed = REPORTING_MANAGED_ENV_KEYS;
347
+ const env = {};
348
+ let mailTokenHeld = false;
349
+ for (const e of await client.listEnvs(resource.key)) {
350
+ if (e.is_preview)
351
+ continue;
352
+ if (managed.includes(e.key))
353
+ env[e.key] = e.value;
354
+ // The token is a secret: only whether it is there leaves this reader.
355
+ if (e.key === REPORTING_MAIL_TOKEN_ENV && e.value)
356
+ mailTokenHeld = true;
357
+ }
358
+ return { ...resource, config: { ...resource.config, env, mailTokenHeld } };
359
+ }
337
360
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
338
361
  function toFoundryResources(system, resources) {
339
362
  return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
@@ -369,7 +392,8 @@ export class ProvidersCoolifyReader {
369
392
  const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
370
393
  const withKosmosConfig = await Promise.all(withBouleConfig.map((r) => withKosmosEnvConfig(this.client, r, recorded)));
371
394
  const withAnalyticsConfig = await Promise.all(withKosmosConfig.map((r) => withAnalyticsEnvConfig(this.client, r, recorded)));
372
- return applyRecordedIds(withAnalyticsConfig, recorded);
395
+ const withReportingConfig = await Promise.all(withAnalyticsConfig.map((r) => withReportingEnvConfig(this.client, r, recorded)));
396
+ return applyRecordedIds(withReportingConfig, recorded);
373
397
  }
374
398
  async liveEnvEquals(orgId, appKey, name, value) {
375
399
  const recorded = await this.resolveRecordedIds(orgId);