@wtfalch/foundry 0.66.0 → 0.67.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 (42) hide show
  1. package/dist/catalogue/copy-en.js +5 -0
  2. package/dist/catalogue/integrations.js +5 -0
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +1 -0
  5. package/dist/modules/analytics.d.ts +8 -0
  6. package/dist/modules/analytics.js +28 -0
  7. package/dist/modules/index.js +2 -0
  8. package/dist/org.d.ts +9 -1
  9. package/dist/org.js +4 -1
  10. package/dist/plan/analytics-boule.d.ts +17 -0
  11. package/dist/plan/analytics-boule.js +43 -0
  12. package/dist/plan/analytics.d.ts +58 -0
  13. package/dist/plan/analytics.js +89 -0
  14. package/dist/plan/company-backups.js +2 -0
  15. package/dist/plan/diff.d.ts +2 -0
  16. package/dist/plan/diff.js +86 -0
  17. package/dist/plan/entry-analytics-key.d.ts +35 -0
  18. package/dist/plan/entry-analytics-key.js +134 -0
  19. package/dist/plan/executor.d.ts +36 -0
  20. package/dist/plan/executor.js +290 -1
  21. package/dist/plan/hostnames.js +4 -0
  22. package/dist/plan/index.d.ts +2 -0
  23. package/dist/plan/index.js +1 -0
  24. package/dist/plan/live.d.ts +1 -1
  25. package/dist/plan/locked-hostnames.js +3 -0
  26. package/dist/plan/org-names.d.ts +3 -3
  27. package/dist/plan/org-names.js +3 -0
  28. package/dist/plan/own-limits.d.ts +6 -0
  29. package/dist/plan/own-limits.js +1 -0
  30. package/dist/plan/plan-apply.d.ts +3 -0
  31. package/dist/plan/plan-apply.js +9 -0
  32. package/dist/plan/platform-diff.d.ts +17 -1
  33. package/dist/plan/platform-diff.js +35 -2
  34. package/dist/plan/platform-providers.js +35 -2
  35. package/dist/plan/providers-readers.js +16 -1
  36. package/dist/plan/stage-services.d.ts +3 -1
  37. package/dist/plan/stage-services.js +2 -1
  38. package/dist/plan/uptime-worker.js +53 -3
  39. package/dist/slug.d.ts +1 -1
  40. package/dist/slug.js +5 -2
  41. package/dist/worker-app-config.js +2 -2
  42. package/package.json +1 -1
@@ -77,6 +77,11 @@ const INTEGRATION_COPY = {
77
77
  meters: { tokens: 'AI use' },
78
78
  outputs: { address: 'The address the app calls for AI tasks.' },
79
79
  },
