@wtfalch/foundry 0.41.2 → 0.43.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 (71) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +7 -3
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0054_org_signin_in_app.sql +6 -0
  5. package/dist/migrations/0055_release_requests.sql +20 -0
  6. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  7. package/dist/org.d.ts +30 -0
  8. package/dist/org.js +52 -1
  9. package/dist/plan/agora.d.ts +4 -1
  10. package/dist/plan/agora.js +16 -2
  11. package/dist/plan/archon-blue-green.d.ts +54 -0
  12. package/dist/plan/archon-blue-green.js +72 -0
  13. package/dist/plan/blue-green-apply.d.ts +82 -0
  14. package/dist/plan/blue-green-apply.js +360 -0
  15. package/dist/plan/blue-green.d.ts +223 -0
  16. package/dist/plan/blue-green.js +444 -0
  17. package/dist/plan/boule.d.ts +5 -2
  18. package/dist/plan/boule.js +14 -2
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/coolify-previews.d.ts +10 -0
  22. package/dist/plan/coolify-previews.js +14 -0
  23. package/dist/plan/diff.d.ts +6 -0
  24. package/dist/plan/diff.js +123 -11
  25. package/dist/plan/edge-ratelimits.d.ts +5 -0
  26. package/dist/plan/edge-ratelimits.js +16 -6
  27. package/dist/plan/executor.d.ts +79 -0
  28. package/dist/plan/executor.js +527 -31
  29. package/dist/plan/login-host.d.ts +23 -2
  30. package/dist/plan/login-host.js +30 -5
  31. package/dist/plan/own-diff.js +6 -0
  32. package/dist/plan/plan-apply.d.ts +51 -0
  33. package/dist/plan/plan-apply.js +222 -0
  34. package/dist/plan/platform-diff.d.ts +9 -0
  35. package/dist/plan/platform-diff.js +28 -1
  36. package/dist/plan/platform-org.d.ts +6 -0
  37. package/dist/plan/platform-org.js +5 -0
  38. package/dist/plan/platform-providers.d.ts +31 -2
  39. package/dist/plan/platform-providers.js +345 -13
  40. package/dist/plan/providers-readers.js +6 -2
  41. package/dist/plan/proxy-labels.d.ts +2 -2
  42. package/dist/plan/proxy-labels.js +12 -4
  43. package/dist/plan/release-host.d.ts +59 -0
  44. package/dist/plan/release-host.js +239 -0
  45. package/dist/plan/remote-database.d.ts +124 -0
  46. package/dist/plan/remote-database.js +373 -0
  47. package/dist/plan/sign-in-archon.d.ts +46 -0
  48. package/dist/plan/sign-in-archon.js +71 -0
  49. package/dist/plan/sign-in-bindings.d.ts +16 -0
  50. package/dist/plan/sign-in-bindings.js +44 -0
  51. package/dist/plan/sign-in.d.ts +135 -0
  52. package/dist/plan/sign-in.js +172 -0
  53. package/dist/platform.d.ts +11 -1
  54. package/dist/platform.js +4 -1
  55. package/dist/release/decide.d.ts +95 -0
  56. package/dist/release/decide.js +242 -0
  57. package/dist/release/health.d.ts +48 -0
  58. package/dist/release/health.js +61 -0
  59. package/dist/release/run.d.ts +40 -0
  60. package/dist/release/run.js +56 -0
  61. package/dist/release/state.d.ts +64 -0
  62. package/dist/release/state.js +126 -0
  63. package/dist/release/types.d.ts +54 -0
  64. package/dist/release/types.js +4 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +62 -3
  67. package/dist/store/schema.d.ts +171 -0
  68. package/dist/store/schema.js +14 -0
  69. package/package.json +1 -1
  70. package/versions.digests.json +2 -1
  71. package/versions.json +2 -1
@@ -1,4 +1,5 @@
1
1
  import { type DeclaredOrg } from '../org.js';
2
+ import { type InAppApp, type SIGN_IN_BASE_PATH } from './sign-in.js';
2
3
  /**
3
4
  * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
5
  * need to agree on for an org's own company login host -- the hostname and
@@ -99,7 +100,17 @@ export interface LoginHostBinding {
99
100
  host: string;
100
101
  organizationId: string;
101
102
  clientIds: string[];
103
+ /** In-app sign-in: which app each client id is, so the login draws that app's room. Absent: the company's own room. */
104
+ apps?: Record<string, 'agora' | 'boule' | 'archon'>;
105
+ /** In-app sign-in: the login serves this host under `/sign-in`. Absent: the root. */
106
+ basePath?: typeof SIGN_IN_BASE_PATH;
102
107
  }
