@wtfalch/foundry 0.66.0 → 0.68.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 (47) hide show
  1. package/dist/build-json.d.ts +16 -1
  2. package/dist/build-json.js +21 -5
  3. package/dist/catalogue/copy-en.js +5 -0
  4. package/dist/catalogue/integrations.js +5 -0
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.js +1 -0
  7. package/dist/modules/analytics.d.ts +8 -0
  8. package/dist/modules/analytics.js +28 -0
  9. package/dist/modules/assistants.js +2 -2
  10. package/dist/modules/index.js +2 -0
  11. package/dist/org.d.ts +9 -1
  12. package/dist/org.js +6 -3
  13. package/dist/plan/analytics-boule.d.ts +17 -0
  14. package/dist/plan/analytics-boule.js +43 -0
  15. package/dist/plan/analytics.d.ts +58 -0
  16. package/dist/plan/analytics.js +89 -0
  17. package/dist/plan/blue-green.d.ts +6 -0
  18. package/dist/plan/blue-green.js +7 -0
  19. package/dist/plan/company-backups.js +2 -0
  20. package/dist/plan/diff.d.ts +2 -0
  21. package/dist/plan/diff.js +87 -0
  22. package/dist/plan/entry-analytics-key.d.ts +35 -0
  23. package/dist/plan/entry-analytics-key.js +134 -0
  24. package/dist/plan/executor.d.ts +36 -0
  25. package/dist/plan/executor.js +290 -1
  26. package/dist/plan/hostnames.js +4 -0
  27. package/dist/plan/index.d.ts +2 -0
  28. package/dist/plan/index.js +1 -0
  29. package/dist/plan/live.d.ts +1 -1
  30. package/dist/plan/locked-hostnames.js +3 -0
  31. package/dist/plan/org-names.d.ts +3 -3
  32. package/dist/plan/org-names.js +3 -0
  33. package/dist/plan/own-limits.d.ts +6 -0
  34. package/dist/plan/own-limits.js +1 -0
  35. package/dist/plan/plan-apply.d.ts +3 -0
  36. package/dist/plan/plan-apply.js +15 -1
  37. package/dist/plan/platform-diff.d.ts +17 -1
  38. package/dist/plan/platform-diff.js +35 -2
  39. package/dist/plan/platform-providers.js +42 -2
  40. package/dist/plan/providers-readers.js +16 -1
  41. package/dist/plan/stage-services.d.ts +3 -1
  42. package/dist/plan/stage-services.js +2 -1
  43. package/dist/plan/uptime-worker.js +53 -3
  44. package/dist/slug.d.ts +1 -1
  45. package/dist/slug.js +5 -2
  46. package/dist/worker-app-config.js +2 -2
  47. package/package.json +1 -1
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';
@@ -568,6 +571,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
568
571
  servers: options.blueGreenServers,
569
572
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
570
573
  accessProviderId: platformReadiness.accessProviderId ?? null,
574
+ ownerOrgId: platformReadiness.platformOwnerOrgId,
571
575
  ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
572
576
  ...(options.stageGuardPorts ? { stageGuardPorts: options.stageGuardPorts } : {}),
573
577
  appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
@@ -723,6 +727,14 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
723
727
  const kosmos = planKosmos(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
724
728
  if (kosmos.step)
725
729
  plannedSteps.push(kosmos.step);
730
+ // The company's analytics backend, only while something uses it (an entry's `uses.analytics`, or Boule's
731
+ // apps); never parked once made. Before the site keys and Boule's env, which need its operator key.
732
+ const analytics = planAnalyticsBackend(org, byKey, claimed, recordedIds, options.otherSlugs ?? []);
733
+ if (analytics.step)
734
+ plannedSteps.push(analytics.step);
735
+ plannedSteps.push(...planAnalyticsBoule(org, recordedIds, plannedSteps));
736
+ const entryAnalyticsKeys = planEntryAnalyticsKeys(org, recordedIds, plannedSteps, options.analyticsPort ?? false);
737
+ plannedSteps.push(...entryAnalyticsKeys.steps);
726
738
  // Boule's, Agora's and kosmos's blue/green: after their own steps, which make the app it copies.
727
739
  plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'last').map(blueGreenDraft()));
