@wtfalch/foundry 0.65.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 (88) hide show
  1. package/dist/apps/cloudflare.d.ts +5 -0
  2. package/dist/apps/deploy.js +33 -1
  3. package/dist/apps/migrations.d.ts +3 -0
  4. package/dist/apps/migrations.js +32 -7
  5. package/dist/catalogue/copy-en.js +5 -0
  6. package/dist/catalogue/integrations.js +5 -0
  7. package/dist/entry-rules.d.ts +11 -0
  8. package/dist/entry-rules.js +70 -0
  9. package/dist/index.d.ts +9 -4
  10. package/dist/index.js +5 -2
  11. package/dist/migrations/0069_release_events.sql +16 -0
  12. package/dist/modules/analytics.d.ts +8 -0
  13. package/dist/modules/analytics.js +28 -0
  14. package/dist/modules/database.js +22 -3
  15. package/dist/modules/index.js +2 -0
  16. package/dist/native-build.d.ts +16 -12
  17. package/dist/native-build.js +16 -12
  18. package/dist/org.d.ts +9 -1
  19. package/dist/org.js +4 -1
  20. package/dist/plan/analytics-boule.d.ts +17 -0
  21. package/dist/plan/analytics-boule.js +43 -0
  22. package/dist/plan/analytics.d.ts +58 -0
  23. package/dist/plan/analytics.js +89 -0
  24. package/dist/plan/boule.js +2 -0
  25. package/dist/plan/company-backups.js +2 -0
  26. package/dist/plan/diff.d.ts +4 -0
  27. package/dist/plan/diff.js +103 -3
  28. package/dist/plan/entry-analytics-key.d.ts +35 -0
  29. package/dist/plan/entry-analytics-key.js +134 -0
  30. package/dist/plan/executor.d.ts +79 -6
  31. package/dist/plan/executor.js +379 -19
  32. package/dist/plan/hostnames.js +4 -0
  33. package/dist/plan/index.d.ts +2 -0
  34. package/dist/plan/index.js +1 -0
  35. package/dist/plan/live.d.ts +1 -1
  36. package/dist/plan/locked-hostnames.js +3 -0
  37. package/dist/plan/login-host.d.ts +8 -0
  38. package/dist/plan/login-host.js +8 -0
  39. package/dist/plan/org-names.d.ts +3 -3
  40. package/dist/plan/org-names.js +3 -0
  41. package/dist/plan/own-limits.d.ts +6 -0
  42. package/dist/plan/own-limits.js +1 -0
  43. package/dist/plan/plan-apply.d.ts +12 -0
  44. package/dist/plan/plan-apply.js +40 -1
  45. package/dist/plan/platform-diff.d.ts +17 -1
  46. package/dist/plan/platform-diff.js +40 -2
  47. package/dist/plan/platform-org.d.ts +6 -0
  48. package/dist/plan/platform-providers.d.ts +8 -6
  49. package/dist/plan/platform-providers.js +56 -10
  50. package/dist/plan/providers-readers.js +16 -1
  51. package/dist/plan/release-host.d.ts +30 -1
  52. package/dist/plan/release-host.js +84 -41
  53. package/dist/plan/release-series-reader.d.ts +26 -0
  54. package/dist/plan/release-series-reader.js +40 -0
  55. package/dist/plan/sign-in-archon.d.ts +1 -1
  56. package/dist/plan/sign-in-archon.js +2 -2
  57. package/dist/plan/stage-services.d.ts +3 -1
  58. package/dist/plan/stage-services.js +2 -1
  59. package/dist/plan/uptime-worker.js +53 -3
  60. package/dist/plan/worker-app-database.d.ts +12 -1
  61. package/dist/plan/worker-app-database.js +13 -0
  62. package/dist/plan/worker-app.d.ts +27 -2
  63. package/dist/plan/worker-app.js +52 -7
  64. package/dist/plan/workspace.d.ts +2 -2
  65. package/dist/plan/workspace.js +3 -3
  66. package/dist/release/decide.d.ts +2 -0
  67. package/dist/release/decide.js +67 -10
  68. package/dist/release/events.d.ts +13 -0
  69. package/dist/release/events.js +67 -0
  70. package/dist/release/health.d.ts +5 -0
  71. package/dist/release/health.js +3 -3
  72. package/dist/release/run.d.ts +7 -2
  73. package/dist/release/run.js +8 -0
  74. package/dist/release/series.d.ts +58 -0
  75. package/dist/release/series.js +65 -0
  76. package/dist/release/state.d.ts +58 -1
  77. package/dist/release/state.js +45 -0
  78. package/dist/release/types.d.ts +44 -0
  79. package/dist/slug.d.ts +1 -1
  80. package/dist/slug.js +5 -2
  81. package/dist/store/postgres.js +29 -3
  82. package/dist/store/schema.d.ts +112 -0
  83. package/dist/store/schema.js +9 -0
  84. package/dist/worker-app-config.d.ts +22 -1
  85. package/dist/worker-app-config.js +73 -15
  86. package/dist/worker-app-migrations.d.ts +6 -2
  87. package/dist/worker-app-migrations.js +1 -0
  88. package/package.json +1 -1