108
+ /**
109
+ * The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
110
+ * Boule's, each only once its client id is recorded. Empty with the switch off. Archon (owner company
111
+ * only) is not here: the platform's Archon step owns its host (`sign-in-archon.ts`).
112
+ */
113
+ export declare function inAppApps(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): InAppApp[];
103
114
  /**
104
115
  * Host-supplied write access to zitadel-login's own `LOGIN_HOST_BINDINGS`
105
116
  * env (`auth/docs/company-login-hosts.md`'s own "Set `LOGIN_HOST_BINDINGS`
@@ -121,6 +132,12 @@ export interface LoginHostBindingsPort {
121
132
  * package.
122
133
  */
123
134
  deleteBinding(host: string): Promise<void>;
135
+ /**
136
+ * A port that queues its upserts writes them here, once: one redeploy of the login however many hosts
137
+ * the step bound. A port without it writes on each call. `deleteBinding` writes at once on either.
138
+ * The login-host step calls it after all its upserts and before any app is repointed.
139
+ */
140
+ flush?(): Promise<void>;
124
141
  }
125
142
  /**
126
143
  * Scope id holding what the binding was last written with
@@ -137,8 +154,12 @@ export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
137
154
  * is no longer declared is not here, so it stays bound as before.
138
155
  */
139
156
  export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
140
- /** The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule), deduplicated and sorted. */
141
- export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string[];
157
+ /**
158
+ * The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule),
159
+ * deduplicated and sorted, bar `except`: the login refuses a client id held by two bindings, so the ids of the
160
+ * app hosts that have their own binding (in-app sign-in) are not in the company's.
161
+ */
162
+ export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, except?: readonly string[]): string[];
142
163
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
143
164
  export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
144
165
  /**
@@ -1,6 +1,7 @@
1
1
  import { ownsServices } from '../org.js';
2
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
3
- import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
2
+ import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, } from './agora.js';
3
+ import { BOULE_CLIENT_ID_SCOPE_KEY, bouleHostnameFor } from './boule.js';
4
+ import { SIGN_IN_IN_APP_SCOPE_KEY, inAppSignIn, inAppStatePairs, } from './sign-in.js';
4
5
  /**
5
6
  * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
6
7
  * need to agree on for an org's own company login host -- the hostname and
@@ -120,7 +121,10 @@ export function loginHostServesAgora(host, org, recorded) {
120
121
  if (!bound?.startsWith(`${host} `))
121
122
  return false;
122
123
  const clientId = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
124
+ // Agora's client moves to its own host's binding once that host signs in on the app's address.
125
+ const movedToAppHost = inAppStatePairs(recorded[SIGN_IN_IN_APP_SCOPE_KEY]).some((pair) => pair.clientId === clientId);
123
126
  return (!clientId ||
127
+ movedToAppHost ||
124
128
  bound
125
129
  .slice(host.length + 1)
126
130
  .split(',')
@@ -132,6 +136,23 @@ export function loginHostServesAgora(host, org, recorded) {
132
136
  * instance, so `destroy` must not remove it.
133
137
  */
134
138
  export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
139
+ /**
140
+ * The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
141
+ * Boule's, each only once its client id is recorded. Empty with the switch off. Archon (owner company
142
+ * only) is not here: the platform's Archon step owns its host (`sign-in-archon.ts`).
143
+ */
144
+ export function inAppApps(org, recorded) {
145
+ if (!inAppSignIn(org))
146
+ return [];
147
+ const apps = [];
148
+ const agora = recorded[AGORA_CLIENT_ID_SCOPE_KEY];
149
+ if (agora)
150
+ apps.push({ app: 'agora', host: agoraHostnameFor(org), clientId: agora });
151
+ const boule = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
152
+ if (boule)
153
+ apps.push({ app: 'boule', host: bouleHostnameFor(org), clientId: boule });
154
+ return apps.sort((a, b) => (a.host < b.host ? -1 : 1));
155
+ }
135
156
  /**
136
157
  * Scope id holding what the binding was last written with
137
158
  * ({@link loginBindingState}). Client ids are not secrets. The planner
@@ -149,8 +170,12 @@ export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
149
170
  export function customersEntryKeys(org) {
150
171
  return new Set(org.hosting.entries.filter((e) => e.uses?.auth?.users === 'customers').map((e) => e.key));
151
172
  }
152
- /** The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule), deduplicated and sorted. */
153
- export function loginHostClientIds(org, recorded) {
173
+ /**
174
+ * The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule),
175
+ * deduplicated and sorted, bar `except`: the login refuses a client id held by two bindings, so the ids of the
176
+ * app hosts that have their own binding (in-app sign-in) are not in the company's.
177
+ */
178
+ export function loginHostClientIds(org, recorded, except = []) {
154
179
  const customers = customersEntryKeys(org);
155
180
  const ids = [
156
181
  recorded[APP_CLIENT_ID_SCOPE_KEY],
@@ -162,7 +187,7 @@ export function loginHostClientIds(org, recorded) {
162
187
  !customers.has(key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)))
163
188
  .map(([, id]) => id),
164
189
  ].filter((id) => Boolean(id));
165
- return [...new Set(ids)].sort();
190
+ return [...new Set(ids)].filter((id) => !except.includes(id)).sort();
166
191
  }
