@wtfalch/foundry 0.17.0 → 0.17.1

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.
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -15,7 +15,7 @@ export { AdoptRefusedError } from './adopt.js';
15
15
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
16
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
18
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformNamesDeps, PlatformNamesRuntime, } from './platform-providers.js';
19
19
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
20
20
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
21
21
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -26,9 +26,11 @@ export interface LiveResource {
26
26
  * `platform-diff.ts`'s `planSfu` always plans its step when
27
27
  * `DeclaredPlatform.sfu.enabled` is true, rather than diffing against a
28
28
  * live resource the way every other system here does. It is still part of
29
- * this union so `StepAction.system` (`diff.ts`) can name it.
29
+ * this union so `StepAction.system` (`diff.ts`) can name it. `'names'`
30
+ * (#317) is the same: a names key has no live lookup of its own, the same
31
+ * shape `ai`/`files` entry keys already follow.
30
32
  */
31
- system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu';
33
+ system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'names';
32
34
  kind: string;
33
35
  key: string;
34
36
  config: Record<string, unknown>;
@@ -21,8 +21,19 @@ import type { DeclaredOrg } from '../org.js';
21
21
  * `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
22
22
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
23
23
  * way `AGORA_SUFFIX` reserves `-agora`.
24
+ *
25
+ * #488: `auth.<domain>` is refused when it would equal `platformAuthHostname`
26
+ * -- the platform's own org, declared with-domain on the platform's own
27
+ * domain, would otherwise compute the shared ZITADEL instance's own login
28
+ * host (`platform.auth.hostname`, e.g. `auth.companyincriminated.com` ->
29
+ * `auth.companyincluded.com`). Archon's login-host write throws on that
30
+ * duplicate host (`archon`'s `validateLoginHostRegistry`); if it were ever
31
+ * written instead, `auth`'s own `company-host.ts` denies every request
32
+ * through a duplicate host, the shared one included. `platformAuthHostname`
33
+ * is `null` when the caller has none to compare against (no platform
34
+ * declared, or sign-in not ready yet), in which case this never refuses.
24
35
  */
25
- export declare function loginHostnameFor(org: DeclaredOrg): string;
36
+ export declare function loginHostnameFor(org: DeclaredOrg, platformAuthHostname?: string | null): string;
26
37
  /**
27
38
  * One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
28
39
  * (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
@@ -22,12 +22,30 @@ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
22
22
  * `RESERVED_AUTH_AND_ENVIRONMENTS` already reserves the `auth` *slug* so no
23
23
  * org's own `<slug>.oikos.host` can collide with this subdomain, the same
24
24
  * way `AGORA_SUFFIX` reserves `-agora`.
25
+ *
26
+ * #488: `auth.<domain>` is refused when it would equal `platformAuthHostname`
27
+ * -- the platform's own org, declared with-domain on the platform's own
28
+ * domain, would otherwise compute the shared ZITADEL instance's own login
29
+ * host (`platform.auth.hostname`, e.g. `auth.companyincriminated.com` ->
30
+ * `auth.companyincluded.com`). Archon's login-host write throws on that
31
+ * duplicate host (`archon`'s `validateLoginHostRegistry`); if it were ever
32
+ * written instead, `auth`'s own `company-host.ts` denies every request
33
+ * through a duplicate host, the shared one included. `platformAuthHostname`
34
+ * is `null` when the caller has none to compare against (no platform
35
+ * declared, or sign-in not ready yet), in which case this never refuses.
25
36
  */
26
- export function loginHostnameFor(org) {
37
+ export function loginHostnameFor(org, platformAuthHostname = null) {
27
38
  // `issuer.usesDomain` is the issuer's opt-in into the org's domain
28
39
  // (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
29
40
  // with `usesDomain` false stays on the tier 1 host.
30
- return org.domain && org.issuer.usesDomain ? `auth.${org.domain}` : `${org.slug}-auth.oikos.host`;
41
+ if (org.domain && org.issuer.usesDomain) {
42
+ const host = `auth.${org.domain}`;
43
+ if (platformAuthHostname && host === platformAuthHostname) {
44
+ return `${org.slug}-auth.oikos.host`;
45
+ }
46
+ return host;
47
+ }
48
+ return `${org.slug}-auth.oikos.host`;
31
49
  }
32
50
  /**
33
51
  * Scope id holding what the binding was last written with
@@ -333,6 +333,12 @@ export interface FoundryPlanApplyOptions {
333
333
  * reported blocked instead of planned.
334
334
  */
335
335
  broker?: boolean;
336
+ /**
337
+ * #318: true when the host's executor was built with a `WorkersPort`.
338
+ * Optional, and false without it: each entry's `edge` Worker is then
339
+ * reported blocked instead of planned.
340
+ */
341
+ edgeWorkers?: boolean;
336
342
  /**
337
343
  * #177: true when the host's executor was built with its own `filesOrganisation`
338
344
  * / `aiOrganisation` port. Without one, the org step needs the platform's
@@ -340,6 +346,12 @@ export interface FoundryPlanApplyOptions {
340
346
  */
341
347
  filesOrganisation?: boolean;
342
348
  aiOrganisation?: boolean;
349
+ /**
350
+ * #317: true when the host's executor was built with a `NamesPort`.
351
+ * Optional, and false without it: each entry's `uses.names` step is then
352
+ * reported blocked instead of planned to fail.
353
+ */
354
+ names?: boolean;
343
355
  /** Overridable for tests; defaults to `() => new Date()`. */
344
356
  now?: () => Date;
345
357
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -416,8 +428,10 @@ export declare class FoundryPlanApply implements PlanApply {
416
428
  private readonly jobsScheduler;
417
429
  private readonly loginHostBindings;
418
430
  private readonly broker;
431
+ private readonly edgeWorkers;
419
432
  private readonly hostFiles;
420
433
  private readonly hostAi;
434
+ private readonly hostNames;
421
435
  private readonly now;
422
436
  private readonly leaseTtlMs;
423
437
  private readonly appliedVersions;
@@ -7,8 +7,10 @@ import { StaleLeaseError, } from '../store.js';
7
7
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
8
8
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
9
9
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
10
+ import { edgeWorkerTraceScopeKeys, liveEdgeWorkers } from './edge-worker.js';
10
11
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
11
12
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
13
+ import { entryNamesRetiredScopeKey, entryNamesUndeliveredScopeKey } from './entry-names-key.js';
12
14
  import { readLivePlatformResources, readLiveResources, } from './live.js';
13
15
  import { lockedHostnames } from './locked-hostnames.js';
14
16
  import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
@@ -97,6 +99,9 @@ const HOSTNAME_STEPS = new Set([
97
99
  'cloudflare:tunnel',
98
100
  'cloudflare:access',
99
101
  'jobs:registration',
102
+ // #318 (F8): the route is itself hostname-shaped, validated against the
103
+ // org's own domain by `validateEntryEdge`.
104
+ 'cloudflare:worker',
100
105
  ]);
101
106
  /**
102
107
  * The #19 implementation of {@link PlanApply}: computes a plan against live
@@ -116,8 +121,10 @@ export class FoundryPlanApply {
116
121
  jobsScheduler;
117
122
  loginHostBindings;
118
123
  broker;
124
+ edgeWorkers;
119
125
  hostFiles;
120
126
  hostAi;
127
+ hostNames;
121
128
  now;
122
129
  leaseTtlMs;
123
130
  appliedVersions;
@@ -135,8 +142,10 @@ export class FoundryPlanApply {
135
142
  this.jobsScheduler = options.jobsScheduler ?? false;
136
143
  this.loginHostBindings = options.loginHostBindings ?? false;
137
144
  this.broker = options.broker ?? false;
145
+ this.edgeWorkers = options.edgeWorkers ?? false;
138
146
  this.hostFiles = options.filesOrganisation ?? false;
139
147
  this.hostAi = options.aiOrganisation ?? false;
148
+ this.hostNames = options.names ?? false;
140
149
  this.now = options.now ?? (() => new Date());
141
150
  this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
142
151
  this.appliedVersions = options.appliedVersions;
@@ -477,7 +486,7 @@ export class FoundryPlanApply {
477
486
  const serviceIds = { ...(state?.serviceIds ?? {}) };
478
487
  let targets;
479
488
  try {
480
- targets = destroyTargets(serviceIds);
489
+ targets = destroyTargets(serviceIds, id);
481
490
  }
482
491
  catch (e) {
483
492
  await this.refuseDestroy(id, e instanceof Error ? e.message : String(e));
@@ -530,6 +539,14 @@ export class FoundryPlanApply {
530
539
  // gone" itself.
531
540
  await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
532
541
  delete serviceIds[destroyServiceIdKey(target)];
542
+ if (target.system === 'cloudflare' && target.kind === 'worker') {
543
+ // #318: its partial-deploy traces too, or a retry after a later
544
+ // step fails would read this Worker as live again.
545
+ const entryKey = target.key.slice(id.length + 1);
546
+ for (const scopeKey of edgeWorkerTraceScopeKeys(entryKey)) {
547
+ delete serviceIds[scopeKey];
548
+ }
549
+ }
533
550
  steps[current] = {
534
551
  step: descriptions[current],
535
552
  status: 'finished',
@@ -593,7 +610,7 @@ export class FoundryPlanApply {
593
610
  const state = await this.store.getAppliedState(id);
594
611
  const serviceIds = { ...(state?.serviceIds ?? {}) };
595
612
  return {
596
- steps: destroyStepDescriptions(destroyTargets(serviceIds)),
613
+ steps: destroyStepDescriptions(destroyTargets(serviceIds, id)),
597
614
  kept: keptDescriptions(serviceIds),
598
615
  };
599
616
  }
@@ -855,6 +872,7 @@ export class FoundryPlanApply {
855
872
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
856
873
  loginHostBindings: this.loginHostBindings,
857
874
  broker: this.broker,
875
+ edgeWorkers: this.edgeWorkers,
858
876
  otherSlugs,
859
877
  now: () => this.now().getTime(),
860
878
  archonUrl,
@@ -909,7 +927,7 @@ export class FoundryPlanApply {
909
927
  refs.add(ORG_PLATFORM_KEY_REFS.ai);
910
928
  if (this.jobsScheduler)
911
929
  refs.add(ORG_PLATFORM_KEY_REFS.jobs);
912
- const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys);
930
+ const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys, this.hostNames);
913
931
  // Entry env from the platform's mail key or a shared secret (#T8).
914
932
  let mailKeyHeld = false;
915
933
  try {
@@ -1112,7 +1130,7 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
1112
1130
  * belongs to is already left in place -- deleting only the rule and not the
1113
1131
  * zone is a decision left for whoever wires teardown in, not made here.
1114
1132
  */
1115
- function destroyTargets(serviceIds) {
1133
+ function destroyTargets(serviceIds, orgId) {
1116
1134
  // Reverse-teardown order: everything under one of these prefixes is
1117
1135
  // collected in this order, then every ZITADEL app (there can be several,
1118
1136
  // #71), then the two scope ids last.
@@ -1120,6 +1138,8 @@ function destroyTargets(serviceIds) {
1120
1138
  'zitadel:login-host:',
1121
1139
  'cloudflare:tunnel:',
1122
1140
  'cloudflare:access:',
1141
+ // #318: an entry's edge Worker and its route.
1142
+ 'cloudflare:worker:',
1123
1143
  'coolify:database:',
1124
1144
  'coolify:app:',
1125
1145
  'zitadel:customers:',
@@ -1127,10 +1147,27 @@ function destroyTargets(serviceIds) {
1127
1147
  'ai:key:',
1128
1148
  // F6b: an entry's files key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
1129
1149
  'files:key:',
1150
+ // F7 (#317): an entry's names key, `<orgId>:<entry>` -- names has no org-root key.
1151
+ 'names:key:',
1130
1152
  ];
1131
1153
  const targets = [];
1132
1154
  const adopted = adoptedResourceIds(serviceIds);
1133
1155
  for (const prefix of ORDERED_PREFIXES) {
1156
+ if (prefix === 'cloudflare:worker:') {
1157
+ // #318 re-review: not the main ids alone -- `liveEdgeWorkers` also
1158
+ // finds an upload-only or route-only partial deploy, and leaves out an
1159
+ // entry whose Worker is already retired (nothing live, so a host with
1160
+ // no WorkersPort is not blocked from destroying the org by it).
1161
+ for (const [entryKey, scriptName] of liveEdgeWorkers(orgId, serviceIds)) {
1162
+ targets.push({
1163
+ system: 'cloudflare',
1164
+ kind: 'worker',
1165
+ key: `${orgId}:${entryKey}`,
1166
+ id: scriptName,
1167
+ });
1168
+ }
1169
+ continue;
1170
+ }
1134
1171
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1135
1172
  if (!resourceKey.startsWith(prefix))
1136
1173
  continue;
@@ -1141,10 +1178,49 @@ function destroyTargets(serviceIds) {
1141
1178
  continue;
1142
1179
  if (prefix === 'files:key:' && !resourceKey.slice(prefix.length).includes(':'))
1143
1180
  continue;
1181
+ // A key a retire step already revoked before org destroy ever ran
1182
+ // (#317 review): `runSteps` never clears this main id on a step that
1183
+ // returns no resourceId, so without this check destroy would target
1184
+ // an already-revoked key and get stuck on names' 404 forever.
1185
+ if (prefix === 'names:key:') {
1186
+ // `resourceKey.slice(prefix.length)` is `<orgId>:<entryKey>`, but
1187
+ // `entryNamesRetiredScopeKey` takes the bare entry key alone (as
1188
+ // every caller in entry-names-key.ts/executor.ts gives it) --
1189
+ // entry keys hold no `:` of their own, so the first one found is
1190
+ // exactly the org id/entry key boundary.
1191
+ const fullKey = resourceKey.slice(prefix.length);
1192
+ const entryKey = fullKey.slice(fullKey.indexOf(':') + 1);
1193
+ if (serviceIds[entryNamesRetiredScopeKey(entryKey)] === id)
1194
+ continue;
1195
+ }
1144
1196
  const [system, kind] = prefix.split(':');
1145
1197
  targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
1146
1198
  }
1147
1199
  }
1200
+ // F7 (#317): a key minted but never delivered never gets the main
1201
+ // `names:key:<org>:<entry>` id recorded (the mint step throws before
1202
+ // `runSteps` sees a `resourceId`), so the loop above never finds it to
1203
+ // revoke on destroy. Skipped if a later mint already succeeded and holds
1204
+ // its own, still-live main id -- entry keys hold no `:`, so matching the
1205
+ // suffix is unambiguous. A main id that equals the entry's retired marker
1206
+ // does not count as "later": mint A, deliver, retire (main id A, retired
1207
+ // marker A); re-declare, mint B, fail delivery three times (undelivered
1208
+ // B, main id stays A). Without this check, A's stale-but-truthy main id
1209
+ // hid B from destroy, leaking it the same way the gone-scan in
1210
+ // `entry-names-key.ts` once did (#317 re-review).
1211
+ const undeliveredNamesPrefix = 'names_key_undelivered_';
1212
+ for (const [resourceKey, id] of Object.entries(serviceIds)) {
1213
+ if (!id || !resourceKey.startsWith(undeliveredNamesPrefix))
1214
+ continue;
1215
+ const entryKey = resourceKey.slice(undeliveredNamesPrefix.length);
1216
+ const hasLaterMainId = Object.entries(serviceIds).some(([k, v]) => v &&
1217
+ k.startsWith('names:key:') &&
1218
+ k.endsWith(`:${entryKey}`) &&
1219
+ serviceIds[entryNamesRetiredScopeKey(entryKey)] !== v);
1220
+ if (hasLaterMainId)
1221
+ continue;
1222
+ targets.push({ system: 'names', kind: 'key', key: entryKey, id });
1223
+ }
1148
1224
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1149
1225
  if (!resourceKey.startsWith('zitadel:app:'))
1150
1226
  continue;
@@ -1226,6 +1302,7 @@ const STEP_FIELDS = {
1226
1302
  'cloudflare:zone': 'hosting.usesDomain',
1227
1303
  'cloudflare:access': 'hosting.accessEmails',
1228
1304
  'cloudflare:ratelimit': 'hosting.usesDomain',
1305
+ 'cloudflare:worker': 'hosting.entries.edge',
1229
1306
  };
1230
1307
  function stepField(action) {
1231
1308
  // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
@@ -1235,6 +1312,9 @@ function stepField(action) {
1235
1312
  // The org's own files key (F6b) is keyed `<orgId>`, an entry's `<orgId>:<entry>`.
1236
1313
  if (action.system === 'files' && action.kind === 'key')
1237
1314
  return 'hosting.entries.uses.files';
1315
+ // An entry's names key (F7): always `<orgId>:<entry>`, names has no org-root key.
1316
+ if (action.system === 'names' && action.kind === 'key')
1317
+ return 'hosting.entries.uses.names';
1238
1318
  // An org declares several issuer apps, each keyed by its name (#71).
1239
1319
  if (action.system === 'zitadel' && action.kind === 'app') {
1240
1320
  return `hosting.issuerApps.${action.key}`;
@@ -1283,6 +1363,17 @@ function failedFields(org, detail, completed, serviceIds) {
1283
1363
  }
1284
1364
  }
1285
1365
  }
1366
+ // The same for an entry's names key (F7): minted, never delivered is no
1367
+ // longer blocked forever on its own (the next apply revokes it and
1368
+ // mints again, #317 re-review) -- but while that retry step is itself
1369
+ // not planned this run (no names service wired, say), the leftover
1370
+ // undelivered id must still read as unresolved rather than the org
1371
+ // converging silently around it.
1372
+ for (const entry of org.hosting.entries) {
1373
+ if (entry.uses?.names && serviceIds[entryNamesUndeliveredScopeKey(entry.key)]) {
1374
+ failed.add('hosting.entries.uses.names');
1375
+ }
1376
+ }
1286
1377
  // The same for a jobs registration whose credentials never reached the host.
1287
1378
  if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
1288
1379
  !serviceIds[`jobs:registration:${org.id}`]) {
@@ -118,6 +118,23 @@ export declare const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = "archon_applied
118
118
  * unrecorded and the old key stays pinned.
119
119
  */
120
120
  export declare const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = "archon_integrations_key_synced";
121
+ /**
122
+ * #467: sha256 of the exact env set the *last confirmed* Archon deploy ran
123
+ * with, recorded only once that deploy's `waitForHealthyVersion` passed --
124
+ * mirroring {@link PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY}'s own
125
+ * confirm-after-health-check timing, generalized from one env to all of
126
+ * them. `provisionPlatformArchon` clears this to `''` the moment it decides
127
+ * a redeploy is needed, before attempting it: a deploy that then fails (or
128
+ * a crash before the health check passes) must leave the attempt
129
+ * unconfirmed, and the env hash alone cannot tell a failed retry's intended
130
+ * content apart from a successful one's, since both intend the same bytes.
131
+ * Without this, a run that writes new envs and then fails before Archon
132
+ * redeploys -- `envsDiffer` comparing desired envs against the *live*
133
+ * store, which this run's own write already updated -- leaves a retry
134
+ * reading no difference at all, and the container stays on the stale
135
+ * values until someone redeploys by hand (#467).
136
+ */
137
+ export declare const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = "archon_envs_deployed_hash";
121
138
  /**
122
139
  * #211 round 4: the broker app id Archon last registered its entry with.
123
140
  * `provisionPlatformArchon` reads the broker to register with from
@@ -136,6 +153,21 @@ export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_
136
153
  * has recorded it.
137
154
  */
138
155
  export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applied_bootstrap_operator";
156
+ /**
157
+ * #475: whether the last successful Archon apply patched the app with
158
+ * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
159
+ * instead of the old fixed `custom_internal_name` alone. Coolify only runs a
160
+ * rolling, health-checked deploy -- start the new container, wait, then stop
161
+ * the old one -- when the consistent-name flag is off; with it on (set by
162
+ * foundry's own pre-#475 applies, or by hand in the Coolify UI) every deploy
163
+ * stops the old container before the new one exists, which is what took
164
+ * Archon down for #475's incident. Absent on every platform applied before
165
+ * this fix landed; `planArchon` plans the step again until
166
+ * `provisionPlatformArchon` records it, which only happens once that run's
167
+ * redeploy has passed its health check -- the same confirm-after-health-check
168
+ * timing {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY} uses.
169
+ */
170
+ export declare const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = "archon_applied_rolling_update";
139
171
  /**
140
172
  * The owner's ZITADEL user id Archon takes as its first operator, from
141
173
  * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
@@ -409,6 +441,18 @@ export declare function appEnvAppliedScopeKey(entryKey: string): string;
409
441
  * would leave a stale container that no plan notices.
410
442
  */
411
443
  export declare function dbEnvAppliedScopeKey(entryKey: string): string;
444
+ /**
445
+ * #456: the same marker as {@link dbEnvAppliedScopeKey}, for the chat and
446
+ * calls-service sibling platform services' own owner/runtime database split
447
+ * -- in its own `service_db_env_applied.` namespace, never
448
+ * `dbEnvAppliedScopeKey`'s `db_env_applied.`. Those two services are not
449
+ * hosting entries, but `dbEnvAppliedScopeKey`'s `entryKey` is an arbitrary
450
+ * caller-chosen string with no reserved words (`validateHostingEntry` only
451
+ * requires a DNS label), so a hosting entry a company declares with the key
452
+ * `chat` or `calls-service` would otherwise read or clear the sibling
453
+ * service's own marker, and vice versa.
454
+ */
455
+ export declare function serviceDbEnvAppliedScopeKey(service: 'chat' | 'calls-service'): string;
412
456
  /**
413
457
  * The settings `coolifyApp` owns on an entry's app (`folder`, `port`,
414
458
  * `autoDeploy`) as the JSON the marker below holds. `coolifyApp` records it
@@ -577,6 +621,50 @@ export declare const PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY = "files_ma
577
621
  */
578
622
  export declare const PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY = "files_applied_hostname";
579
623
  export declare const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = "files_applied_git_ref";
624
+ /**
625
+ * #478: the resource key `planNames` plans under and
626
+ * `FoundryPlatformStepExecutor` dispatches on -- the {@link PLATFORM_FILES_KEY}
627
+ * shape for the names host (https://github.com/wtfalch/names). `runPlatformSteps`
628
+ * records the app's uuid under `coolify:app:platform:names` once the step
629
+ * succeeds, which is what stops `planNames` planning it again.
630
+ */
631
+ export declare const PLATFORM_NAMES_KEY = "platform:names";
632
+ /**
633
+ * #478: the held-key ref the names platform secret's full value is
634
+ * delivered under, once, by {@link FoundryStepExecutorPorts.deliverSecret} --
635
+ * never the host's app env, which gets only its SHA-256 hash
636
+ * (`NAMES_PLATFORM_KEY_HASH`). Archon's own `NamesPort` reads the full
637
+ * secret back from this ref to call names' `mintKey`/`revokeKey`/
638
+ * `setReservedLabels`.
639
+ */
640
+ export declare const PLATFORM_NAMES_KEY_REF = "names:platform:key";
641
+ /**
642
+ * #478: the held-key ref for names' own Cloudflare API token (tunnel edit +
643
+ * DNS edit on one account) -- the issue's own "never Archon's token": this
644
+ * is a narrower, names-only credential a human provisions and holds
645
+ * separately from whatever broader token `PlatformNamesDeps.cloudflare`
646
+ * uses to stand the host itself up on Coolify.
647
+ */
648
+ export declare const PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF = "cloudflare:names:apiToken";
649
+ /**
650
+ * #478: scope keys (colon-free) for the names platform key, the
651
+ * {@link PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY} pattern once:
652
+ * the undelivered id is recorded and persisted the moment the secret is
653
+ * minted, before delivery; the guard is recorded only once it was delivered
654
+ * AND its hash written to the runner's env. An undelivered id with no guard
655
+ * means the secret is lost (never kept past that point): the planner
656
+ * reports it and never mints again. Neither key holds the secret itself.
657
+ */
658
+ export declare const PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = "names_platform_key_undelivered";
659
+ export declare const PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY = "names_platform_key_set";
660
+ /**
661
+ * #478: the hostname and `gitRef` the last successful names run applied.
662
+ * `planNames` plans the step again when either differs from the declared
663
+ * value; a re-run updates `git_branch` and the runner env and redeploys,
664
+ * and mints no new platform key (the guards above hold).
665
+ */
666
+ export declare const PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY = "names_applied_hostname";
667
+ export declare const PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY = "names_applied_git_ref";
580
668
  /**
581
669
  * Options for {@link planPlatform}, replacing four positional arguments that
582
670
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -615,6 +703,8 @@ export interface PlanPlatformOptions {
615
703
  ai?: string;
616
704
  archon?: string;
617
705
  mail?: string;
706
+ /** #478: why the names host is blocked, or absent when it is not. */
707
+ names?: string;
618
708
  /**
619
709
  * #257: true once a service's database exists but the host built the
620
710
  * platform executor with no backup dependencies -- every such service's
@@ -666,7 +756,8 @@ export interface PlanPlatformOptions {
666
756
  * Scope for #167: the frame plus one step, {@link planApexZone}. #173 adds
667
757
  * {@link planJobs}, #170 adds {@link planSfu}, #168 adds {@link planAuth},
668
758
  * #169 adds {@link planMail}, #174 adds {@link planIntegrations}, #171 adds
669
- * {@link planFiles}, #172 adds {@link planAi} and #175 adds
759
+ * {@link planFiles}, #172 adds {@link planAi}, #478 adds {@link planNames}
760
+ * and #175 adds
670
761
  * {@link planArchon}, the same way #19 shipped org plan/apply against
671
762
  * three singletons before #27, #71, #80 and the rest added the remaining
672
763
  * twelve. Until then, a live resource under one of those eventual keys is