@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
@@ -0,0 +1,134 @@
1
+ import { analyticsUrlFor } from './analytics.js';
2
+ import { appStepKey } from './platform-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 const entryAnalyticsUndeliveredScopeKey = (entryKey) => `analytics_key_undelivered.${entryKey}`;
18
+ /** The address the entry's env was last written with; a change rewrites the address only. */
19
+ export const entryAnalyticsAppliedScopeKey = (entryKey) => `analytics_key_applied.${entryKey}`;
20
+ /** The id of a key that was revoked, so a stale main id is not revoked again. */
21
+ export const entryAnalyticsRetiredScopeKey = (entryKey) => `analytics_key_retired.${entryKey}`;
22
+ export const ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS = 3;
23
+ /** The name the backend knows the key by; its site derives from it. */
24
+ export const entryAnalyticsKeyName = (org, entryKey) => `${org.slug}-${entryKey}-analytics`;
25
+ /** The stage copy's key name: the backend reads the `-stage` suffix as the stage environment. */
26
+ export const stageAnalyticsKeyName = (org, entryKey) => `${org.slug}-${entryKey}-stage-analytics`;
27
+ const servesKey = (entry) => entry.uses?.analytics === true && !entry.workerApp && entry.live !== false;
28
+ export function planEntryAnalyticsKeys(org, recordedIds, plannedSteps, analyticsPort) {
29
+ const steps = [];
30
+ const blocked = [];
31
+ if (!org.hosting.enabled)
32
+ return { steps, blocked };
33
+ const planned = (system, kind, key) => plannedSteps.some((p) => p.action.system === system && p.action.kind === kind && p.action.key === key);
34
+ const wanted = new Map(org.hosting.entries.filter(servesKey).map((e) => [e.key, e]));
35
+ const mainPrefix = `analytics:key:${org.id}:`;
36
+ // Keys whose entry stopped using analytics or is gone: a delivered key (main id) or one minted and never
37
+ // delivered (undelivered marker), so the retire clears the marker and the entry can mint again later.
38
+ const undeliveredPrefix = entryAnalyticsUndeliveredScopeKey('');
39
+ const retiring = new Set();
40
+ for (const [name, id] of Object.entries(recordedIds)) {
41
+ if (!id)
42
+ continue;
43
+ const prefix = name.startsWith(mainPrefix)
44
+ ? mainPrefix
45
+ : name.startsWith(undeliveredPrefix)
46
+ ? undeliveredPrefix
47
+ : null;
48
+ if (!prefix)
49
+ continue;
50
+ const entryKey = name.slice(prefix.length);
51
+ if (wanted.has(entryKey) ||
52
+ retiring.has(entryKey) ||
53
+ recordedIds[entryAnalyticsRetiredScopeKey(entryKey)] === id) {
54
+ continue;
55
+ }
56
+ retiring.add(entryKey);
57
+ const key = `${mainPrefix}${entryKey}`;
58
+ if (!analyticsPort) {
59
+ blocked.push({
60
+ description: `${org.slug}'s ${entryKey} analytics key is not revoked, not planned: the host supplies no analytics port`,
61
+ key,
62
+ });
63
+ }
64
+ else if (!recordedIds[`coolify:analytics:${org.id}`]) {
65
+ blocked.push({
66
+ description: `${org.slug}'s ${entryKey} analytics key is not revoked, not planned: the analytics backend is not recorded`,
67
+ key,
68
+ });
69
+ }
70
+ else {
71
+ steps.push({
72
+ action: {
73
+ system: 'analytics',
74
+ kind: 'key',
75
+ key: `${org.id}:${entryKey}`,
76
+ desired: { mode: 'retire' },
77
+ },
78
+ step: {
79
+ description: `revoke ${org.slug}'s ${entryKey} analytics key and take ANALYTICS_* out of its env: the entry no longer uses analytics`,
80
+ destroys: true,
81
+ migrates: false,
82
+ },
83
+ });
84
+ }
85
+ }
86
+ for (const entry of wanted.values()) {
87
+ const stepKey = appStepKey(org, entry.key);
88
+ const key = `analytics:key:${stepKey}`;
89
+ const at = `${org.slug}'s ${entry.key} analytics key`;
90
+ const block = (why) => blocked.push({ description: `${at} is blocked: ${why}`, key });
91
+ const url = analyticsUrlFor(org);
92
+ const undelivered = recordedIds[entryAnalyticsUndeliveredScopeKey(entry.key)];
93
+ if (undelivered) {
94
+ block(`key ${undelivered} was minted but never written to the app, and the backend shows a key once; remove the recorded id ${entryAnalyticsUndeliveredScopeKey(entry.key)} to mint a new key, which replaces that one`);
95
+ continue;
96
+ }
97
+ const live = recordedIds[key] &&
98
+ recordedIds[entryAnalyticsRetiredScopeKey(entry.key)] !== recordedIds[key];
99
+ if (live && recordedIds[entryAnalyticsAppliedScopeKey(entry.key)] === url)
100
+ continue;
101
+ if (!analyticsPort) {
102
+ block('the host supplies no analytics port');
103
+ continue;
104
+ }
105
+ if (!recordedIds[`coolify:analytics:${org.id}`] && !planned('coolify', 'analytics', org.id)) {
106
+ block("the company's analytics backend is not recorded or planned yet");
107
+ continue;
108
+ }
109
+ if (!recordedIds[`coolify:app:${stepKey}`] && !planned('coolify', 'app', stepKey)) {
110
+ block("the entry's app is not recorded yet");
111
+ continue;
112
+ }
113
+ steps.push({
114
+ action: {
115
+ system: 'analytics',
116
+ kind: 'key',
117
+ key: stepKey,
118
+ desired: {
119
+ mode: live ? 'url' : 'mint',
120
+ name: entryAnalyticsKeyName(org, entry.key),
121
+ analyticsUrl: url,
122
+ },
123
+ },
124
+ step: {
125
+ description: live
126
+ ? `write the analytics address to ${org.slug}'s ${entry.key} env`
127
+ : `mint ${at}, write ANALYTICS_URL and ANALYTICS_SITE_KEY to its env, restart the app`,
128
+ destroys: false,
129
+ migrates: false,
130
+ },
131
+ });
132
+ }
133
+ return { steps, blocked };
134
+ }
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import { type CallsSfuUrls } from '../calls-sfu.js';
3
3
  import type { DeclaredOrg, HostingBuildPack, OrgId } from '../org.js';
