@wtfalch/foundry 0.17.0 → 0.18.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 (38) hide show
  1. package/dist/bootstrap/declare.js +6 -4
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/migrations/0043_platform_names.sql +25 -0
  5. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  6. package/dist/migrations/0045_names_to_edge.sql +107 -0
  7. package/dist/org.d.ts +74 -3
  8. package/dist/org.js +133 -0
  9. package/dist/plan/chat-matrix.d.ts +0 -1
  10. package/dist/plan/chat-matrix.js +4 -1
  11. package/dist/plan/diff.d.ts +18 -0
  12. package/dist/plan/diff.js +239 -26
  13. package/dist/plan/entry-edge-key.d.ts +79 -0
  14. package/dist/plan/entry-edge-key.js +260 -0
  15. package/dist/plan/executor.d.ts +246 -1
  16. package/dist/plan/executor.js +684 -32
  17. package/dist/plan/index.d.ts +2 -2
  18. package/dist/plan/index.js +1 -1
  19. package/dist/plan/live.d.ts +4 -2
  20. package/dist/plan/login-host.d.ts +12 -1
  21. package/dist/plan/login-host.js +20 -2
  22. package/dist/plan/plan-apply.d.ts +15 -0
  23. package/dist/plan/plan-apply.js +102 -9
  24. package/dist/plan/platform-diff.d.ts +92 -1
  25. package/dist/plan/platform-diff.js +166 -1
  26. package/dist/plan/platform-org.d.ts +29 -1
  27. package/dist/plan/platform-org.js +17 -2
  28. package/dist/plan/platform-providers.d.ts +45 -1
  29. package/dist/plan/platform-providers.js +514 -7
  30. package/dist/plan/step-system.js +1 -0
  31. package/dist/plan/worker.d.ts +134 -0
  32. package/dist/plan/worker.js +239 -0
  33. package/dist/platform.d.ts +22 -3
  34. package/dist/platform.js +6 -0
  35. package/dist/store/postgres.js +15 -1
  36. package/dist/store/schema.d.ts +70 -2
  37. package/dist/store/schema.js +6 -0
  38. package/package.json +1 -1