@@ -5,10 +5,12 @@ import { EdgeApiError } from '@wtfalch/edge';
5
5
  import { coolifyImageFor } from '../bootstrap/versions.js';
6
6
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
7
7
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
8
- import { NATIVE_BUILD_KEY_BYTES, NATIVE_BUILD_KEY_ENV } from '../native-build.js';
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';
@@ -52,7 +55,7 @@ import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
52
55
  import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, workspaceHost, } from './hostnames.js';
53
56
  import { KEYS_REGISTRAR_DELIVERY_ATTEMPTS, KEYS_REGISTRAR_KEY_DAYS, KEYS_REGISTRAR_KEY_ENV, KEYS_REGISTRAR_MINTED_AT_SCOPE_KEY, KEYS_REGISTRAR_MINT_STARTED_SCOPE_KEY, KEYS_REGISTRAR_SCOPE_KEY, KEYS_REGISTRAR_UNDELIVERED_SCOPE_KEY, KEYS_REGISTRAR_URL_APPLIED_SCOPE_KEY, KEYS_REGISTRAR_URL_ENV, } from './keys-registrar.js';
54
57
  import { KOSMOS_CLIENT_ID_SCOPE_KEY, KOSMOS_DATABASE_SCOPE_KEY, KOSMOS_DEPLOYED_SCOPE_KEY, desiredKosmosEnv, kosmosEnvDrift, kosmosHostnameFor, } from './kosmos.js';
55
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
58
+ import { LOGIN_BASE_URI_SWEEP_SCOPE_KEY, LOGIN_BASE_URI_SWEEP_VERSION, LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
56
59
  import { machineBypassScopeKey } from './machine-paths.js';
57
60
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
58
61
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.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':
@@ -1755,6 +1770,7 @@ export class FoundryStepExecutor {
1755
1770
  : {}),
1756
1771
  zones: this.zonePort(),
1757
1772
  dns: this.dnsPort(),
1773
+ secret: (value) => void this.handledSecrets.add(value),
1758
1774
  ...(this.ports.heldKeys ? { heldKeys: this.ports.heldKeys } : {}),
1759
1775
  };
1760
1776
  }
@@ -2354,6 +2370,7 @@ export class FoundryStepExecutor {
2354
2370
  ? loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId).filter((id) => !movedIds.has(id)))
2355
2371
  : clientIds;
2356
2372
  const cookieDomain = inAppCookieDomain(action.desired.cookieDomain);
2373
+ let swept = false;
2357
2374
  const writeCompanyBinding = async (ids) => {
2358
2375
  if (ids.length > 0 || activePairs.length === 0) {
2359
2376
  await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds: ids });