4
4
  import type { DeclaredOrgStore } from '../store.js';
5
+ import { type AnalyticsPort } from './analytics.js';
5
6
  import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
6
7
  import { type BouleOwnerSeed } from './boule.js';
7
8
  import { type BrokerPort } from './broker-entries.js';
@@ -1841,6 +1842,8 @@ export interface FoundryStepExecutorPorts {
1841
1842
  workerAppKeys?: KeysCustodyPort;
1842
1843
  /** F7: the keys service's per-company registrar. Without it the plan reports Boule's registrar key blocked (the host also sets `FoundryPlanApplyOptions.keysRegistrarUrl`). */
1843
1844
  keysRegistrar?: KeysRegistrarPort;
1845
+ /** The company's analytics backend operator API: mints and revokes site keys. Without it the plan reports each `uses.analytics` key blocked (the host also sets `FoundryPlanApplyOptions.analyticsPort`). */
1846
+ analytics?: AnalyticsPort;
1844
1847
  /**
1845
1848
  * C3: mints the credential an org's Boule reaches Archon with (org-scoped; `orgs:request` and
1846
1849
  * `orgs:read` for that org). Archon implements it. Every call rotates the credential (the old secret
@@ -2020,6 +2023,10 @@ export declare const BOULE_PORT = 3000;
2020
2023
  export declare const KOSMOS_REPO = "wtfalch/kosmos";
2021
2024
  export declare const KOSMOS_APP_DOCKERFILE = "/Dockerfile";
2022
2025
  export declare const KOSMOS_PORT = 3000;
2026
+ /** The company's analytics backend (https://github.com/wtfalch/analytics): root Dockerfile, serves on 3000, migrates in its own start script. */
2027
+ export declare const ANALYTICS_REPO = "wtfalch/analytics";
2028
+ export declare const ANALYTICS_APP_DOCKERFILE = "/Dockerfile";
2029
+ export declare const ANALYTICS_PORT = 3000;
2023
2030
  export declare const LOG_DIR = "/app/logs";
2024
2031
  /** The company's Coolify destination (its Docker network `co-<slug>`), recorded when its first resource is created. */
2025
2032
  export declare const COOLIFY_DESTINATION_SCOPE_KEY = "coolify_destination";
@@ -3425,6 +3432,35 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3425
3432
  * shape {@link coolifyAgora}'s own doc comment explains.
3426
3433
  */
3427
3434
  private coolifyBoule;
3435
+ /**
3436
+ * `plan/diff.ts`'s `planAnalyticsBackend`: the company's analytics backend as one more Coolify application
3437
+ * from https://github.com/wtfalch/analytics's root Dockerfile ({@link ANALYTICS_REPO}), with a database of its
3438
+ * own, shaped like {@link coolifyKosmos}. Its start script migrates as the owner role and then serves as the
3439
+ * runtime role, so `pushDatabaseUrl` gives it both URLs. It needs no sign-in client: it reads only
3440
+ * `AUTH_ORGANIZATION_ID` (the tenant id derives from it), `APP_URL` and the operator key.
3441
+ *
3442
+ * `ANALYTICS_OPERATOR_KEY` is made once here (`mintIfAbsent`) and written only to this app's env. No outside
3443
+ * service shows it once, so no undelivered marker is needed: a failed write leaves nothing live to mint twice.
3444
+ * Steps that call the backend's operator API read it back from this env.
3445
+ *
3446
+ * A brand new app is deployed once ({@link ANALYTICS_DEPLOYED_SCOPE_KEY}), as for Boule (#504).
3447
+ */
3448
+ private coolifyAnalytics;
3449
+ /** The backend's operator key, read from its own env. Fixed text when the backend or the key is not there. */
3450
+ private analyticsOperatorKey;
3451
+ /**
3452
+ * C2 `coolify:analytics-boule` (`plan/analytics-boule.ts`): the backend's address and operator key into
3453
+ * Boule's env, then Boule restarts. `analytics_boule_applied` is recorded last. Retire takes both names away.
3454
+ */
3455
+ private analyticsBoule;
3456
+ /**
3457
+ * `plan/entry-analytics-key.ts`: one server entry's site key (CLAUDE.md "Platform steps" rule 1). Mint: the
3458
+ * key's id is recorded as undelivered and persisted before anything is written; the env write is tried 3
3459
+ * times with the same secret; the applied address and the cleared marker come last; every try failing leaves
3460
+ * the marker, and the error names the id only. `url` rewrites the address. Retire revokes the key and the
3461
+ * env names. The operator key is read from the backend's env and never recorded.
3462
+ */
3463
+ private analyticsEntryKey;
3428
3464
  /**
3429
3465
  * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
3430
3466
  * (https://github.com/wtfalch/kosmos) as one more Coolify application, from
@@ -9,6 +9,8 @@ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
9
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';
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 { 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';
12
14
  import { APPS_DATABASE_ADMIN_URL_ENV, applyAppsDbRoute, retireAppsDbRoute, } from './apps-db-route.js';
13
15
  import { APPS_ZONES_APPLIED_SCOPE_KEY, applyAppsZones, retireAppsZones } from './apps-zones.js';
14
16
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, applyWorkspaceServer, } from './blue-green-apply.js';
@@ -38,6 +40,7 @@ import { createEdgeRateLimits, } from './edge-ratelimits.js';
38
40
  import { createEdgeTunnels } from './edge-tunnels.js';
39
41
  import { createEdgeZones } from './edge-zones.js';
40
42
  import { AI_ENTRY_KEY_DAYS, AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
43
+ import { ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS, entryAnalyticsAppliedScopeKey, entryAnalyticsRetiredScopeKey, entryAnalyticsUndeliveredScopeKey, stageAnalyticsKeyName, } from './entry-analytics-key.js';
41
44
  import { applyEntryAudit } from './entry-audit.js';
42
45
  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';
43
46
  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';
@@ -404,6 +407,10 @@ export const BOULE_PORT = 3000;
404
407
  export const KOSMOS_REPO = 'wtfalch/kosmos';
405
408
  export const KOSMOS_APP_DOCKERFILE = '/Dockerfile';
406
409
  export const KOSMOS_PORT = 3000;
410
+ /** The company's analytics backend (https://github.com/wtfalch/analytics): root Dockerfile, serves on 3000, migrates in its own start script. */
411
+ export const ANALYTICS_REPO = 'wtfalch/analytics';
412
+ export const ANALYTICS_APP_DOCKERFILE = '/Dockerfile';
413
+ export const ANALYTICS_PORT = 3000;
407
414
  /**
408
415
  * Every app foundry deploys keeps its own logs across a redeploy, so a
409
416
  * database outage does not also erase the only other copy of the evidence
@@ -969,6 +976,8 @@ function limitProfileOf(action) {
969
976
  return LIMITS.boule;
970
977
  case 'coolify:kosmos':
971
978
  return LIMITS.kosmos;
979
+ case 'coolify:analytics':
980
+ return LIMITS.analytics;
972
981
  case 'coolify:broker-registrar':
973
982
  return LIMITS.brokerEntry;
974
983
  case 'coolify:keys-registrar':
@@ -1404,6 +1413,12 @@ export class FoundryStepExecutor {
1404
1413
  return this.bouleBrokerEntry(org, context);
1405
1414
  case 'coolify:kosmos':
1406
1415
  return this.coolifyKosmos(action, org, context);
1416
+ case 'coolify:analytics':
1417
+ return this.coolifyAnalytics(action, org, context);
1418
+ case 'coolify:analytics-boule':
1419
+ return this.analyticsBoule(action, org, context);
1420
+ case 'analytics:key':
1421
+ return this.analyticsEntryKey(action, org, context);
1407
1422
  case 'coolify:broker-registrar':
1408
1423
  return this.brokerRegistrar(org, context);
1409
1424
  case 'coolify:keys-registrar':
@@ -3831,8 +3846,34 @@ export class FoundryStepExecutor {
3831
3846
  : { id: registered.id, name: projectName };
3832
3847
  },
3833
3848
  };
3849
+ const analytics = {
3850
+ held: held('analytics'),
3851
+ available: async () => canHold &&
3852
+ Boolean(this.ports.analytics) &&
3853
+ Boolean(context.recordedIds()[`coolify:analytics:${org.id}`]) &&
3854
+ entry?.uses?.analytics === true,
3855
+ create: async () => {
3856
+ const port = this.ports.analytics;
3857
+ if (!port)
3858
+ throw new Error('no analytics port');
3859
+ const operatorKey = await this.analyticsOperatorKey(org, context);
3860
+ const name = stageAnalyticsKeyName(org, subject.id);
3861
+ const minted = await port.mintSiteKey({
3862
+ baseUrl: analyticsUrlFor(org),
3863
+ operatorKey,
3864
+ name,
3865
+ environment: 'stage',
3866
+ });
3867
+ return {
3868
+ id: minted.id,
3869
+ name,
3870
+ keyIds: [minted.id],
3871
+ secrets: [{ env: 'ANALYTICS_SITE_KEY', value: minted.secret }],
3872
+ };
3873
+ },
3874
+ };
3834
3875
  const prodJobsProjectId = context.recordedIds()[`jobs:registration:${org.id}`];
3835
- return { files, ai, jobs, ...(prodJobsProjectId ? { prodJobsProjectId } : {}) };
3876
+ return { files, ai, jobs, analytics, ...(prodJobsProjectId ? { prodJobsProjectId } : {}) };
3836
3877
  }
3837
3878
  /**
3838
3879
  * Stage's own sign-in (`plan/stage-signin.ts`): a client named `<production client>-stage` on production's
@@ -7859,6 +7900,254 @@ export class FoundryStepExecutor {
7859
7900
  this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
7860
7901
  return { resourceId: app.appId };
7861
7902
  }
7903
+ // ---- analytics backend ----------------------------------------------------
7904
+ /**
7905
+ * `plan/diff.ts`'s `planAnalyticsBackend`: the company's analytics backend as one more Coolify application
7906
+ * from https://github.com/wtfalch/analytics's root Dockerfile ({@link ANALYTICS_REPO}), with a database of its
7907
+ * own, shaped like {@link coolifyKosmos}. Its start script migrates as the owner role and then serves as the
7908
+ * runtime role, so `pushDatabaseUrl` gives it both URLs. It needs no sign-in client: it reads only
7909
+ * `AUTH_ORGANIZATION_ID` (the tenant id derives from it), `APP_URL` and the operator key.
7910
+ *
7911
+ * `ANALYTICS_OPERATOR_KEY` is made once here (`mintIfAbsent`) and written only to this app's env. No outside
7912
+ * service shows it once, so no undelivered marker is needed: a failed write leaves nothing live to mint twice.
7913
+ * Steps that call the backend's operator API read it back from this env.
7914
+ *
7915
+ * A brand new app is deployed once ({@link ANALYTICS_DEPLOYED_SCOPE_KEY}), as for Boule (#504).
7916
+ */
7917
+ async coolifyAnalytics(_action, org, context) {
7918
+ const { env, missing } = desiredAnalyticsEnv(org, context.recordedIds());
7919
+ if (missing.length > 0) {
7920
+ throw new Error(`the analytics backend for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
7921
+ }
7922
+ const projectUuid = await this.ensureCoolifyProject(org, context);
7923
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
7924
+ const name = `${org.slug}-analytics`;
7925
+ const dbName = `${org.slug}-analytics-db`.replace(/-/g, '_');
7926
+ const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-analytics-db`, dbName, ANALYTICS_DATABASE_SCOPE_KEY);
7927
+ context.recordScopeId(ANALYTICS_DATABASE_SCOPE_KEY, dbId);
7928
+ const server = await this.resolveServer(org, context);
7929
+ const githubApp = await this.ports.coolify.requireGithubApp();
7930
+ const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
7931
+ is_preview_deployments_enabled: false,
7932
+ project_uuid: projectUuid,
7933
+ server_uuid: server.uuid,
7934
+ environment_name: environment.name,
7935
+ environment_uuid: environment.uuid,
7936
+ github_app_uuid: githubApp.uuid,
7937
+ git_repository: ANALYTICS_REPO,
7938
+ git_branch: 'main',
7939
+ build_pack: 'dockerfile',
7940
+ name,
7941
+ domains: coolifyTunnelDomains([analyticsHostnameFor(org)]),
7942
+ dockerfile_location: ANALYTICS_APP_DOCKERFILE,
7943
+ }));
7944
+ await this.ensureLogVolume(app.appId, org, app.existed);
7945
+ await this.ports.coolify.updateApplication(app.appId, {
7946
+ ...healthCheckSettings('/api/health', ANALYTICS_PORT),
7947
+ ...(await this.routedLabels(name, analyticsHostnameFor(org), ANALYTICS_PORT)),
7948
+ });
7949
+ const liveEnv = Object.fromEntries((await this.ports.coolify.listEnvs(app.appId))
7950
+ .filter((e) => !e.is_preview)
7951
+ .map((e) => [e.key, e.value]));
7952
+ const envChanged = analyticsEnvDrift(env, liveEnv).length > 0;
7953
+ const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
7954
+ const operatorKey = await this.mintIfAbsent(app.appId, ANALYTICS_OPERATOR_KEY_ENV, 32);
7955
+ if (operatorKey) {
7956
+ this.handledSecrets.add(operatorKey);
7957
+ envs.push({ key: ANALYTICS_OPERATOR_KEY_ENV, value: operatorKey });
7958
+ }
7959
+ try {
7960
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
7961
+ }
7962
+ catch (error) {
7963
+ if (!operatorKey)
7964
+ throw error;
7965
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), [operatorKey]));
7966
+ }
7967
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
7968
+ if (!dbPush.pushed) {
7969
+ throw new Error(`the analytics backend for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
7970
+ }
7971
+ if (!context.recordedIds()[ANALYTICS_DEPLOYED_SCOPE_KEY]) {
7972
+ const runtime = this.ports.runtime ?? {};
7973
+ const built = await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), analyticsHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:analytics', `${org.slug}'s analytics backend stays at its previous deploy`);
7974
+ await recordBuiltCommit(this.ports.coolify, { appId: app.appId, deploymentUuid: built.deploymentUuid, name: 'analytics' }, (key, value) => context.recordScopeId(key, value));
7975
+ context.recordScopeId(ANALYTICS_DEPLOYED_SCOPE_KEY, '1');
7976
+ }
7977
+ else if (app.existed && (envChanged || operatorKey !== null || dbPush.changed)) {
7978
+ await this.ports.coolify.restart(app.appId);
7979
+ }
7980
+ return { resourceId: app.appId };
7981
+ }
7982
+ /** The backend's operator key, read from its own env. Fixed text when the backend or the key is not there. */
7983
+ async analyticsOperatorKey(org, context) {
7984
+ const appId = context.recordedIds()[`coolify:analytics:${org.id}`];
7985
+ if (!appId)
7986
+ throw new Error(`the analytics backend for ${org.slug} is not recorded yet`);
7987
+ const rows = await this.ports.coolify.listEnvs(appId);
7988
+ const key = rows.find((e) => e.key === ANALYTICS_OPERATOR_KEY_ENV && !e.is_preview)?.value;
7989
+ if (!key)
7990
+ throw new Error(`the analytics backend for ${org.slug} has no operator key yet`);
7991
+ this.handledSecrets.add(key);
7992
+ return key;
7993
+ }
7994
+ /**
7995
+ * C2 `coolify:analytics-boule` (`plan/analytics-boule.ts`): the backend's address and operator key into
7996
+ * Boule's env, then Boule restarts. `analytics_boule_applied` is recorded last. Retire takes both names away.
7997
+ */
7998
+ async analyticsBoule(action, org, context) {
7999
+ const at = `coolify:analytics-boule for ${org.slug}`;
8000
+ const recorded = context.recordedIds();
8001
+ const bouleId = recorded[`coolify:boule:${org.id}`];
8002
+ if (!bouleId)
8003
+ throw new Error(`${at}: Boule is not recorded yet`);
8004
+ if (action.desired.mode === 'retire') {
8005
+ const rows = await this.ports.coolify.listEnvs(bouleId);
8006
+ for (const name of ANALYTICS_BOULE_ENV_NAMES) {
8007
+ const row = rows.find((e) => e.key === name && !e.is_preview);
8008
+ if (row?.uuid) {
8009
+ try {
8010
+ await this.ports.coolify.deleteEnv(bouleId, row.uuid);
8011
+ }
8012
+ catch (error) {
8013
+ if (!isNotFoundError(error))
8014
+ throw error;
8015
+ }
8016
+ }
8017
+ }
8018
+ await this.ports.coolify.restart(bouleId);
8019
+ context.recordScopeId(ANALYTICS_BOULE_APPLIED_SCOPE_KEY, '');
8020
+ return {};
8021
+ }
8022
+ if (!appsOn(org))
8023
+ throw new Error(`${at}: the company's Boule runs no apps (org.boule.apps)`);
8024
+ const backendId = recorded[`coolify:analytics:${org.id}`];
8025
+ if (!backendId)
8026
+ throw new Error(`${at}: the analytics backend is not recorded yet`);
8027
+ const operatorKey = await this.analyticsOperatorKey(org, context);
8028
+ try {
8029
+ await this.ports.coolify.bulkUpsertEnvs(bouleId, [
8030
+ { key: 'ANALYTICS_URL', value: analyticsUrlFor(org), literal: true },
8031
+ { key: ANALYTICS_OPERATOR_KEY_ENV, value: operatorKey, literal: true },
8032
+ ]);
8033
+ await this.ports.coolify.restart(bouleId);
8034
+ }
8035
+ catch (error) {
8036
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), [operatorKey]));
8037
+ }
8038
+ context.recordScopeId(ANALYTICS_BOULE_APPLIED_SCOPE_KEY, analyticsBouleMarker(org, backendId));
8039
+ return {};
8040
+ }
8041
+ /**
8042
+ * `plan/entry-analytics-key.ts`: one server entry's site key (CLAUDE.md "Platform steps" rule 1). Mint: the
8043
+ * key's id is recorded as undelivered and persisted before anything is written; the env write is tried 3
8044
+ * times with the same secret; the applied address and the cleared marker come last; every try failing leaves
8045
+ * the marker, and the error names the id only. `url` rewrites the address. Retire revokes the key and the
8046
+ * env names. The operator key is read from the backend's env and never recorded.
8047
+ */
8048
+ async analyticsEntryKey(action, org, context) {
8049
+ const entryKey = action.key.slice(org.id.length + 1);
8050
+ const at = `analytics:key for ${org.slug} entry '${entryKey}'`;
8051
+ const analytics = this.ports.analytics;
8052
+ if (!analytics)
8053
+ throw new Error(`${at}: this executor was built without an analytics port`);
8054
+ const desired = action.desired;
8055
+ const recorded = context.recordedIds();
8056
+ const appId = recorded[`coolify:app:${action.key}`];
8057
+ const baseUrl = analyticsUrlFor(org);
8058
+ const tolerate = async (call, gone) => {
8059
+ try {
8060
+ return await call();
8061
+ }
8062
+ catch (error) {
8063
+ if (!isNotFoundError(error))
8064
+ throw error;
8065
+ return gone;
8066
+ }
8067
+ };
8068
+ if (desired.mode === 'retire') {
8069
+ const mainId = recorded[`analytics:key:${action.key}`];
8070
+ const undelivered = recorded[entryAnalyticsUndeliveredScopeKey(entryKey)];
8071
+ const ids = [...new Set([mainId, undelivered].filter((id) => Boolean(id)))];
8072
+ if (ids.length > 0) {
8073
+ const operatorKey = await this.analyticsOperatorKey(org, context);
8074
+ for (const keyId of ids) {
8075
+ await tolerate(() => analytics.revokeSiteKey({ baseUrl, operatorKey, keyId }), undefined);
8076
+ }
8077
+ }
8078
+ if (appId) {
8079
+ const rows = await tolerate(() => this.ports.coolify.listEnvs(appId), []);
8080
+ let removed = false;
8081
+ for (const name of ['ANALYTICS_URL', 'ANALYTICS_SITE_KEY']) {
8082
+ const row = rows.find((e) => e.key === name && !e.is_preview);
8083
+ if (row?.uuid) {
8084
+ const uuid = row.uuid;
8085
+ await tolerate(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
8086
+ removed = true;
8087
+ }
8088
+ }
8089
+ if (removed)
8090
+ await tolerate(() => this.ports.coolify.restart(appId), undefined);
8091
+ }
8092
+ context.recordScopeId(entryAnalyticsUndeliveredScopeKey(entryKey), '');
8093
+ context.recordScopeId(entryAnalyticsAppliedScopeKey(entryKey), '');
8094
+ if (mainId ?? undelivered) {
8095
+ context.recordScopeId(entryAnalyticsRetiredScopeKey(entryKey), (mainId ?? undelivered));
8096
+ }
8097
+ return {};
8098
+ }
8099
+ if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.analytics)) {
8100
+ throw new Error(`${at}: the entry no longer declares uses.analytics`);
8101
+ }
8102
+ if (!appId)
8103
+ throw new Error(`${at}: the entry's app is not recorded yet`);
8104
+ if (desired.mode === 'url') {
8105
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
8106
+ { key: 'ANALYTICS_URL', value: baseUrl, literal: true },
8107
+ ]);
8108
+ await this.ports.coolify.restart(appId);
8109
+ context.recordScopeId(entryAnalyticsAppliedScopeKey(entryKey), baseUrl);
8110
+ return {};
8111
+ }
8112
+ if (recorded[entryAnalyticsUndeliveredScopeKey(entryKey)]) {
8113
+ throw new Error(`${at}: a key was minted and never delivered; it is not minted again`);
8114
+ }
8115
+ const operatorKey = await this.analyticsOperatorKey(org, context);
8116
+ let minted;
8117
+ try {
8118
+ minted = await analytics.mintSiteKey({
8119
+ baseUrl,
8120
+ operatorKey,
8121
+ name: desired.name,
8122
+ environment: 'production',
8123
+ });
8124
+ }
8125
+ catch (error) {
8126
+ const status = error?.status;
8127
+ throw new Error(`${at}: the backend did not mint a key${typeof status === 'number' ? ` (status ${status})` : ''}`);
8128
+ }
8129
+ this.handledSecrets.add(minted.secret);
8130
+ context.recordScopeId(entryAnalyticsUndeliveredScopeKey(entryKey), minted.id);
8131
+ await context.persistScopeIds();
8132
+ const rows = [
8133
+ { key: 'ANALYTICS_URL', value: baseUrl, literal: true },
8134
+ { key: 'ANALYTICS_SITE_KEY', value: minted.secret, literal: true },
8135
+ ];
8136
+ let lastError;
8137
+ for (let attempt = 1; attempt <= ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS; attempt++) {
8138
+ try {
8139
+ await this.ports.coolify.bulkUpsertEnvs(appId, rows);
8140
+ await this.ports.coolify.restart(appId);
8141
+ context.recordScopeId(entryAnalyticsAppliedScopeKey(entryKey), baseUrl);
8142
+ context.recordScopeId(entryAnalyticsUndeliveredScopeKey(entryKey), '');
8143
+ return { resourceId: minted.id };
8144
+ }
8145
+ catch (error) {
8146
+ lastError = error;
8147
+ }
8148
+ }
8149
+ 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'})`);
8150
+ }
7862
8151
  // ---- kosmos (foundry#949) --------------------------------------------------