728
740
  // Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
@@ -995,6 +1007,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
995
1007
  }
996
1008
  if (kosmos.blocked)
997
1009
  unmanaged.push({ description: kosmos.blocked, key: 'coolify:kosmos' });
1010
+ if (analytics.blocked)
1011
+ unmanaged.push({ description: analytics.blocked, key: 'coolify:analytics' });
1012
+ unmanaged.push(...entryAnalyticsKeys.blocked);
998
1013
  // Same for an ai key minted but never delivered: planning a new mint would
999
1014
  // orphan a second live key.
1000
1015
  if (aiKey.blocked)
@@ -3212,6 +3227,78 @@ function planKosmos(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
3212
3227
  blocked: null,
3213
3228
  };
3214
3229
  }
3230
+ /**
3231
+ * The company's analytics backend: a per-org singleton keyed by the org id under its own `analytics` kind, built
3232
+ * from https://github.com/wtfalch/analytics's root Dockerfile (`executor.ts`'s `coolifyAnalytics`) with a
3233
+ * database of its own. Planned only while `analyticsWanted(org)`; once live it is kept and never parked. A live
3234
+ * app is planned again when its `APP_URL` or `AUTH_ORGANIZATION_ID` is missing or different. It needs no
3235
+ * sign-in client and no issuer; only the company's sign-in organisation id (recorded by an earlier step).
3236
+ */
3237
+ function planAnalyticsBackend(org, byKey, claimed, recordedIds, otherSlugs) {
3238
+ const key = resourceKey('coolify', 'analytics', org.id);
3239
+ const live = byKey.get(key);
3240
+ if (live)
3241
+ claimed.add(key);
3242
+ if (!analyticsWanted(org))
3243
+ return { step: null, blocked: null };
3244
+ const tooLong = [
3245
+ [`${org.slug}-analytics`, 'the analytics host label and app name'],
3246
+ [`${org.slug}_analytics_db_rt`, 'the analytics database role'],
3247
+ ].filter(([name]) => name.length > 63);
3248
+ if (tooLong.length > 0) {
3249
+ return {
3250
+ step: null,
3251
+ blocked: `${org.slug}'s analytics backend is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
3252
+ };
3253
+ }
3254
+ if (otherSlugs.includes(`${org.slug}-analytics`)) {
3255
+ return {
3256
+ step: null,
3257
+ 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`,
3258
+ };
3259
+ }
3260
+ // `coolifyAnalytics` writes nothing without the sign-in organisation, so the step waits for it here.
3261
+ const { env, missing } = desiredAnalyticsEnv(org, recordedIds);
3262
+ if (missing.length > 0) {
3263
+ return {
3264
+ step: null,
3265
+ blocked: `${org.slug}'s analytics backend is blocked, not planned: the company's sign-in organisation is not recorded yet`,
3266
+ };
3267
+ }
3268
+ const action = {
3269
+ system: 'coolify',
3270
+ kind: 'analytics',
3271
+ key: org.id,
3272
+ desired: {},
3273
+ };
3274
+ if (!live) {
3275
+ return {
3276
+ step: {
3277
+ action,
3278
+ step: {
3279
+ description: `provision ${org.slug}'s analytics backend`,
3280
+ destroys: false,
3281
+ migrates: false,
3282
+ },
3283
+ },
3284
+ blocked: null,
3285
+ };
3286
+ }
3287
+ const drift = analyticsEnvDrift(env, (live.config.env ?? {}));
3288
+ if (drift.length === 0)
3289
+ return { step: null, blocked: null };
3290
+ return {
3291
+ step: {
3292
+ action,
3293
+ step: {
3294
+ description: `update ${org.slug}'s analytics backend config (${drift.join(', ')} missing or different)`,
3295
+ destroys: false,
3296
+ migrates: false,
3297
+ },
3298
+ },
3299
+ blocked: null,
3300
+ };
3301
+ }
3215
3302
  /**
3216
3303
  * C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
3217
3304
  * 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 {};
@@ -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