@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
@@ -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;
@@ -30,6 +30,7 @@ export const LIMITS = {
30
30
  agora: profile('1g', '512m'),
31
31
  boule: profile('512m'),
32
32
  kosmos: profile('512m'),
33
+ analytics: profile('512m', '512m'),
33
34
  companyOther: profile('1g', '512m'),
34
35
  platformArchon: profile('1g'),
35
36
  platformAi: profile('1g'),
@@ -583,6 +583,8 @@ export interface FoundryPlanApplyOptions {
583
583
  workerAppKeysUrl?: string;
584
584
  /** F7: the keys service's registrar address, when the executor has a `KeysRegistrarPort`. */
585
585
  keysRegistrarUrl?: string;
586
+ /** True when the host's executor was built with an `analytics` port. A `uses.analytics` key is blocked without it. */
587
+ analyticsPort?: boolean;
586
588
  /** C2: true when the host's executor was built with a `WorkerAppDatabasePort`. */
587
589
  workerAppDatabase?: boolean;
588
590
  /** True when that port has `deletableTables`. A `workerApp.database.noDelete` is blocked without it. */
@@ -747,6 +749,7 @@ export declare class FoundryPlanApply implements PlanApply {
747
749
  private readonly workerApps;
748
750
  private readonly workerAppKeysUrl;
749
751
  private readonly keysRegistrarUrl;
752
+ private readonly analyticsPort;
750
753
  private readonly workerAppDatabase;
751
754
  private readonly workerAppNoDeleteCheck;
752
755
  private readonly workerAppRegistryAccountId;
@@ -188,6 +188,7 @@ export class FoundryPlanApply {
188
188
  workerApps;
189
189
  workerAppKeysUrl;
190
190
  keysRegistrarUrl;
191
+ analyticsPort;
191
192
  workerAppDatabase;
192
193
  workerAppNoDeleteCheck;
193
194
  workerAppRegistryAccountId;
@@ -233,6 +234,7 @@ export class FoundryPlanApply {
233
234
  this.workerApps = options.workerApps ?? false;
234
235
  this.workerAppKeysUrl = options.workerAppKeysUrl;
235
236
  this.keysRegistrarUrl = options.keysRegistrarUrl;
237
+ this.analyticsPort = options.analyticsPort ?? false;
236
238
  this.workerAppDatabase = options.workerAppDatabase ?? false;
237
239
  this.workerAppNoDeleteCheck = options.workerAppNoDeleteCheck ?? false;
238
240
  this.workerAppRegistryAccountId = options.workerAppRegistryAccountId;
@@ -1177,7 +1179,12 @@ export class FoundryPlanApply {
1177
1179
  const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1178
1180
  if (!store || !this.executor.releaseHost || !primaryUuid)
1179
1181
  return out;
1180
- const subjects = blueGreenSubjects(org, context.recordedIds());
1182
+ // #1019: Boule and Agora are our own products, so their blue/green release only runs for the
1183
+ // platform's own company; any other company's declaration was never planned as a structure, and a
1184
+ // leftover one is not run here either.
1185
+ const declaredPlatform = await this.platformDeps?.store.get();
1186
+ const ownerOrgId = declaredPlatform?.ownerOrgId ?? null;
1187
+ const subjects = blueGreenSubjects(org, context.recordedIds()).filter((s) => (s.builtin !== 'boule' && s.builtin !== 'agora') || ownerOrgId === org.id);
1181
1188
  // A copy waits while any subject of the org has a release running: the runner must stay free to roll back.
1182
1189
  if (copies === 'claim' && releaseRunning(subjects, context.recordedIds()))
1183
1190
  return out;
@@ -1627,6 +1634,7 @@ export class FoundryPlanApply {
1627
1634
  workerApps: this.workerApps,
1628
1635
  workerAppKeysUrl: this.workerAppKeysUrl,
1629
1636
  keysRegistrarUrl: this.keysRegistrarUrl,
1637
+ analyticsPort: this.analyticsPort,
1630
1638
  workerAppDatabase: this.workerAppDatabase,
1631
1639
  workerAppNoDeleteCheck: this.workerAppNoDeleteCheck,
1632
1640
  workerAppRegistryAccountId: this.workerAppRegistryAccountId,
@@ -2665,6 +2673,8 @@ const STEP_FIELDS = {
2665
2673
  'cloudflare:worker': 'hosting.entries.worker',
2666
2674
  'cloudflare:worker-app': 'hosting.entries.workerApp',
2667
2675
  // F4 (#925): both hang off the company's Apps switch.
2676
+ 'coolify:analytics': 'hosting.entries.uses.analytics',
2677
+ 'coolify:analytics-boule': 'boule.apps',
2668
2678
  'coolify:org-apps-admin': 'boule.apps',
2669
2679
  'cloudflare:apps-db-route': 'boule.apps',
2670
2680
  'cloudflare:apps-zones': 'boule.apps',
@@ -2688,6 +2698,10 @@ function stepField(action) {
2688
2698
  // An entry's jobs project: always `<orgId>:<entry>`.
2689
2699
  if (action.system === 'jobs' && action.kind === 'project')
2690
2700
  return 'hosting.entries.uses.jobs';
2701
+ // An entry's analytics site key: always `<orgId>:<entry>`.
2702
+ if (action.system === 'analytics' && action.kind === 'key') {
2703
+ return 'hosting.entries.uses.analytics';
2704
+ }
2691
2705
  // An entry's audit ledger registration: always `<orgId>:<entry>`.
2692
2706
  if (action.system === 'audit' && action.kind === 'ledger')
2693
2707
  return 'hosting.entries.uses.audit';
@@ -1117,6 +1117,13 @@ export declare const PLATFORM_UPTIME_KV_TITLE = "platform-uptime";
1117
1117
  * a blocked item, never a throw.
1118
1118
  */
1119
1119
  export declare const PLATFORM_UPTIME_TOKEN_REF = "uptime:worker:token";
1120
+ /**
1121
+ * The release tick and audit seal tick tokens: minted by the step like `UPTIME_TOKEN`, each its own
1122
+ * value, set on the Worker and on Archon's env under the same names. The Worker skips a tick while
1123
+ * its token is unset, and Archon's route refuses every call while its own is unset.
1124
+ */
1125
+ export declare const PLATFORM_UPTIME_RELEASE_TICK_TOKEN_REF = "uptime:worker:release-tick-token";
1126
+ export declare const PLATFORM_UPTIME_AUDIT_SEAL_TICK_TOKEN_REF = "uptime:worker:audit-seal-tick-token";
1120
1127
  export declare const PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF = "uptime:worker:access-client-id";
1121
1128
  export declare const PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF = "uptime:worker:access-client-secret";
1122
1129
  export declare const PLATFORM_UPTIME_CHAT_TOKEN_REF = "uptime:worker:chat-token";
@@ -1138,6 +1145,15 @@ export declare const PLATFORM_UPTIME_OPTIONAL_SECRETS: Readonly<Record<string, s
1138
1145
  */
1139
1146
  export declare const PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY = "uptime_token_undelivered";
1140
1147
  export declare const PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY = "uptime_token_id";
1148
+ /** The same pair of scope keys for each tick token: `<undelivered>` before the value is held, `<id>` once it is. */
1149
+ export declare const PLATFORM_UPTIME_RELEASE_TICK_UNDELIVERED_SCOPE_KEY = "uptime_release_tick_undelivered";
1150
+ export declare const PLATFORM_UPTIME_RELEASE_TICK_ID_SCOPE_KEY = "uptime_release_tick_id";
1151
+ export declare const PLATFORM_UPTIME_AUDIT_SEAL_TICK_UNDELIVERED_SCOPE_KEY = "uptime_audit_seal_tick_undelivered";
1152
+ export declare const PLATFORM_UPTIME_AUDIT_SEAL_TICK_ID_SCOPE_KEY = "uptime_audit_seal_tick_id";
1153
+ /** The tick token ids (`<release>:<seal>`) Archon's env was last written with; like {@link PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY}. */
1154
+ export declare const PLATFORM_ARCHON_UPTIME_TICKS_SYNCED_SCOPE_KEY = "archon_uptime_ticks_synced";
1155
+ /** What {@link PLATFORM_ARCHON_UPTIME_TICKS_SYNCED_SCOPE_KEY} holds once Archon's env carries the tick tokens; empty while neither is minted. */
1156
+ export declare function uptimeTicksSyncValue(recorded: Readonly<Record<string, string>>): string;
1141
1157
  /** The token id Archon's env was last written with; `planArchon` plans again when it differs from {@link PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY}. */
1142
1158
  export declare const PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY = "archon_uptime_token_synced";
1143
1159
  /** The tag, sha256 and Archon hostname the last successful run applied; a change plans the step again and mints nothing. */
@@ -1171,7 +1187,7 @@ export declare function uptimeEffectiveVars(hostVars: Readonly<Record<string, st
1171
1187
  } | null): Record<string, string | undefined>;
1172
1188
  /** The digest of the Worker's plain text variables that are set; empty when none is. */
1173
1189
  export declare function uptimeVarsDigest(vars?: Readonly<Record<string, string | undefined>>): string;
1174
- /** The Worker secret names the step sets: the three it mints, and each held one. */
1190
+ /** The Worker secret names the step sets: the five it mints, and each held one. */
1175
1191
  export declare function uptimeSecretNames(held: readonly string[]): string;
1176
1192
  /** The Access service token's name at Cloudflare; edge answers `conflict` when a token of this name exists. */
1177
1193
  export declare const PLATFORM_UPTIME_ACCESS_TOKEN_NAME = "platform-uptime";