7863
8152
  /**
7864
8153
  * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
@@ -2,6 +2,7 @@ import { INBOX_HOST_PREFIX, WORKSPACE_HOST_PREFIX } from '../hosted-label.js';
2
2
  import { APPS_DOMAIN, isMainEntry, mainEntry } from '../org.js';
3
3
  import { workerAppHostnameOf } from '../worker-app-config.js';
4
4
  import { agoraHostnameFor } from './agora.js';
5
+ import { analyticsHostnameFor, analyticsWanted } from './analytics.js';
5
6
  import { bouleHostnameFor } from './boule.js';
6
7
  import { workspaceWorkerParent } from './workspace-worker.js';
7
8
  /**
@@ -154,6 +155,9 @@ export function tunnelIngressHostnames(org, loginHost = null) {
154
155
  hostnames.push(agoraHostnameFor(org));
155
156
  // #161: and Boule moves onto `boule.<domain>` the same way.
156
157
  hostnames.push(bouleHostnameFor(org));
158
+ // The analytics backend's route, only while something uses it.
159
+ if (analyticsWanted(org))
160
+ hostnames.push(analyticsHostnameFor(org));
157
161
  }
158
162
  const share = filesShareHostname(org);
159
163
  if (share)
@@ -1,4 +1,6 @@
1
1
  export type { KeysRegistrarPort } from './keys-registrar.js';
2
+ export type { AnalyticsPort } from './analytics.js';
3
+ export { httpAnalytics } from './analytics.js';
2
4
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, } from './live.js';
3
5
  export { readLiveResources, readLivePlatformResources } from './live.js';
4
6
  export type { StepAction, PlannedStep, PlanDetail, OrgPostgresPlan } from './diff.js';
@@ -1,3 +1,4 @@
1
+ export { httpAnalytics } from './analytics.js';
1
2
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
3
  export { computePlanDetail, toPlan } from './diff.js';
3
4
  export { stepSystemOf } from './step-system.js';
@@ -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';
45
+ system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats' | 'github' | 'audit' | 'analytics';
46
46
  kind: string;
47
47
  key: string;
48
48
  config: Record<string, unknown>;
@@ -1,6 +1,7 @@
1
1
  import { kosmosOn, ownsServices } from '../org.js';
2
2
  import { OWN_SERVICE_LABELS } from '../service-labels.js';
3
3
  import { agoraHostnameFor } from './agora.js';
4
+ import { analyticsHostnameFor } from './analytics.js';
4
5
  import { bouleHostnameFor } from './boule.js';
5
6
  import { cmsStudioHostnameFor, siteHostnameFor } from './hostnames.js';
6
7
  import { kosmosHostnameFor } from './kosmos.js';
@@ -32,6 +33,8 @@ export function lockedHostnames(org, platformHostnames) {
32
33
  }
33
34
  locked.add(agoraHostnameFor(org));
34
35
  locked.add(bouleHostnameFor(org));
36
+ // Reserved always, planned only while something uses analytics (`analyticsWanted`).
37
+ locked.add(analyticsHostnameFor(org));
35
38
  if (kosmosOn(org))
36
39
  locked.add(kosmosHostnameFor(org));
37
40
  if (org.site.enabled)
@@ -6,13 +6,13 @@ export type OrgNameSource = 'own' | 'hosting';
6
6
  * hosting entries: Agora, Boule, the tunnel and its connector, and chat,
7
7
  * calls and the cms site while they are on.
8
8
  */