80
+ analytics: {
81
+ name: 'Count visitors',
82
+ summary: 'We count how many people visit the app and which pages they open, for each release.',
83
+ ours: 'Run by us',
84
+ },
80
85
  organisations: {
81
86
  name: 'Sign-in for organisations',
82
87
  summary: 'Companies sign their own people in to the app.',
@@ -76,6 +76,11 @@ const EXTRA = {
76
76
  price: { class: 'usage', meters: [meter('audit', 'entries', 'entries')] },
77
77
  advanced: [],
78
78
  },
79
+ analytics: {
80
+ providers: [ours('analytics')],
81
+ price: { class: 'free', meters: [] },
82
+ advanced: [],
83
+ },
79
84
  organisations: {
80
85
  providers: [ours('organisations')],
81
86
  price: { class: 'free', meters: [] },
package/dist/index.d.ts CHANGED
@@ -63,7 +63,7 @@ export type { FilesHostInput, JobsHostDeps, JobsHostInput, OwnAuthInstance, Plan
63
63
  export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './plan/index.js';
64
64
  export type { PlanOwnSfuOptions, SfuAppDeps, SfuAppInput } from './plan/index.js';
65
65
  export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig, sfuCompose, } from './plan/index.js';
66
- export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, KeysRegistrarPort, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './plan/index.js';
66
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, KeysRegistrarPort, AnalyticsPort, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './plan/index.js';
67
67
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
68
68
  export type { StageProbePort, ReleasePreviewMembers } from './plan/stage-guards.js';
69
69
  export type { StageCopyHostConfig } from './plan/stage-copy-host.js';
@@ -75,6 +75,7 @@ export type { OwnHostnames } from './plan/index.js';
75
75
  export type { EnsureSignInOrgOptions } from './plan/index.js';
76
76
  export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
77
77
  export { HttpJobsSchedulerClient } from './plan/index.js';
78
+ export { httpAnalytics } from './plan/index.js';
78
79
  export { httpKeysRegistrar, keysRegistrarFromConfig, KEYS_PLATFORM_TOKEN_ENV, KEYS_SERVICE_URL_ENV, } from './plan/index.js';
79
80
  export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
80
81
  export { healthTargets, platformHealthTargets } from './plan/index.js';
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ export { NO_DOMAIN_ZONE, entryMachinePaths, ownHostnames, privatePageGateKey, wo
41
41
  export { hostedLabelRefusal } from './hosted-label.js';
42
42
  export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
43
43
  export { HttpJobsSchedulerClient } from './plan/index.js';
44
+ export { httpAnalytics } from './plan/index.js';
44
45
  export { httpKeysRegistrar, keysRegistrarFromConfig, KEYS_PLATFORM_TOKEN_ENV, KEYS_SERVICE_URL_ENV, } from './plan/index.js';
45
46
  export { healthTargets, platformHealthTargets } from './plan/index.js';
46
47
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
@@ -0,0 +1,8 @@
1
+ import type { AppModule } from './types.js';
2
+ /**
3
+ * `uses.analytics` on a Worker app: the app counts its visitors and page views. Boule installs a site key of the
4
+ * company's own analytics backend on each copy of the app (`ANALYTICS_URL`, `ANALYTICS_SITE_KEY`, and the
5
+ * `CF_VERSION_METADATA` binding the app reads its release from); foundry only sets up the backend. One on/off
6
+ * module, no fields. A server app declares `uses.analytics` itself and gets its key from foundry.
7
+ */
8
+ export declare const analytics: AppModule;
@@ -0,0 +1,28 @@
1
+ import { setUse } from './entry-parts.js';
2
+ /**
3
+ * `uses.analytics` on a Worker app: the app counts its visitors and page views. Boule installs a site key of the
4
+ * company's own analytics backend on each copy of the app (`ANALYTICS_URL`, `ANALYTICS_SITE_KEY`, and the
5
+ * `CF_VERSION_METADATA` binding the app reads its release from); foundry only sets up the backend. One on/off
6
+ * module, no fields. A server app declares `uses.analytics` itself and gets its key from foundry.
7
+ */
8
+ export const analytics = {
9
+ view: {
10
+ id: 'analytics',
11
+ name: 'Count visitors',
12
+ line: 'We count how many people visit the app and which pages they open, for each release.',
13
+ category: 'Data',
14
+ icon: 'chart',
15
+ runtimes: ['worker'],
16
+ requires: [],
17
+ availability: 'ready',
18
+ fields: [],
19
+ adds: [
20
+ {
21
+ name: 'Visitor numbers',
22
+ hint: 'Visitors and page views for every release, on the release page. Kept in your own company only.',
23
+ },
24
+ ],
25
+ },
26
+ toEntry: (entry, values) => setUse(entry, 'analytics', values === null ? undefined : true),
27
+ fromEntry: (entry) => (entry.uses?.analytics === true ? {} : null),
28
+ };
@@ -1,5 +1,6 @@
1
1
  import { SECRET_LIKE_NAME, WORKER_APP_ENV_NAME, WORKER_APP_RESERVED_NAMES, } from '../entry-rules.js';
2
2
  import { ai } from './ai.js';
3
+ import { analytics } from './analytics.js';
3
4
  import { assistants } from './assistants.js';
4
5
  import { audit } from './audit.js';
5
6
  import { customerGrants } from './customer-grants.js';
@@ -28,6 +29,7 @@ export const MODULES = [
28
29
  files,
29
30
  email,
30
31
  jobs,
32
+ analytics,
31
33
  ai,
32
34
  organisations,
33
35
  audit,
package/dist/org.d.ts CHANGED
@@ -301,7 +301,7 @@ export type AccessRule = {
301
301
  /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
302
302
  export declare const APP_ENTRY_KEY = "app";
303
303
  /** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
304
- export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
304
+ export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
305
305
  /**
306
306
  * One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
307
307
  * (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
@@ -732,9 +732,17 @@ export interface HostingEntryUses {
732
732
  repo?: {
733
733
  access: 'read' | 'write';
734
734
  };
735
+ /**
736
+ * The app counts its visitors in the company's own analytics backend. A server entry gets its own site key
737
+ * (`ANALYTICS_URL`, `ANALYTICS_SITE_KEY`; `plan/entry-analytics-key.ts`), and its stage another. A Worker
738
+ * app gets its keys from Boule, which installs them per copy, so foundry only plans the backend for it.
739
+ */
740
+ analytics?: true;
735
741
  }
736
742
  /** The env names `uses.edge` writes; `env` may not name them (#317). */
737
743
  export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"];
744
+ /** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
745
+ export declare const ANALYTICS_ENTRY_ENV_NAMES: readonly ["ANALYTICS_URL", "ANALYTICS_SITE_KEY"];
738
746
  /** The env names `uses.mail` writes; `env` may not name them. */
739
747
  export declare const MAIL_ENTRY_ENV_NAMES: readonly ["LETTERMINT_API_TOKEN", "MAIL_FROM"];
740
748
  /** The env names `uses.jobs` writes; `env` may not name them. */
package/dist/org.js CHANGED
@@ -60,6 +60,8 @@ export const FILES_ENTRY_ENV_NAMES = [
60
60
  export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
61
61
  /** The env names `uses.edge` writes; `env` may not name them (#317). */
62
62
  export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
63
+ /** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
64
+ export const ANALYTICS_ENTRY_ENV_NAMES = ['ANALYTICS_URL', 'ANALYTICS_SITE_KEY'];
63
65
  /** The env names `uses.mail` writes; `env` may not name them. */
64
66
  export const MAIL_ENTRY_ENV_NAMES = ['LETTERMINT_API_TOKEN', 'MAIL_FROM'];
65
67
  /** The env names `uses.jobs` writes; `env` may not name them. */
@@ -1275,11 +1277,12 @@ function validateEntryCustomerGrants(entry) {
1275
1277
  }
1276
1278
  return null;
1277
1279
  }
1278
- /** `uses.mail` and `uses.jobs`: `true`, `hosting.enabled`, and no `env` entry that names a value they write. */
1280
+ /** `uses.mail`, `uses.jobs` and `uses.analytics`: `true`, `hosting.enabled`, and no `env` entry that names a value they write. */
1279
1281
  function validateEntryMailJobs(entry, hosting) {
1280
1282
  for (const [use, written, code] of [
1281
1283
  ['mail', MAIL_ENTRY_ENV_NAMES, 'mail'],
1282
1284
  ['jobs', JOBS_ENTRY_ENV_NAMES, 'jobs'],
1285
+ ['analytics', ANALYTICS_ENTRY_ENV_NAMES, 'analytics'],
1283
1286
  ]) {
1284
1287
  const value = entry.uses?.[use];
1285
1288
  if (value === undefined)
@@ -0,0 +1,17 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ /**
4
+ * C2: while the company's Boule runs apps (`org.boule.apps`) and its analytics backend is applied, Boule's env
5
+ * holds `ANALYTICS_URL` and `ANALYTICS_OPERATOR_KEY`. Boule mints a site key for each app copy with the
6
+ * operator key and reads each release's visitors and page views with it. The key is read from the backend's own
7
+ * env and written to Boule's env; it is never held, recorded or planned. Without the backend Boule installs
8
+ * apps without analytics.
9
+ *
10
+ * One `coolify:analytics-boule` step keyed by the org id. `analytics_boule_applied` holds
11
+ * `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
12
+ * apps off takes both names out of Boule's env.
13
+ */
14
+ export declare const ANALYTICS_BOULE_APPLIED_SCOPE_KEY = "analytics_boule_applied";
15
+ export declare const ANALYTICS_BOULE_ENV_NAMES: readonly ["ANALYTICS_URL", "ANALYTICS_OPERATOR_KEY"];
16
+ export declare const analyticsBouleMarker: (org: DeclaredOrg, backendAppId: string) => string;
17
+ export declare function planAnalyticsBoule(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[]): DraftStep[];
@@ -0,0 +1,43 @@
1
+ import { appsOn } from '../org.js';
2
+ import { ANALYTICS_DEPLOYED_SCOPE_KEY, analyticsUrlFor } from './analytics.js';
3
+ /**
4
+ * C2: while the company's Boule runs apps (`org.boule.apps`) and its analytics backend is applied, Boule's env
5
+ * holds `ANALYTICS_URL` and `ANALYTICS_OPERATOR_KEY`. Boule mints a site key for each app copy with the
6
+ * operator key and reads each release's visitors and page views with it. The key is read from the backend's own
7
+ * env and written to Boule's env; it is never held, recorded or planned. Without the backend Boule installs
8
+ * apps without analytics.
9
+ *
10
+ * One `coolify:analytics-boule` step keyed by the org id. `analytics_boule_applied` holds
11
+ * `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
12
+ * apps off takes both names out of Boule's env.
13
+ */
14
+ export const ANALYTICS_BOULE_APPLIED_SCOPE_KEY = 'analytics_boule_applied';
15
+ export const ANALYTICS_BOULE_ENV_NAMES = ['ANALYTICS_URL', 'ANALYTICS_OPERATOR_KEY'];
16
+ export const analyticsBouleMarker = (org, backendAppId) => `${analyticsUrlFor(org)}|${backendAppId}`;
17
+ export function planAnalyticsBoule(org, recordedIds, plannedSteps) {
18
+ const draft = (mode, description) => ({
19
+ action: { system: 'coolify', kind: 'analytics-boule', key: org.id, desired: { mode } },
20
+ step: { description, destroys: mode === 'retire', migrates: false },
21
+ });
22
+ const planned = (kind) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === kind && p.action.key === org.id);
23
+ const bouleKnown = Boolean(recordedIds[`coolify:boule:${org.id}`]) || planned('boule');
24
+ const marker = recordedIds[ANALYTICS_BOULE_APPLIED_SCOPE_KEY];
25
+ if (!appsOn(org)) {
26
+ if (!marker || !recordedIds[`coolify:boule:${org.id}`])
27
+ return [];
28
+ return [
29
+ draft('retire', `take the analytics address and key out of ${org.slug}'s Boule env (the company's Boule runs no apps)`),
30
+ ];
31
+ }
32
+ if (!bouleKnown)
33
+ return [];
34
+ const backendApp = recordedIds[`coolify:analytics:${org.id}`];
35
+ const applied = Boolean(backendApp) && Boolean(recordedIds[ANALYTICS_DEPLOYED_SCOPE_KEY]);
36
+ if (!applied && !planned('analytics'))
37
+ return [];
38
+ if (applied && marker === analyticsBouleMarker(org, backendApp))
39
+ return [];
40
+ return [
41
+ draft('write', `write ANALYTICS_URL and ANALYTICS_OPERATOR_KEY to ${org.slug}'s Boule env, restart Boule`),
42
+ ];
43
+ }
@@ -0,0 +1,58 @@
1
+ import { type DeclaredOrg } from '../org.js';
2
+ /**
3
+ * A company's own analytics backend (https://github.com/wtfalch/analytics): one Coolify app and one database
4
+ * per company, built from that repo's root Dockerfile. It is planned only while something uses it: a hosting
5
+ * entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
6
+ *
7
+ * What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
8
+ * `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
9
+ * sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
10
+ * and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
11
+ * made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
12
+ */
13
+ /** True while some entry declares `uses.analytics`, or Boule runs apps: the backend is wanted. */
14
+ export declare const analyticsWanted: (org: Pick<DeclaredOrg, "boule" | "hosting">) => boolean;
15
+ /** `analytics.<domain>`, or `<slug>-analytics.oikos.host` for a company without a domain. */
16
+ export declare function analyticsHostnameFor(org: Pick<DeclaredOrg, 'domain' | 'slug'>): string;
17
+ /** The public address apps and Boule send to, `ANALYTICS_URL`. */
18
+ export declare const analyticsUrlFor: (org: Pick<DeclaredOrg, "domain" | "slug">) => string;
19
+ /** The backend's database uuid, recorded beside the app's own id. */
20
+ export declare const ANALYTICS_DATABASE_SCOPE_KEY = "coolify_analytics_database";
21
+ /** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
22
+ export declare const ANALYTICS_DEPLOYED_SCOPE_KEY = "coolify_analytics_deployed";
23
+ /** The env name the backend reads its operator key from; also Boule's name for it (C2). */
24
+ export declare const ANALYTICS_OPERATOR_KEY_ENV = "ANALYTICS_OPERATOR_KEY";
25
+ /** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
26
+ export declare const ANALYTICS_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ORGANIZATION_ID"];
27
+ export declare function desiredAnalyticsEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
28
+ env: Record<string, string>;
29
+ missing: string[];
30
+ };
31
+ export declare function analyticsEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
32
+ /** The backend's operator API. Every call carries the company's own base URL and operator key. */
33
+ export interface AnalyticsPort {
34
+ /** Mints a site key; the secret is shown once. The same `name` again replaces the older key. */
35
+ mintSiteKey(input: {
36
+ baseUrl: string;
37
+ operatorKey: string;
38
+ name: string;
39
+ environment: 'production' | 'stage';
40
+ }): Promise<{
41
+ id: string;
42
+ secret: string;
43
+ }>;
44
+ /** Revokes a key. An unknown id is not an error. */
45
+ revokeSiteKey(input: {
46
+ baseUrl: string;
47
+ operatorKey: string;
48
+ keyId: string;
49
+ }): Promise<void>;
50
+ }
51
+ /**
52
+ * The real {@link AnalyticsPort}: `POST <baseUrl>/v1/site-keys` with `{ name, environment }` answers 201
53
+ * `{ id, secret }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. Errors carry the status only, never a
54
+ * body, the key or the secret.
55
+ */
56
+ export declare function httpAnalytics(options?: {
57
+ fetch?: typeof fetch;
58
+ }): AnalyticsPort;
@@ -0,0 +1,89 @@
1
+ import { appsOn } from '../org.js';
2
+ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
+ /**
4
+ * A company's own analytics backend (https://github.com/wtfalch/analytics): one Coolify app and one database
5
+ * per company, built from that repo's root Dockerfile. It is planned only while something uses it: a hosting
6
+ * entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
7
+ *
8
+ * What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
9
+ * `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
10
+ * sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
11
+ * and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
12
+ * made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
13
+ */
14
+ /** True while some entry declares `uses.analytics`, or Boule runs apps: the backend is wanted. */
15
+ export const analyticsWanted = (org) => appsOn(org) || org.hosting.entries.some((e) => e.uses?.analytics === true);
16
+ /** `analytics.<domain>`, or `<slug>-analytics.oikos.host` for a company without a domain. */
17
+ export function analyticsHostnameFor(org) {
18
+ return org.domain ? `analytics.${org.domain}` : `${org.slug}-analytics.oikos.host`;
19
+ }
20
+ /** The public address apps and Boule send to, `ANALYTICS_URL`. */
21
+ export const analyticsUrlFor = (org) => `https://${analyticsHostnameFor(org)}`;
22
+ /** The backend's database uuid, recorded beside the app's own id. */
23
+ export const ANALYTICS_DATABASE_SCOPE_KEY = 'coolify_analytics_database';
24
+ /** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
25
+ export const ANALYTICS_DEPLOYED_SCOPE_KEY = 'coolify_analytics_deployed';
26
+ /** The env name the backend reads its operator key from; also Boule's name for it (C2). */
27
+ export const ANALYTICS_OPERATOR_KEY_ENV = 'ANALYTICS_OPERATOR_KEY';
28
+ /** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
29
+ export const ANALYTICS_MANAGED_ENV_KEYS = ['APP_URL', 'AUTH_ORGANIZATION_ID'];
30
+ export function desiredAnalyticsEnv(org, recorded) {
31
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
32
+ const env = { APP_URL: analyticsUrlFor(org) };
33
+ if (zitadelOrgId)
34
+ env.AUTH_ORGANIZATION_ID = zitadelOrgId;
35
+ return { env, missing: zitadelOrgId ? [] : ['zitadelOrgId'] };
36
+ }
37
+ export function analyticsEnvDrift(desired, live) {
38
+ return Object.entries(desired)
39
+ .filter(([key, value]) => live[key] !== value)
40
+ .map(([key]) => key);
41
+ }
42
+ const CALL_TIMEOUT_MS = 20_000;
43
+ /**
44
+ * The real {@link AnalyticsPort}: `POST <baseUrl>/v1/site-keys` with `{ name, environment }` answers 201
45
+ * `{ id, secret }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. Errors carry the status only, never a
46
+ * body, the key or the secret.
47
+ */
48
+ export function httpAnalytics(options = {}) {
49
+ const fetchImpl = options.fetch ?? fetch;
50
+ const call = async (url, init, what) => {
51
+ try {
52
+ return await fetchImpl(url, {
53
+ ...init,
54
+ redirect: 'error',
55
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
56
+ });
57
+ }
58
+ catch {
59
+ throw new Error(`the analytics backend did not answer when asked to ${what}`);
60
+ }
61
+ };
62
+ return {
63
+ async mintSiteKey({ baseUrl, operatorKey, name, environment }) {
64
+ const response = await call(`${baseUrl}/v1/site-keys`, {
65
+ method: 'POST',
66
+ headers: { authorization: `Bearer ${operatorKey}`, 'content-type': 'application/json' },
67
+ body: JSON.stringify({ name, environment }),
68
+ }, 'mint a site key');
69
+ if (response.status !== 201) {
70
+ throw Object.assign(new Error(`the analytics backend refused to mint a site key (status ${response.status})`), { status: response.status });
71
+ }
72
+ const body = await response.json().catch(() => undefined);
73
+ const fields = typeof body === 'object' && body !== null ? body : {};
74
+ if (typeof fields.id !== 'string' ||
75
+ !fields.id ||
76
+ typeof fields.secret !== 'string' ||
77
+ !fields.secret) {
78
+ throw new Error('the analytics backend answered 201 but sent no key id and secret');
79
+ }
80
+ return { id: fields.id, secret: fields.secret };
81
+ },
82
+ async revokeSiteKey({ baseUrl, operatorKey, keyId }) {
83
+ const response = await call(`${baseUrl}/v1/site-keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${operatorKey}` } }, 'revoke a site key');
84
+ if (response.status !== 204) {
85
+ throw Object.assign(new Error(`the analytics backend refused to revoke a site key (status ${response.status})`), { status: response.status });
86
+ }
87
+ },
88
+ };
89
+ }
@@ -1,4 +1,5 @@
1
1
  import { ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, orgServiceDatabase, } from '../org.js';
2
+ import { ANALYTICS_DATABASE_SCOPE_KEY } from './analytics.js';
2
3
  import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
3
4
  import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
4
5
  import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
@@ -29,6 +30,7 @@ export function companyDatabaseKeys(org) {
29
30
  chat_synapse: 'coolify_chat_synapse_database',
30
31
  chat_mas: 'coolify_chat_mas_database',
31
32
  boule: BOULE_DATABASE_SCOPE_KEY,
33
+ analytics: ANALYTICS_DATABASE_SCOPE_KEY,
32
34
  chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
33
35
  chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
34
36
  postgres: ORG_POSTGRES_SERVER_SCOPE_KEY,
@@ -83,6 +83,8 @@ export interface PlanOrgOptions {
83
83
  workerAppKeysUrl?: string;
84
84
  /** F7: the keys service's registrar address, when the executor has a `KeysRegistrarPort`. */
85
85
  keysRegistrarUrl?: string;
86
+ /** The executor was built with an `analytics` port; without it each `uses.analytics` key is blocked. */
87
+ analyticsPort?: boolean;
86
88
  /** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
87
89
  workerAppDatabase?: boolean;
88
90
  /** True when that port has `deletableTables`; a `workerApp.database.noDelete` is blocked without it. */
package/dist/plan/diff.js CHANGED
@@ -3,6 +3,8 @@ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
3
3
  import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
4
4
  import { entryHasDatabase } from '../worker-app-config.js';
5
5
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, entryClientIdScopeKey, withRecordedMessagingProject, } from './agora.js';
6
+ import { planAnalyticsBoule } from './analytics-boule.js';
7
+ import { analyticsEnvDrift, analyticsWanted, desiredAnalyticsEnv } from './analytics.js';
6
8
  import { planAppsDbRoute } from './apps-db-route.js';
7
9
  import { planAppsZones } from './apps-zones.js';
8
10
  import { bgKey, planBlueGreen, } from './blue-green.js';
@@ -16,6 +18,7 @@ import { DESKTOP_AUTH_HOME, planDesktopAuth } from './desktop-auth.js';
16
18
  import { planDesktopCallbacks } from './desktop-callback.js';
17
19
  import { onPlatformNetwork } from './destinations.js';
18
20
  import { planEntryAiKeys } from './entry-ai-key.js';
21
+ import { planEntryAnalyticsKeys } from './entry-analytics-key.js';
19
22
  import { planEntryAudit } from './entry-audit.js';
20
23
  import { CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsShapeMatches, customerGrantsToDrop, wantedCustomerGrants, } from './entry-customer-grants.js';
21
24
  import { planEdgeReservedLabels, planEdgeSlug, planEntryEdgeKeys } from './entry-edge-key.js';
@@ -723,6 +726,14 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
723
726
  const kosmos = planKosmos(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
724
727
  if (kosmos.step)
725
728
  plannedSteps.push(kosmos.step);
729
+ // The company's analytics backend, only while something uses it (an entry's `uses.analytics`, or Boule's
730
+ // apps); never parked once made. Before the site keys and Boule's env, which need its operator key.
731
+ const analytics = planAnalyticsBackend(org, byKey, claimed, recordedIds, options.otherSlugs ?? []);
732
+ if (analytics.step)
733
+ plannedSteps.push(analytics.step);
734
+ plannedSteps.push(...planAnalyticsBoule(org, recordedIds, plannedSteps));
735
+ const entryAnalyticsKeys = planEntryAnalyticsKeys(org, recordedIds, plannedSteps, options.analyticsPort ?? false);
736
+ plannedSteps.push(...entryAnalyticsKeys.steps);
726
737
  // Boule's, Agora's and kosmos's blue/green: after their own steps, which make the app it copies.
727
738
  plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'last').map(blueGreenDraft()));
728
739
  // Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
@@ -995,6 +1006,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
995
1006
  }
996
1007
  if (kosmos.blocked)
997
1008
  unmanaged.push({ description: kosmos.blocked, key: 'coolify:kosmos' });
1009
+ if (analytics.blocked)
1010
+ unmanaged.push({ description: analytics.blocked, key: 'coolify:analytics' });
1011
+ unmanaged.push(...entryAnalyticsKeys.blocked);
998
1012
  // Same for an ai key minted but never delivered: planning a new mint would
999
1013
  // orphan a second live key.
1000
1014
  if (aiKey.blocked)
@@ -3212,6 +3226,78 @@ function planKosmos(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
3212
3226
  blocked: null,
3213
3227
  };
3214
3228
  }
3229
+ /**
3230
+ * The company's analytics backend: a per-org singleton keyed by the org id under its own `analytics` kind, built
3231
+ * from https://github.com/wtfalch/analytics's root Dockerfile (`executor.ts`'s `coolifyAnalytics`) with a
3232
+ * database of its own. Planned only while `analyticsWanted(org)`; once live it is kept and never parked. A live
3233
+ * app is planned again when its `APP_URL` or `AUTH_ORGANIZATION_ID` is missing or different. It needs no
3234
+ * sign-in client and no issuer; only the company's sign-in organisation id (recorded by an earlier step).
3235
+ */
3236
+ function planAnalyticsBackend(org, byKey, claimed, recordedIds, otherSlugs) {
3237
+ const key = resourceKey('coolify', 'analytics', org.id);
3238
+ const live = byKey.get(key);
3239
+ if (live)
3240
+ claimed.add(key);
3241
+ if (!analyticsWanted(org))
3242
+ return { step: null, blocked: null };
3243
+ const tooLong = [
3244
+ [`${org.slug}-analytics`, 'the analytics host label and app name'],
3245
+ [`${org.slug}_analytics_db_rt`, 'the analytics database role'],
3246
+ ].filter(([name]) => name.length > 63);
3247
+ if (tooLong.length > 0) {
3248
+ return {
3249
+ step: null,
3250
+ blocked: `${org.slug}'s analytics backend is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
3251
+ };
3252
+ }
3253
+ if (otherSlugs.includes(`${org.slug}-analytics`)) {
3254
+ return {
3255
+ step: null,
3256
+ blocked: `${org.slug}'s analytics backend is blocked, not planned: another declared org has the slug '${org.slug}-analytics', which is this backend's hostname`,
3257
+ };
3258
+ }
3259
+ // `coolifyAnalytics` writes nothing without the sign-in organisation, so the step waits for it here.
3260
+ const { env, missing } = desiredAnalyticsEnv(org, recordedIds);
3261
+ if (missing.length > 0) {
3262
+ return {
3263
+ step: null,
3264
+ blocked: `${org.slug}'s analytics backend is blocked, not planned: the company's sign-in organisation is not recorded yet`,
3265
+ };
3266
+ }
3267
+ const action = {
3268
+ system: 'coolify',
3269
+ kind: 'analytics',
3270
+ key: org.id,
3271
+ desired: {},
3272
+ };
3273
+ if (!live) {
3274
+ return {
3275
+ step: {
3276
+ action,
3277
+ step: {
3278
+ description: `provision ${org.slug}'s analytics backend`,
3279
+ destroys: false,
3280
+ migrates: false,
3281
+ },
3282
+ },
3283
+ blocked: null,
3284
+ };
3285
+ }
3286
+ const drift = analyticsEnvDrift(env, (live.config.env ?? {}));
3287
+ if (drift.length === 0)
3288
+ return { step: null, blocked: null };
3289
+ return {
3290
+ step: {
3291
+ action,
3292
+ step: {
3293
+ description: `update ${org.slug}'s analytics backend config (${drift.join(', ')} missing or different)`,
3294
+ destroys: false,
3295
+ migrates: false,
3296
+ },
3297
+ },
3298
+ blocked: null,
3299
+ };
3300
+ }
3215
3301
  /**
3216
3302
  * C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
3217
3303
  * Boule the host can mint it for; the customers directory credential only while `customers.enabled`. Each
@@ -0,0 +1,35 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ /**
4
+ * `uses.analytics` on a server entry: one site key of its own at the company's analytics backend, minted
5
+ * through `POST /v1/site-keys` with the operator key and written as `ANALYTICS_URL` and `ANALYTICS_SITE_KEY` to
6
+ * the entry's env. The backend shows the secret once, so this follows rule 1 of CLAUDE.md "Platform steps":
7
+ *
8
+ * 1. the key's id is recorded as `analytics_key_undelivered.<entry>` and persisted before the env is written;
9
+ * 2. the env write is tried 3 times with the same secret; the id is the main id `analytics:key:<orgId>:<entry>`
10
+ * and the applied marker only after it worked, and the undelivered marker is cleared then;
11
+ * 3. a marker left behind (every try failed) is reported blocked and never minted again.
12
+ *
13
+ * A Worker app entry is skipped: Boule installs its keys per copy. The entry's stage gets its own key through
14
+ * `plan/stage-services.ts` (service `analytics`, environment `stage`), so the two differ. Removing
15
+ * `uses.analytics` or the entry plans the retire form: revoke the key, take both env names away.
16
+ */
17
+ export declare const entryAnalyticsUndeliveredScopeKey: (entryKey: string) => string;
18
+ /** The address the entry's env was last written with; a change rewrites the address only. */
19
+ export declare const entryAnalyticsAppliedScopeKey: (entryKey: string) => string;
20
+ /** The id of a key that was revoked, so a stale main id is not revoked again. */
21
+ export declare const entryAnalyticsRetiredScopeKey: (entryKey: string) => string;
22
+ export declare const ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS = 3;
23
+ /** The name the backend knows the key by; its site derives from it. */
24
+ export declare const entryAnalyticsKeyName: (org: Pick<DeclaredOrg, "slug">, entryKey: string) => string;
25
+ /** The stage copy's key name: the backend reads the `-stage` suffix as the stage environment. */
26
+ export declare const stageAnalyticsKeyName: (org: Pick<DeclaredOrg, "slug">, entryKey: string) => string;
27
+ type Blocked = Array<{
28
+ description: string;
29
+ key: string;
30
+ }>;
31
+ export declare function planEntryAnalyticsKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], analyticsPort: boolean): {
32
+ steps: DraftStep[];
33
+ blocked: Blocked;
34
+ };
35
+ export {};