167
192
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
168
193
  export function loginBindingState(host, clientIds) {
@@ -13,6 +13,7 @@ import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_NODE_IP_SCOPE_KEY,
13
13
  import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
14
14
  import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
15
15
  import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
16
+ import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn } from './sign-in.js';
16
17
  import { signinDbBackupMissing } from './signin-db-backup.js';
17
18
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, signinDbCertDue } from './signin-db-tls.js';
18
19
  import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db.js';
@@ -162,6 +163,11 @@ export function planOwnAuth(options) {
162
163
  recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
163
164
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
164
165
  (Boolean(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) || !brokerHoldsAnEntry) &&
166
+ // In-app sign-in: the second login app is recorded once it is deployed and holds the token, which
167
+ // needs the login client; until the broker holds an entry that cannot be, so the step waits.
168
+ (!inAppSignIn(org) ||
169
+ Boolean(recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY]) ||
170
+ !brokerHoldsAnEntry) &&
165
171
  !signinDbCertDue(recordedIds, options.now ?? Date.now()) &&
166
172
  !signinDbBackupMissing(recordedIds) &&
167
173
  // ZITADEL was started on the TLS database and never seen healthy there: the step starts it again.
@@ -2,18 +2,21 @@ import type { DestroyProgress, PlanApply } from '../apply.js';
2
2
  import type { DeclaredOrg, OrgId } from '../org.js';
3
3
  import type { Plan, PlanStep } from '../plan.js';
4
4
  import type { DeclaredPlatform } from '../platform.js';
5
+ import type { ReleaseStore } from '../release/state.js';
5
6
  import type { JobRunner } from '../runner/types.js';
6
7
  import type { AppliedState } from '../state.js';
7
8
  import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
8
9
  import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
9
10
  import { type AgoraHostPort } from './agora.js';
10
11
  import { type MoveGatePorts } from './backup-gate.js';
12
+ import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
11
13
  import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
12
14
  import { type StepAction } from './diff.js';
13
15
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
14
16
  import { type LoginHostDiscoveryPort } from './login-host.js';
15
17
  import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