9
- export declare function companyServiceNames(org: Pick<DeclaredOrg, 'slug' | 'chat' | 'calls' | 'site' | 'kosmos'>): string[];
9
+ export declare function companyServiceNames(org: Pick<DeclaredOrg, 'slug' | 'chat' | 'calls' | 'site' | 'kosmos' | 'boule' | 'hosting'>): string[];
10
10
  /**
11
11
  * Every container name, network alias, database, tunnel and bucket name that
12
12
  * foundry derives for an org: its own-service names when it owns services,
13
13
  * and its hosting-entry and database names.
14
14
  */
15
- export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos'>): {
15
+ export declare function orgDerivedNames(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos' | 'boule'>): {
16
16
  name: string;
17
17
  source: OrgNameSource;
18
18
  }[];
@@ -26,5 +26,5 @@ export interface NameClash {
26
26
  source: OrgNameSource;
27
27
  }
28
28
  /** Every name `org` derives that another org or the platform also derives. */
29
- export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos'>, others: readonly Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos'>[]): NameClash[];
29
+ export declare function findNameClashes(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos' | 'boule'>, others: readonly Pick<DeclaredOrg, 'slug' | 'domain' | 'services' | 'hosting' | 'chat' | 'calls' | 'site' | 'kosmos' | 'boule'>[]): NameClash[];
30
30
  export declare function nameClashMessage(slug: string, clash: NameClash): string;
@@ -1,5 +1,6 @@
1
1
  import { kosmosOn, ownsServices } from '../org.js';
2
2
  import { entryHasDatabase } from '../worker-app-config.js';
3
+ import { analyticsWanted } from './analytics.js';
3
4
  import { chatInternalNames, chatRekeyedInternalNames } from './chat-matrix.js';
4
5
  import { workerAppHostname, workspaceHost, workspaceInboxHost } from './hostnames.js';
5
6
  import { orgTarget, platformTarget } from './own-services.js';
@@ -20,6 +21,8 @@ export function companyServiceNames(org) {
20
21
  `${s}-tunnel`,
21
22
  `${s}-cloudflared`,
22
23
  ];
24
+ if (analyticsWanted(org))
25
+ names.push(`${s}-analytics`, `${s}-analytics-db`);
23
26
  if (kosmosOn(org))
24
27
  names.push(`${s}-kosmos`, `${s}-kosmos-db`);
25
28
  if (org.chat?.enabled) {
@@ -113,6 +113,12 @@ export declare const LIMITS: {
113
113
  autoDeploy: false | "declared";
114
114
  capabilities: CapabilityTier;
115
115
  };
116
+ readonly analytics: {
117
+ memory: string;
118
+ dbMemory: string;
119
+ autoDeploy: false | "declared";
120
+ capabilities: CapabilityTier;
121
+ };
116
122
  readonly companyOther: {
117
123
  memory: string;
118
124
  dbMemory: string;