@@ -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_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_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, PlatformEdgeDeps, PlatformEdgeRuntime, } 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_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_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. `'edge'`
30
+ * (#317) is the same: a edge 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' | 'edge';
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 `worker` 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 `EdgePort`.
351
+ * Optional, and false without it: each entry's `uses.edge` step is then
352
+ * reported blocked instead of planned to fail.
353
+ */
354
+ edge?: 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 hostEdge;
421
435
  private readonly now;
422
436
  private readonly leaseTtlMs;
423
437
  private readonly appliedVersions;
@@ -566,6 +580,7 @@ export declare class FoundryPlanApply implements PlanApply {
566
580
  * which only the plan can read.
567
581
  */
568
582
  private invalidHosting;
583
+ private platformServiceHostnames;
569
584
  /**
570
585
  * #177: an org's plan reads the shared platform's own addresses and
571
586
  * readiness through the very `platform` deps `planPlatform`/`applyPlatform`
@@ -8,12 +8,14 @@ import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScope
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
10
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
11
+ import { entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey } from './entry-edge-key.js';
11
12
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
12
13
  import { readLivePlatformResources, readLiveResources, } from './live.js';
13
14
  import { lockedHostnames } from './locked-hostnames.js';
14
15
  import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
15
16
  import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
16
17
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
18
+ import { liveWorkers, workerTraceScopeKeys } from './worker.js';
17
19
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
18
20
  const companyScope = (orgId) => `company:${orgId}`;
19
21
  const PLATFORM_SCOPE = 'platform';
@@ -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 `validateEntryWorker`.
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
+ hostEdge;
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.hostEdge = options.edge ?? 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 workerTraceScopeKeys(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
  }
@@ -852,12 +869,15 @@ export class FoundryPlanApply {
852
869
  this.orgArchonUrl(),
853
870
  ]);
854
871
  const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
872
+ const platformHostnames = await this.platformServiceHostnames();
855
873
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
856
874
  loginHostBindings: this.loginHostBindings,
857
875
  broker: this.broker,
876
+ edgeWorkers: this.edgeWorkers,
858
877
  otherSlugs,
859
878
  now: () => this.now().getTime(),
860
879
  archonUrl,
880
+ platformHostnames,
861
881
  });
862
882
  const invalid = await this.invalidHosting(org);
863
883
  if (invalid) {
@@ -877,15 +897,15 @@ export class FoundryPlanApply {
877
897
  * which only the plan can read.
878
898
  */
879
899
  async invalidHosting(org) {
880
- const platform = await this.platformDeps?.store.get();
881
- const platformHostnames = platform
882
- ? PLATFORM_SERVICE_KEYS.map((k) => platform[k].hostname)
883
- : [];
884
900
  return validateHosting(org.hosting, {
885
901
  domain: org.domain,
886
- locked: lockedHostnames(org, platformHostnames),
902
+ locked: lockedHostnames(org, await this.platformServiceHostnames()),
887
903
  });
888
904
  }
905
+ async platformServiceHostnames() {
906
+ const platform = await this.platformDeps?.store.get();
907
+ return platform ? PLATFORM_SERVICE_KEYS.map((k) => platform[k].hostname) : [];
908
+ }
889
909
  /**
890
910
  * #177: an org's plan reads the shared platform's own addresses and
891
911
  * readiness through the very `platform` deps `planPlatform`/`applyPlatform`
@@ -909,7 +929,7 @@ export class FoundryPlanApply {
909
929
  refs.add(ORG_PLATFORM_KEY_REFS.ai);
910
930
  if (this.jobsScheduler)
911
931
  refs.add(ORG_PLATFORM_KEY_REFS.jobs);
912
- const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys);
932
+ const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys, this.hostEdge);
913
933
  // Entry env from the platform's mail key or a shared secret (#T8).
914
934
  let mailKeyHeld = false;
915
935
  try {
@@ -1112,7 +1132,7 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
1112
1132
  * belongs to is already left in place -- deleting only the rule and not the
1113
1133
  * zone is a decision left for whoever wires teardown in, not made here.
1114
1134
  */
1115
- function destroyTargets(serviceIds) {
1135
+ function destroyTargets(serviceIds, orgId) {
1116
1136
  // Reverse-teardown order: everything under one of these prefixes is
1117
1137
  // collected in this order, then every ZITADEL app (there can be several,
1118
1138
  // #71), then the two scope ids last.
@@ -1120,6 +1140,8 @@ function destroyTargets(serviceIds) {
1120
1140
  'zitadel:login-host:',
1121
1141
  'cloudflare:tunnel:',
1122
1142
  'cloudflare:access:',
1143
+ // #318: an entry's Worker and its route.
1144
+ 'cloudflare:worker:',
1123
1145
  'coolify:database:',
1124
1146
  'coolify:app:',
1125
1147
  'zitadel:customers:',
@@ -1127,10 +1149,27 @@ function destroyTargets(serviceIds) {
1127
1149
  'ai:key:',
1128
1150
  // F6b: an entry's files key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
1129
1151
  'files:key:',
1152
+ // F7 (#317): an entry's edge key, `<orgId>:<entry>` -- edge has no org-root key.
1153
+ 'edge:key:',
1130
1154
  ];
1131
1155
  const targets = [];
1132
1156
  const adopted = adoptedResourceIds(serviceIds);
1133
1157
  for (const prefix of ORDERED_PREFIXES) {
1158
+ if (prefix === 'cloudflare:worker:') {
1159
+ // #318 re-review: not the main ids alone -- `liveWorkers` also
1160
+ // finds an upload-only or route-only partial deploy, and leaves out an
1161
+ // entry whose Worker is already retired (nothing live, so a host with
1162
+ // no WorkersPort is not blocked from destroying the org by it).
1163
+ for (const [entryKey, scriptName] of liveWorkers(orgId, serviceIds)) {
1164
+ targets.push({
1165
+ system: 'cloudflare',
1166
+ kind: 'worker',
1167
+ key: `${orgId}:${entryKey}`,
1168
+ id: scriptName,
1169
+ });
1170
+ }
1171
+ continue;
1172
+ }
1134
1173
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1135
1174
  if (!resourceKey.startsWith(prefix))
1136
1175
  continue;
@@ -1141,10 +1180,49 @@ function destroyTargets(serviceIds) {
1141
1180
  continue;
1142
1181
  if (prefix === 'files:key:' && !resourceKey.slice(prefix.length).includes(':'))
1143
1182
  continue;
1183
+ // A key a retire step already revoked before org destroy ever ran
1184
+ // (#317 review): `runSteps` never clears this main id on a step that
1185
+ // returns no resourceId, so without this check destroy would target
1186
+ // an already-revoked key and get stuck on edge' 404 forever.
1187
+ if (prefix === 'edge:key:') {
1188
+ // `resourceKey.slice(prefix.length)` is `<orgId>:<entryKey>`, but
1189
+ // `entryEdgeRetiredScopeKey` takes the bare entry key alone (as
1190
+ // every caller in entry-edge-key.ts/executor.ts gives it) --
1191
+ // entry keys hold no `:` of their own, so the first one found is
1192
+ // exactly the org id/entry key boundary.
1193
+ const fullKey = resourceKey.slice(prefix.length);
1194
+ const entryKey = fullKey.slice(fullKey.indexOf(':') + 1);
1195
+ if (serviceIds[entryEdgeRetiredScopeKey(entryKey)] === id)
1196
+ continue;
1197
+ }
1144
1198
  const [system, kind] = prefix.split(':');
1145
1199
  targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
1146
1200
  }
1147
1201
  }
1202
+ // F7 (#317): a key minted but never delivered never gets the main
1203
+ // `edge:key:<org>:<entry>` id recorded (the mint step throws before
1204
+ // `runSteps` sees a `resourceId`), so the loop above never finds it to
1205
+ // revoke on destroy. Skipped if a later mint already succeeded and holds
1206
+ // its own, still-live main id -- entry keys hold no `:`, so matching the
1207
+ // suffix is unambiguous. A main id that equals the entry's retired marker
1208
+ // does not count as "later": mint A, deliver, retire (main id A, retired
1209
+ // marker A); re-declare, mint B, fail delivery three times (undelivered
1210
+ // B, main id stays A). Without this check, A's stale-but-truthy main id
1211
+ // hid B from destroy, leaking it the same way the gone-scan in
1212
+ // `entry-edge-key.ts` once did (#317 re-review).
1213
+ const undeliveredEdgePrefix = 'edge_key_undelivered_';
1214
+ for (const [resourceKey, id] of Object.entries(serviceIds)) {
1215
+ if (!id || !resourceKey.startsWith(undeliveredEdgePrefix))
1216
+ continue;
1217
+ const entryKey = resourceKey.slice(undeliveredEdgePrefix.length);
1218
+ const hasLaterMainId = Object.entries(serviceIds).some(([k, v]) => v &&
1219
+ k.startsWith('edge:key:') &&
1220
+ k.endsWith(`:${entryKey}`) &&
1221
+ serviceIds[entryEdgeRetiredScopeKey(entryKey)] !== v);
1222
+ if (hasLaterMainId)
1223
+ continue;
1224
+ targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
1225
+ }
1148
1226
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1149
1227
  if (!resourceKey.startsWith('zitadel:app:'))
1150
1228
  continue;
@@ -1226,6 +1304,7 @@ const STEP_FIELDS = {
1226
1304
  'cloudflare:zone': 'hosting.usesDomain',
1227
1305
  'cloudflare:access': 'hosting.accessEmails',
1228
1306
  'cloudflare:ratelimit': 'hosting.usesDomain',
1307
+ 'cloudflare:worker': 'hosting.entries.worker',
1229
1308
  };
1230
1309
  function stepField(action) {
1231
1310
  // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
@@ -1235,6 +1314,9 @@ function stepField(action) {
1235
1314
  // The org's own files key (F6b) is keyed `<orgId>`, an entry's `<orgId>:<entry>`.
1236
1315
  if (action.system === 'files' && action.kind === 'key')
1237
1316
  return 'hosting.entries.uses.files';
1317
+ // An entry's edge key (F7): always `<orgId>:<entry>`, edge has no org-root key.
1318
+ if (action.system === 'edge' && action.kind === 'key')
1319
+ return 'hosting.entries.uses.edge';
1238
1320
  // An org declares several issuer apps, each keyed by its name (#71).
1239
1321
  if (action.system === 'zitadel' && action.kind === 'app') {
1240
1322
  return `hosting.issuerApps.${action.key}`;
@@ -1283,6 +1365,17 @@ function failedFields(org, detail, completed, serviceIds) {
1283
1365
  }
1284
1366
  }
1285
1367
  }
1368
+ // The same for an entry's edge key (F7): minted, never delivered is no
1369
+ // longer blocked forever on its own (the next apply revokes it and
1370
+ // mints again, #317 re-review) -- but while that retry step is itself
1371
+ // not planned this run (no edge service wired, say), the leftover
1372
+ // undelivered id must still read as unresolved rather than the org
1373
+ // converging silently around it.
1374
+ for (const entry of org.hosting.entries) {
1375
+ if (entry.uses?.edge && serviceIds[entryEdgeUndeliveredScopeKey(entry.key)]) {
1376
+ failed.add('hosting.entries.uses.edge');
1377
+ }
1378
+ }
1286
1379
  // The same for a jobs registration whose credentials never reached the host.
1287
1380
  if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
1288
1381
  !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 `planEdge` plans under and
626
+ * `FoundryPlatformStepExecutor` dispatches on -- the {@link PLATFORM_FILES_KEY}
627
+ * shape for the edge host (https://github.com/wtfalch/edge). `runPlatformSteps`
628
+ * records the app's uuid under `coolify:app:platform:edge` once the step
629
+ * succeeds, which is what stops `planEdge` planning it again.
630
+ */
631
+ export declare const PLATFORM_EDGE_KEY = "platform:edge";
632
+ /**
633
+ * #478: the held-key ref the edge 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
+ * (`EDGE_PLATFORM_KEY_HASH`). Archon's own `EdgePort` reads the full
637
+ * secret back from this ref to call edge' `mintKey`/`revokeKey`/
638
+ * `setReservedLabels`.
639
+ */
640
+ export declare const PLATFORM_EDGE_KEY_REF = "edge:platform:key";
641
+ /**
642
+ * #478: the held-key ref for edge' 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, edge-only credential a human provisions and holds
645
+ * separately from whatever broader token `PlatformEdgeDeps.cloudflare`
646
+ * uses to stand the host itself up on Coolify.
647
+ */
648
+ export declare const PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF = "cloudflare:edge:apiToken";
649
+ /**
650
+ * #478: scope keys (colon-free) for the edge 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_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = "edge_platform_key_undelivered";
659
+ export declare const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = "edge_platform_key_set";
660
+ /**
661
+ * #478: the hostname and `gitRef` the last successful names run applied.
662
+ * `planEdge` 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_EDGE_APPLIED_HOSTNAME_SCOPE_KEY = "edge_applied_hostname";
667
+ export declare const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = "edge_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 edge host is blocked, or absent when it is not. */
707
+ edge?: 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 planEdge}
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