16
18
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
19
+ import { type ReleaseContext, type ReleaseHost } from './release-host.js';
17
20
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
18
21
  export interface AuditActor {
19
22
  readonly id: string;
@@ -205,6 +208,17 @@ export interface StepExecutor {
205
208
  }>;
206
209
  /** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
207
210
  ownMailKeyMintable?(): boolean;
211
+ /** Every Coolify server with whether it is usable and reachable; blue/green plans copies on each. Optional: an executor without it plans no blue/green. */
212
+ blueGreenServers?(): Promise<Array<{
213
+ uuid: string;
214
+ usable: boolean;
215
+ reachable: boolean;
216
+ }>>;
217
+ /**
218
+ * The release runner's outside calls for one blue/green entry (`plan/release-host.ts`); `null` while its blue
219
+ * copy is not recorded. Optional: an executor without it runs no release.
220
+ */
221
+ releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
208
222
  /** Whether the executor's held keys can store the sign-in database's CA and certificate (#746). Absent: true. */
209
223
  ownSigninDbKeysPuttable?(): boolean;
210
224
  /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
@@ -325,6 +339,16 @@ export interface PlatformStepExecutor {
325
339
  * one), or `null` when nothing changed.
326
340
  */
327
341
  refreshFilesShare?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<Record<string, string> | null>;
342
+ /**
343
+ * The Coolify servers and the reason platform-archon's blue/green cannot run (the executor cannot hold the
344
+ * health token), for the plan. Optional: an executor without it plans no blue/green for archon.
345
+ */
346
+ archonBlueGreen?(): Promise<{
347
+ servers?: BlueGreenServer[];
348
+ blocked?: string;
349
+ }>;
350
+ /** The release runner's outside calls for platform-archon (`plan/release-host.ts`); `null` while its app is not recorded. */
351
+ releaseHost?(platform: DeclaredPlatform, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
328
352
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
329
353
  resourceId?: string;
330
354
  } | undefined>;
@@ -510,6 +534,11 @@ export interface FoundryPlanApplyOptions {
510
534
  step(action: StepAction): boolean;
511
535
  blocked(key: string): boolean;
512
536
  };
537
+ /**
538
+ * The release requests (Archon's promote, roll back, clear held and decide-at-cap clicks), consumed once each by
539
+ * the release runner. Without it no release runs, in an apply or in {@link FoundryPlanApply.runReleaseTick}.
540
+ */
541
+ releaseStore?: ReleaseStore;
513
542
  /** Overridable for tests; defaults to `() => new Date()`. */
514
543
  now?: () => Date;
515
544
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -605,6 +634,7 @@ export declare class FoundryPlanApply implements PlanApply {
605
634
  private readonly backupGate;
606
635
  private readonly platformDeps;
607
636
  private readonly runner;
637
+ private readonly releaseStore;
608
638
  constructor(options: FoundryPlanApplyOptions);
609
639
  plan(id: OrgId): Promise<Plan>;
610
640
  /**
@@ -741,6 +771,27 @@ export declare class FoundryPlanApply implements PlanApply {
741
771
  private refusePlatform;
742
772
  private refuseDestroy;
743
773
  private runSteps;
774
+ /**
775
+ * ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
776
+ * (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
777
+ * before this returns.
778
+ */
779
+ private runOrgReleases;
780
+ /** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
781
+ private runPlatformReleases;
782
+ private runPlatformReleaseTick;
783
+ /**
784
+ * The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
785
+ * archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
786
+ * no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
787
+ * apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
788
+ * A refusal is audited; a run that does nothing writes no audit row.
789
+ */
790
+ runReleaseTick(): Promise<Array<{
791
+ target: string;
792
+ error?: string;
793
+ }>>;
794
+ private runOrgReleaseTick;
744
795
  /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
745
796
  private readSfuPortsOpen;
746
797
  private computeFreshPlan;
@@ -8,6 +8,7 @@ import { StaleLeaseError, } from '../store.js';
8
8
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
9
9
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
10
10
  import { NetworkMoveBackupMissingError, SigninDbMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupForSigninMove, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
11
+ import { ARCHON_REPO, PRIMARY_SERVER_SCOPE_KEY, archonSubject, entrySubject, } from './blue-green.js';
11
12
  import { readLiveBuilds } from './build-record.js';
12
13
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
13
14
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
@@ -25,6 +26,8 @@ import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILE
25
26
  import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerChatFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
26
27
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
27
28
  import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
29
+ import { runSubjectRelease } from './release-host.js';
30
+ import { remoteDatabaseFactsFrom } from './remote-database.js';
28
31
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
29
32
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
30
33
  const companyScope = (orgId) => `company:${orgId}`;
@@ -119,6 +122,9 @@ export class PlatformConfirmationRequiredError extends Error {
119
122
  /** The steps that read an entry's hostnames: `computeFreshPlan` drops them for an org whose hosting is invalid. */
120
123
  const HOSTNAME_STEPS = new Set([
121
124
  'coolify:app',
125
+ 'coolify:bluegreen',
126
+ 'coolify:bluegreen-lb',
127
+ 'coolify:bluegreen-connector',
122
128
  'coolify:broker-entry',
123
129
  'coolify:database',
124
130
  'cloudflare:tunnel',
@@ -167,6 +173,7 @@ export class FoundryPlanApply {
167
173
  backupGate;
168
174
  platformDeps;
169
175
  runner;
176
+ releaseStore;
170
177
  constructor(options) {
171
178
  this.store = options.store;
172
179
  this.readers = options.readers;
@@ -195,6 +202,7 @@ export class FoundryPlanApply {
195
202
  this.backupGate = options.backupGate;
196
203
  this.platformDeps = options.platform;
197
204
  this.runner = options.runner;
205
+ this.releaseStore = options.releaseStore;
198
206
  }
199
207
  async plan(id) {
200
208
  const { plan } = await this.computeFreshPlan(id);
@@ -953,6 +961,13 @@ export class FoundryPlanApply {
953
961
  }
954
962
  checkpoints.push({ step: planned.step.description, completedAt: this.now().toISOString() });
955
963
  }
964
+ // The steps made the structure; the release runner then moves traffic on it, on the same live ids.
965
+ await this.runOrgReleases(org, {
966
+ recordedIds: context.recordedIds,
967
+ recordScopeId: context.recordScopeId,
968
+ forgetId: (key) => context.forgetId?.(key),
969
+ persist: context.persistScopeIds,
970
+ }, fence);
956
971
  }
957
972
  catch (e) {
958
973
  // #9 (resolved): "it stops at the checkpoint... there is no automatic
@@ -1012,6 +1027,135 @@ export class FoundryPlanApply {
1012
1027
  });
1013
1028
  return applied;
1014
1029
  }
1030
+ /**
1031
+ * ADR 0001: one release run for each blue/green entry whose structure is applied, writing into `context`
1032
+ * (the live ids of the run that holds the lease). A weight the runner changed reaches every server's balancer
1033
+ * before this returns.
1034
+ */
1035
+ async runOrgReleases(org, context, fence) {
1036
+ const store = this.releaseStore;
1037
+ const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1038
+ if (!store || !this.executor.releaseHost || !primaryUuid)
1039
+ return;
1040
+ for (const entry of org.hosting.entries) {
1041
+ const subject = entrySubject(org, entry, context.recordedIds());
1042
+ if (!subject)
1043
+ continue;
1044
+ const host = this.executor.releaseHost(org, subject, context, () => this.renewLease(fence));
1045
+ if (!host)
1046
+ continue;
1047
+ await runSubjectRelease({
1048
+ store,
1049
+ scopeOwnerId: org.id,
1050
+ subject,
1051
+ host,
1052
+ context,
1053
+ now: this.now(),
1054
+ primaryUuid,
1055
+ });
1056
+ }
1057
+ }
1058
+ /** One release run for platform-archon, on the platform's live ids; see {@link runOrgReleases}. */
1059
+ async runPlatformReleases(platform, context, fence) {
1060
+ const store = this.releaseStore;
1061
+ const executor = this.platformDeps?.executor;
1062
+ const subject = archonSubject(platform, ARCHON_REPO);
1063
+ const primaryUuid = context.recordedIds()[PRIMARY_SERVER_SCOPE_KEY];
1064
+ if (!store || !executor?.releaseHost || !subject || !primaryUuid)
1065
+ return;
1066
+ const host = executor.releaseHost(platform, subject, context, () => this.renewLease(fence));
1067
+ if (!host)
1068
+ return;
1069
+ await runSubjectRelease({
1070
+ store,
1071
+ scopeOwnerId: 'platform',
1072
+ subject,
1073
+ host,
1074
+ context,
1075
+ now: this.now(),
1076
+ primaryUuid,
1077
+ });
1078
+ }
1079
+ async runPlatformReleaseTick() {
1080
+ const deps = this.platformDeps;
1081
+ const platform = await deps?.store.get();
1082
+ if (!deps || !platform)
1083
+ return;
1084
+ const empty = { orgId: platform.ownerOrgId, steps: [], unmanaged: [] };
1085
+ if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
1086
+ await this.refusePlatform(empty, platform.ownerOrgId, 'not authorized to run the release tick');
1087
+ throw new NotAuthorizedError(this.actor, platform.ownerOrgId);
1088
+ }
1089
+ await this.withMutationLease(PLATFORM_SCOPE, (reason) => this.refusePlatform(empty, platform.ownerOrgId, reason), async (fence) => {
1090
+ const serviceIds = { ...(await deps.store.getServiceIds()) };
1091
+ await this.runPlatformReleases(platform, {
1092
+ recordedIds: () => ({ ...serviceIds }),
1093
+ recordScopeId: (key, value) => {
1094
+ serviceIds[key] = value;
1095
+ },
1096
+ forgetId: (key) => {
1097
+ delete serviceIds[key];
1098
+ },
1099
+ persist: () => deps.store.putServiceIds({ ...serviceIds }, fence),
1100
+ }, fence);
1101
+ });
1102
+ }
1103
+ /**
1104
+ * The recurring release run (ADR 0001): for every declared org with a blue/green entry, and the platform's
1105
+ * archon when it declares one, takes the open requests, reads the health signal and moves traffic. Foundry has
1106
+ * no scheduler of its own, so the host calls this every 10 minutes. An org that cannot run (the actor may not
1107
+ * apply it, an apply holds its lease, a provider call fails) is reported by id and does not stop the others.
1108
+ * A refusal is audited; a run that does nothing writes no audit row.
1109
+ */
1110
+ async runReleaseTick() {
1111
+ const results = [];
1112
+ for (const org of await this.store.list()) {
1113
+ if (!org.hosting.entries.some((entry) => entry.blueGreen))
1114
+ continue;
1115
+ try {
1116
+ await this.runOrgReleaseTick(org.id);
1117
+ results.push({ target: org.id });
1118
+ }
1119
+ catch (e) {
1120
+ results.push({ target: org.id, error: e instanceof Error ? e.message : String(e) });
1121
+ }
1122
+ }
1123
+ const platform = await this.platformDeps?.store.get();
1124
+ if (platform?.archon.enabled && platform.archon.blueGreen) {
1125
+ try {
1126
+ await this.runPlatformReleaseTick();
1127
+ results.push({ target: 'platform' });
1128
+ }
1129
+ catch (e) {
1130
+ results.push({ target: 'platform', error: e instanceof Error ? e.message : String(e) });
1131
+ }
1132
+ }
1133
+ return results;
1134
+ }
1135
+ async runOrgReleaseTick(id) {
1136
+ const empty = { orgId: id, steps: [], unmanaged: [] };
1137
+ if (!(await this.authz.canApply(this.actor, id))) {
1138
+ await this.refuse(empty, 'not authorized to run the release tick');
1139
+ throw new NotAuthorizedError(this.actor, id);
1140
+ }
1141
+ await this.withMutationLease(companyScope(id), (reason) => this.refuse(empty, reason), async (fence) => {
1142
+ const org = await this.store.get(id);
1143
+ const previous = await this.store.getAppliedState(id);
1144
+ if (!org || !previous)
1145
+ return;
1146
+ const serviceIds = { ...previous.serviceIds };
1147
+ await this.runOrgReleases(org, {
1148
+ recordedIds: () => ({ ...serviceIds }),
1149
+ recordScopeId: (key, value) => {
1150
+ serviceIds[key] = value;
1151
+ },
1152
+ forgetId: (key) => {
1153
+ delete serviceIds[key];
1154
+ },
1155
+ persist: () => this.store.putAppliedState({ ...previous, serviceIds: { ...serviceIds } }, fence),
1156
+ }, fence);
1157
+ });
1158
+ }
1015
1159
  /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
1016
1160
  async readSfuPortsOpen(org) {
1017
1161
  const slot = org.services?.sfuSlot;
@@ -1048,6 +1192,10 @@ export class FoundryPlanApply {
1048
1192
  : false;
1049
1193
  const sfuPortsOpen = await this.readSfuPortsOpen(org);
1050
1194
  const resumable = await this.ownResumableRefs(org);
1195
+ // Read only for an org that declares it; a failed read plans none and says so (`planBlueGreen`).
1196
+ const blueGreenServers = org.hosting.entries.some((e) => e.blueGreen) && this.executor.blueGreenServers
1197
+ ? await this.executor.blueGreenServers().catch(() => undefined)
1198
+ : undefined;
1051
1199
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
1052
1200
  loginHostBindings: this.loginHostBindings,
1053
1201
  loginHostServesDiscovery,
@@ -1066,6 +1214,18 @@ export class FoundryPlanApply {
1066
1214
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1067
1215
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1068
1216
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1217
+ ...(blueGreenServers ? { blueGreenServers } : {}),
1218
+ ...(blueGreenServers && org.hosting.entries.some((e) => e.blueGreen && e.database)
1219
+ ? {
1220
+ remoteDatabase: remoteDatabaseFactsFrom({
1221
+ org,
1222
+ servers: blueGreenServers,
1223
+ recorded,
1224
+ access: !platformReadiness.dnsBlocked ||
1225
+ ['dns', 'zones', 'access', 'tunnels'].every((port) => platformReadiness.ownEdgePorts?.[port]),
1226
+ }),
1227
+ }
1228
+ : {}),
1069
1229
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1070
1230
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1071
1231
  ...(this.backupGate
@@ -1391,8 +1551,21 @@ export class FoundryPlanApply {
1391
1551
  .map((org) => org.slug);
1392
1552
  const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs() : null;
1393
1553
  const owner = await this.platformOwnerInput(platform);
1554
+ const archonBlueGreen = platform.archon.enabled && platform.archon.blueGreen && executor.archonBlueGreen
1555
+ ? await executor.archonBlueGreen().catch(() => ({ servers: undefined }))
1556
+ : null;
1394
1557
  const detail = computePlatformPlanDetail(platform, live, {
1395
1558
  owner,
1559
+ ...(archonBlueGreen
1560
+ ? {
1561
+ archonBlueGreen: {
1562
+ servers: archonBlueGreen.servers,
1563
+ ...('blocked' in archonBlueGreen && archonBlueGreen.blocked
1564
+ ? { blocked: archonBlueGreen.blocked }
1565
+ : {}),
1566
+ },
1567
+ }
1568
+ : {}),
1396
1569
  jobsObservable: readers.coolify !== undefined,
1397
1570
  sfuBlocked,
1398
1571
  callsOrgSlugs,
@@ -1515,6 +1688,14 @@ export class FoundryPlanApply {
1515
1688
  serviceIds[`${system}:${kind}:${key}`] = result.resourceId;
1516
1689
  }
1517
1690
  }
1691
+ await this.runPlatformReleases(platform, {
1692
+ recordedIds: context.recordedIds,
1693
+ recordScopeId: context.recordScopeId,
1694
+ forgetId: (key) => {
1695
+ delete serviceIds[key];
1696
+ },
1697
+ persist: () => store.putServiceIds(serviceIds, fence),
1698
+ }, fence);
1518
1699
  }
1519
1700
  catch (e) {
1520
1701
  // Whatever completed stays recorded, as an org's apply does. A lost
@@ -1630,6 +1811,10 @@ function destroyTargets(serviceIds, orgId) {
1630
1811
  }
1631
1812
  continue;
1632
1813
  }
1814
+ // Blue/green: the colors, balancers, stage, database sidecars and extra connectors, by recorded id, before
1815
+ // the main database they connect to. Their keys carry no ':' (scope ids), so no prefix above finds them.
1816
+ if (prefix === 'coolify:database:')
1817
+ targets.push(...blueGreenDestroyTargets(serviceIds));
1633
1818
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1634
1819
  if (!resourceKey.startsWith(prefix) || !id)
1635
1820
  continue;
@@ -1745,10 +1930,44 @@ function destroyStepDescriptions(targets) {
1745
1930
  'delete the declared org',
1746
1931
  ];
1747
1932
  }
1933
+ /** Scope keys of the Coolify apps blue/green made: `bg.<entry>.app.<server>.<part>`, a database sidecar, a connector on another server. */
1934
+ const BLUE_GREEN_APP_KEY = /^(bg\.[^.:]+\.app\.[^.:]+\.[a-z]+|bgdb\.[^.:]+\.sidecar\.[^.:]+|cloudflare_tunnel_connector_app\.[^.:]+)$/;
1935
+ const BLUE_GREEN_DATABASE_KEY = /^bg\.[^.:]+\.stagedb$/;
1936
+ /** The database route's Cloudflare parts: its Access app, its service token (also one minted and never delivered) and its DNS record (recorded as the route). */
1937
+ const BLUE_GREEN_EDGE_KEYS = [
1938
+ [/^bgdb\.[^.:]+\.access$/, 'bg-access'],
1939
+ [/^bgdb\.[^.:]+\.(token|undelivered)$/, 'bg-token'],
1940
+ [/^bgdb\.[^.:]+\.route$/, 'bg-dns'],
1941
+ ];
1942
+ /** Every recorded blue/green app (then stage's database), in key order. A destroy target's `key` is the scope key itself. */
1943
+ function blueGreenDestroyTargets(serviceIds) {
1944
+ const keys = Object.keys(serviceIds).sort();
1945
+ const targets = [];
1946
+ for (const key of keys.filter((k) => BLUE_GREEN_APP_KEY.test(k))) {
1947
+ const id = serviceIds[key];
1948
+ if (id)
1949
+ targets.push({ system: 'coolify', kind: 'bg-app', key, id });
1950
+ }
1951
+ for (const key of keys.filter((k) => BLUE_GREEN_DATABASE_KEY.test(k))) {
1952
+ const id = serviceIds[key];
1953
+ if (id)
1954
+ targets.push({ system: 'coolify', kind: 'bg-database', key, id });
1955
+ }
1956
+ for (const [pattern, kind] of BLUE_GREEN_EDGE_KEYS) {
1957
+ for (const key of keys.filter((k) => pattern.test(k))) {
1958
+ const id = serviceIds[key];
1959
+ if (id)
1960
+ targets.push({ system: 'cloudflare', kind, key, id });
1961
+ }
1962
+ }
1963
+ return targets;
1964
+ }
1748
1965
  /** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
1749
1966
  function destroyServiceIdKey(target) {
1750
1967
  if (target.kind === 'project' || target.kind === 'org')
1751
1968
  return target.key;
1969
+ if (target.kind.startsWith('bg-'))
1970
+ return target.key;
1752
1971
  if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
1753
1972
  return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
1754
1973
  }
@@ -1781,6 +2000,9 @@ const STEP_FIELDS = {
1781
2000
  'ai:key': 'ai',
1782
2001
  'cms:site': 'site',
1783
2002
  'coolify:app': 'hosting.enabled',
2003
+ 'coolify:bluegreen': 'hosting.entries.blueGreen',
2004
+ 'coolify:bluegreen-lb': 'hosting.entries.blueGreen',
2005
+ 'coolify:bluegreen-connector': 'hosting.entries.blueGreen',
1784
2006
  'coolify:database': 'hosting.database',
1785
2007
  'cloudflare:tunnel': 'hosting.tunnel',
1786
2008
  'cloudflare:zone': 'hosting.usesDomain',
@@ -1,6 +1,7 @@
1
1
  import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
2
2
  import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
+ import { type BlueGreenServer } from './blue-green.js';
4
5
  import type { PlannedStep } from './diff.js';
5
6
  import type { LiveResource } from './live.js';
6
7
  import { type MailRoute, PLATFORM_MAIL_RELAY_KEY } from './mail-relay.js';
@@ -1092,6 +1093,14 @@ export declare function uptimeRecorded(recordedIds: Readonly<Record<string, stri
1092
1093
  * the merge comment that named this shape (#189).
1093
1094
  */
1094
1095
  export interface PlanPlatformOptions {
1096
+ /**
1097
+ * What `platform.archon.blueGreen` needs from the host: every Coolify server (`undefined`: it cannot list them)
1098
+ * and why the executor cannot run it, if it cannot. Absent: nothing is planned for it.
1099
+ */
1100
+ archonBlueGreen?: {
1101
+ servers: readonly BlueGreenServer[] | undefined;
1102
+ blocked?: string;
1103
+ };
1095
1104
  /** Whether the host supplies a Coolify reader for the platform (default `true`). */
1096
1105
  jobsObservable?: boolean;
1097
1106
  /** Why the shared SFU is blocked, or `null` when it is not (default `null`). */
@@ -3,6 +3,7 @@ import { VERSIONS } from '../bootstrap/versions.js';
3
3
  import { APP_ENTRY_KEY } from '../org.js';
4
4
  import { heldEnvRef } from '../org.js';
5
5
  import { VERSION_CHANGE_KIND, } from '../plan.js';
6
+ import { ARCHON_REPO, planArchonBlueGreen } from './blue-green.js';
6
7
  import { buildDriftNotes } from './build-record.js';
7
8
  import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
8
9
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
@@ -11,6 +12,7 @@ import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
11
12
  import { MAIL_DATABASE_SCOPE_KEY, mailStoreIsLegacy, mailStoreSwitchChange, mailStoreSwitchDescription, } from './mail-store.js';
12
13
  import { planOikosIngress } from './oikos-ingress.js';
13
14
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
15
+ import { archonInAppHost } from './sign-in-archon.js';
14
16
  import { signInDomainBlockedText } from './sign-in-org.js';
15
17
  import { withStepSystem } from './step-system.js';
16
18
  /**
@@ -1267,7 +1269,7 @@ export function uptimeRecorded(recordedIds) {
1267
1269
  * unchanged).
1268
1270
  */
1269
1271
  export function planPlatform(platform, live, options = {}) {
1270
- const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailRoutesBlocked = [], recordedIds = {}, owner, liveBuilds = {}, blocked = {}, drifted = {}, } = options;
1272
+ const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailRoutesBlocked = [], recordedIds = {}, owner, liveBuilds = {}, blocked = {}, drifted = {}, archonBlueGreen, } = options;
1271
1273
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
1272
1274
  const claimed = new Set();
1273
1275
  const plannedSteps = [];
@@ -1385,6 +1387,27 @@ export function planPlatform(platform, live, options = {}) {
1385
1387
  const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld, blocked.uptimeChat);
1386
1388
  if (uptime.step)
1387
1389
  plannedSteps.push(uptime.step);
1390
+ // Archon's app and tunnel are recorded by an earlier apply, so this runs before a last-placed archon step.
1391
+ const archonBg = archonBlueGreen
1392
+ ? planArchonBlueGreen({
1393
+ platform,
1394
+ repo: ARCHON_REPO,
1395
+ recorded: recordedIds,
1396
+ servers: archonBlueGreen.servers,
1397
+ executorBlocked: archonBlueGreen.blocked,
1398
+ })
1399
+ : { step: null, unmanaged: [] };
1400
+ if (archonBg.step) {
1401
+ plannedSteps.push({
1402
+ action: {
1403
+ system: 'coolify',
1404
+ kind: 'archon-bluegreen',
1405
+ key: archonBg.step.key,
1406
+ desired: archonBg.step.desired,
1407
+ },
1408
+ step: { description: archonBg.step.description, destroys: false, migrates: false },
1409
+ });
1410
+ }
1388
1411
  if (archon.step && blocked.archonRunsInsideApp)
1389
1412
  plannedSteps.push(archon.step);
1390
1413
  // After Archon's own step: a repair that fails must not hold up the work before it.
@@ -1486,6 +1509,7 @@ export function planPlatform(platform, live, options = {}) {
1486
1509
  unmanaged.push({ description: archonOrg.blocked, key: 'archon-org' });
1487
1510
  if (archon.blocked)
1488
1511
  unmanaged.push({ description: archon.blocked, key: 'archon' });
1512
+ unmanaged.push(...archonBg.unmanaged);
1489
1513
  // #398: the old integrations key stays pinned until Archon holds the new
1490
1514
  // id; name that when Archon is what is stopping it.
1491
1515
  if (archon.blocked &&
@@ -3055,6 +3079,9 @@ function planArchon(platform, recordedIds, executorBlocked, ownerSignIn, ownerFi
3055
3079
  recordedIds[PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY] === ownerSignIn.organisationId &&
3056
3080
  (recordedIds[PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY] ?? '') ===
3057
3081
  archonFirstOwnerLink(recordedIds, ownerSignIn) &&
3082
+ // In-app sign-in: Archon's own address is the sign-in address only once the owner company has the
3083
+ // switch on and its in-app login is applied, and goes back to the company's login host when it does not.
3084
+ archonInAppHost(recordedIds) === (ownerSignIn.signInInApp ? platform.archon.hostname : '') &&
3058
3085
  recordedIds[PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY] === archonOrgId &&
3059
3086
  !recordedIds[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY] &&
3060
3087
  Boolean(recordedIds[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY]) &&