@@ -2428,8 +2445,10 @@ export class FoundryStepExecutor {
2428
2445
  await writeClientOrgs();
2429
2446
  // One write for every upsert above (one redeploy of the login), before any app is repointed.
2430
2447
  await bindings.flush?.();
2431
- if (own && activeHosts.size > 0) {
2432
- await this.ports.zitadel.allowPerAppLoginBaseUri?.(`https://${host}/`);
2448
+ // In-app sign-in recorded or wanted: `required` is off, so every app needs a base URI (marker below).
2449
+ if (own && (activeHosts.size > 0 || recorded[SIGN_IN_IN_APP_SCOPE_KEY])) {
2450
+ await allowPerAppLoginBaseUris(this.ports.zitadel, `https://${host}/`);
2451
+ swept = Boolean(this.ports.zitadel.allowPerAppLoginBaseUri);
2433
2452
  }
2434
2453
  }
2435
2454
  // The base URI each app's sign-in starts from: its own address under `/sign-in/` in-app, else the login host.
@@ -2643,10 +2662,23 @@ export class FoundryStepExecutor {
2643
2662
  if (own) {
2644
2663
  // Written last: a failed base URI write leaves it behind and the step is planned again.
2645
2664
  context.recordScopeId(OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, ownCustomersSet.sort().join(','));
2665
+ if (swept)
2666
+ context.recordScopeId(LOGIN_BASE_URI_SWEEP_SCOPE_KEY, LOGIN_BASE_URI_SWEEP_VERSION);
2646
2667
  }
2647
2668
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
2648
2669
  return { resourceId: host };
2649
2670
  }
2671
+ /**
2672
+ * `{ loginBaseUri }` for an app made in the org's ZITADEL: the instance's own login host (a company's own
2673
+ * `auth.` host, else the shared one). Nothing when the shared host is not known. See
2674
+ * {@link FoundryStepExecutorPorts}' `createOidcApp` `loginBaseUri`.
2675
+ */
2676
+ async instanceLoginBaseUriFor(org) {
2677
+ if (ownsServices(org))
2678
+ return { loginBaseUri: `https://${ownHostnames(org).auth}/` };
2679
+ const shared = await this.ports.platformOrg?.authHostname?.();
2680
+ return shared ? { loginBaseUri: `https://${shared}/` } : {};
2681
+ }
2650
2682
  /**
2651
2683
  * The host table of a company's own in-app login app as a bindings port that collects its edits and
2652
2684
  * writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
@@ -2931,6 +2963,7 @@ export class FoundryStepExecutor {
2931
2963
  : [...signInUris, ...(authEntry?.uses?.auth?.mcp ? [mcpCallbackUri(org)] : [])],
2932
2964
  postLogoutRedirectUris: postLogoutUris,
2933
2965
  ...(authEntry?.uses?.auth?.mcp ? { accessTokenType: 'jwt' } : {}),
2966
+ ...(await this.instanceLoginBaseUriFor(org)),
2934
2967
  });
2935
2968
  if (appType === 'web') {
2936
2969
  // `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
@@ -3813,8 +3846,34 @@ export class FoundryStepExecutor {
3813
3846
  : { id: registered.id, name: projectName };
3814
3847
  },
3815
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
+ };
3816
3875
  const prodJobsProjectId = context.recordedIds()[`jobs:registration:${org.id}`];
3817
- return { files, ai, jobs, ...(prodJobsProjectId ? { prodJobsProjectId } : {}) };
3876
+ return { files, ai, jobs, analytics, ...(prodJobsProjectId ? { prodJobsProjectId } : {}) };
3818
3877
  }
3819
3878
  /**
3820
3879
  * Stage's own sign-in (`plan/stage-signin.ts`): a client named `<production client>-stage` on production's
@@ -3869,6 +3928,7 @@ export class FoundryStepExecutor {
3869
3928
  appType: 'web',
3870
3929
  redirectUris: spec.redirectUris,
3871
3930
  postLogoutRedirectUris: spec.postLogoutRedirectUris,
3931
+ ...(await this.instanceLoginBaseUriFor(org)),
3872
3932
  });
3873
3933
  },
3874
3934
  };
@@ -3921,6 +3981,7 @@ export class FoundryStepExecutor {
3921
3981
  appType: 'web',
3922
3982
  redirectUris: spec.redirectUris,
3923
3983
  postLogoutRedirectUris: spec.postLogoutRedirectUris,
3984
+ ...(await this.instanceLoginBaseUriFor(org)),
3924
3985
  });
3925
3986
  },
3926
3987
  writeBrokerEntry: async (entryWant, appKey) => {
@@ -5203,13 +5264,28 @@ export class FoundryStepExecutor {
5203
5264
  return already ? null : randomBytes(bytes).toString('base64url');
5204
5265
  }
5205
5266
  /**
5206
- * Every company's Boule, F3/#970: `NATIVE_BUILD_KEY` is what Boule presents to Archon's
5207
- * native-build route (estate#262) to ask it to dispatch `build-app.yml`. Minted once, like
5208
- * {@link ensureAuthAppKey}, and never rotated; a present-but-empty value (the bug
5209
- * `mintNativeBuildKey`'s own read-then-generate had, see #957) is treated as not set.
5267
+ * foundry#987: nothing reads `NATIVE_BUILD_KEY` any more -- Boule calls Archon's org build
5268
+ * route (`/api/orgs/{orgId}/native/builds`) with its own org credential instead
5269
+ * (`BOULE_ARCHON_CREDENTIAL_ENV`, archon#706, boule#432). This takes the env row away from
5270
+ * every Boule that still has one from #970/#971; `NATIVE_BUILD_KEY_ENV` and
5271
+ * `NATIVE_BUILD_KEY_BYTES` are deprecated and go in the next release (the two-release removal
5272
+ * rule), once no live Boule holds the row any more.
5210
5273
  */
5211
- async ensureNativeBuildKey(appId) {
5212
- return this.mintIfAbsent(appId, NATIVE_BUILD_KEY_ENV, NATIVE_BUILD_KEY_BYTES, 1);
5274
+ async dropNativeBuildKeyEnv(appId, liveRows) {
5275
+ let changed = false;
5276
+ for (const row of liveRows) {
5277
+ if (row.key !== NATIVE_BUILD_KEY_ENV || !row.uuid)
5278
+ continue;
5279
+ try {
5280
+ await this.ports.coolify.deleteEnv(appId, row.uuid);
5281
+ changed = true;
5282
+ }
5283
+ catch (error) {
5284
+ if (!isNotFoundError(error))
5285
+ throw error;
5286
+ }
5287
+ }
5288
+ return changed;
5213
5289
  }
5214
5290
  /**
5215
5291
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
@@ -7703,12 +7779,10 @@ export class FoundryStepExecutor {
7703
7779
  this.handledSecrets.add(ownerSeedSecret);
7704
7780
  envs.push({ key: BOULE_OWNER_SEED_SECRET_ENV, value: ownerSeedSecret });
7705
7781
  }
7706
- // The key Boule presents to Archon's native-build route, for every org's Boule (foundry#970).
7707
- const nativeBuildKey = await this.ensureNativeBuildKey(app.appId);
7708
- if (nativeBuildKey) {
7709
- this.handledSecrets.add(nativeBuildKey);
7710
- envs.push({ key: NATIVE_BUILD_KEY_ENV, value: nativeBuildKey });
7711
- }
7782
+ // foundry#987: Boule calls Archon's org build route with its own org credential now, so
7783
+ // nothing reads this any more -- take the row away from every Boule that still has one.
7784
+ if (await this.dropNativeBuildKeyEnv(app.appId, liveRows))
7785
+ envChanged = true;
7712
7786
  // Held, minted once (C3). Compared with the live value so a changed one restarts the app.
7713
7787
  const credentialEnvs = [];
7714
7788
  let archonPushedAt = null;
@@ -7769,7 +7843,6 @@ export class FoundryStepExecutor {
7769
7843
  peerPassword,
7770
7844
  bridgeSecret,
7771
7845
  ownerSeedSecret,
7772
- nativeBuildKey,
7773
7846
  ...credentialEnvs.map((credential) => credential.value),
7774
7847
  ...grants.secrets,
7775
7848
  ].filter((v) => v !== null);
@@ -7809,7 +7882,6 @@ export class FoundryStepExecutor {
7809
7882
  peerPassword !== null ||
7810
7883
  bridgeSecret !== null ||
7811
7884
  ownerSeedSecret !== null ||
7812
- nativeBuildKey !== null ||
7813
7885
  secretsNotRestarted ||
7814
7886
  registrarUnstarted ||
7815
7887
  dbPush.changed ||
@@ -7828,6 +7900,254 @@ export class FoundryStepExecutor {
7828
7900
  this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
7829
7901
  return { resourceId: app.appId };
7830
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
+ }
7831
8151
  // ---- kosmos (foundry#949) --------------------------------------------------
7832
8152
  /**
7833
8153
  * `plan/diff.ts`'s `planKosmos`: provisions the org's own kosmos
@@ -11448,6 +11768,46 @@ export function isNoChangesError(error) {
11448
11768
  * {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
11449
11769
  * done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
11450
11770
  */
11771
+ /**
11772
+ * Turns the instance feature `loginV2.required` off so each app's own base URI counts, and makes sure no
11773
+ * app can then land on ZITADEL's v1 login: with `required` off an app without a base URI of its own
11774
+ * (ZITADEL's own console app, an app foundry did not make, an app made later) falls back to v1, and
11775
+ * ZITADEL v4.17.3 gives a `required` instance's base URI to every app but ignores an app's own
11776
+ * (`internal/query/oidc_client.go:88-92`), so the only way is a base URI on every app. Every live OIDC
11777
+ * app of every organisation and project gets `instanceBaseUri` when it has none; an app that has one
11778
+ * keeps it. The apps are set first, while `required` still makes the instance ignore them, so no sign-in
11779
+ * passes through a gap. Without a way to list every app, or with a listing that may be cut short, it
11780
+ * throws before the feature is touched.
11781
+ */
11782
+ export async function allowPerAppLoginBaseUris(zitadel, instanceBaseUri) {
11783
+ if (!zitadel.allowPerAppLoginBaseUri)
11784
+ return;
11785
+ const { listOrgs, listProjects } = zitadel;
11786
+ if (!listOrgs || !listProjects) {
11787
+ throw new Error('the ZITADEL port cannot list every organisation and project, so apps without a login base URI would fall back to the v1 login');
11788
+ }
11789
+ for (const org of await listOrgs()) {
11790
+ if (org.state && org.state !== 'ORG_STATE_ACTIVE')
11791
+ continue;
11792
+ for (const project of await listProjects(org.id)) {
11793
+ if (project.state && project.state !== 'PROJECT_STATE_ACTIVE')
11794
+ continue;
11795
+ const listed = await zitadel.listApps(org.id, project.id);
11796
+ if (!listed.complete) {
11797
+ throw new Error(`the app listing of project ${project.id} may be cut short, so apps without a login base URI could be missed`);
11798
+ }
11799
+ for (const app of listed.apps) {
11800
+ if (app.state === 'APP_STATE_REMOVED' || app.appType === 'api')
11801
+ continue;
11802
+ // `undefined` is "not read": set it, the write is the same either way.
11803
+ if (typeof app.loginBaseUri === 'string' && app.loginBaseUri !== '')
11804
+ continue;
11805
+ await setOidcAppLoginBaseUri(zitadel, org.id, project.id, app.id, instanceBaseUri);
11806
+ }
11807
+ }
11808
+ }
11809
+ await zitadel.allowPerAppLoginBaseUri(instanceBaseUri);
11810
+ }
11451
11811
  export async function setOidcAppLoginBaseUri(zitadel, orgId, projectId, appId, loginBaseUri) {
11452
11812
  try {
11453
11813
  await zitadel.updateOidcAppLoginBaseUri(orgId, projectId, appId, loginBaseUri);
@@ -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)
@@ -190,6 +190,14 @@ export declare function customersClientOrganizations(org: DeclaredOrg, recorded:
190
190
  * falls back to the v1 login once in-app sign-in turns `loginV2.required` off. Absent or empty means none.
191
191
  */
192
192
  export declare const OWN_CUSTOMERS_BASE_URI_SCOPE_KEY = "own_customers_login_base_uri";
193
+ /**
194
+ * Scope id recording that the instance's apps were all given a Login V2 base URI ({@link LOGIN_BASE_URI_SWEEP_VERSION}),
195
+ * on a company's own instance and on the platform's (Archon's step). A value that is missing or older plans the step
196
+ * again while in-app sign-in has `loginV2.required` off, so an instance applied before the sweep gets it too.
197
+ */
198
+ export declare const LOGIN_BASE_URI_SWEEP_SCOPE_KEY = "login_base_uri_sweep";
199
+ /** The value {@link LOGIN_BASE_URI_SWEEP_SCOPE_KEY} holds once the sweep ran. Raise it to run the sweep again everywhere. */
200
+ export declare const LOGIN_BASE_URI_SWEEP_VERSION = "1";
193
201
  /** The recorded ZITADEL app ids of the org's customers entries, sorted and joined by `,` ('' when none). */
194
202
  export declare function ownCustomersAppState(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string;
195
203
  /** {@link LOGIN_CLIENT_ORGS_SCOPE_KEY}'s value for a map. */
@@ -223,6 +223,14 @@ export function customersClientOrganizations(org, recorded) {
223
223
  * falls back to the v1 login once in-app sign-in turns `loginV2.required` off. Absent or empty means none.
224
224
  */
225
225
  export const OWN_CUSTOMERS_BASE_URI_SCOPE_KEY = 'own_customers_login_base_uri';
226
+ /**
227
+ * Scope id recording that the instance's apps were all given a Login V2 base URI ({@link LOGIN_BASE_URI_SWEEP_VERSION}),
228
+ * on a company's own instance and on the platform's (Archon's step). A value that is missing or older plans the step
229
+ * again while in-app sign-in has `loginV2.required` off, so an instance applied before the sweep gets it too.
230
+ */
231
+ export const LOGIN_BASE_URI_SWEEP_SCOPE_KEY = 'login_base_uri_sweep';
232
+ /** The value {@link LOGIN_BASE_URI_SWEEP_SCOPE_KEY} holds once the sweep ran. Raise it to run the sweep again everywhere. */
233
+ export const LOGIN_BASE_URI_SWEEP_VERSION = '1';
226
234
  /** The recorded ZITADEL app ids of the org's customers entries, sorted and joined by `,` ('' when none). */
227
235
  export function ownCustomersAppState(org, recorded) {
228
236
  return [...customersEntryKeys(org)]