@wtfalch/foundry 0.16.16 → 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.
Files changed (67) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +18 -6
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +8 -1
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/migrations/0043_platform_names.sql +25 -0
  14. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  15. package/dist/org.d.ts +128 -3
  16. package/dist/org.js +186 -1
  17. package/dist/plan/adopt.d.ts +76 -0
  18. package/dist/plan/adopt.js +184 -0
  19. package/dist/plan/agora.d.ts +21 -0
  20. package/dist/plan/agora.js +37 -0
  21. package/dist/plan/broker-entries.d.ts +9 -1
  22. package/dist/plan/broker-entries.js +13 -5
  23. package/dist/plan/chat-matrix.d.ts +1 -2
  24. package/dist/plan/chat-matrix.js +15 -4
  25. package/dist/plan/core-files.d.ts +25 -0
  26. package/dist/plan/core-files.js +25 -0
  27. package/dist/plan/diff.d.ts +27 -3
  28. package/dist/plan/diff.js +264 -86
  29. package/dist/plan/edge-worker.d.ts +114 -0
  30. package/dist/plan/edge-worker.js +201 -0
  31. package/dist/plan/entry-ai-key.d.ts +3 -3
  32. package/dist/plan/entry-files-key.d.ts +3 -3
  33. package/dist/plan/entry-names-key.d.ts +73 -0
  34. package/dist/plan/entry-names-key.js +236 -0
  35. package/dist/plan/executor.d.ts +292 -24
  36. package/dist/plan/executor.js +745 -77
  37. package/dist/plan/index.d.ts +6 -2
  38. package/dist/plan/index.js +3 -1
  39. package/dist/plan/live.d.ts +9 -2
  40. package/dist/plan/login-host.d.ts +13 -2
  41. package/dist/plan/login-host.js +26 -7
  42. package/dist/plan/plan-apply.d.ts +153 -6
  43. package/dist/plan/plan-apply.js +639 -78
  44. package/dist/plan/platform-diff.d.ts +106 -1
  45. package/dist/plan/platform-diff.js +182 -2
  46. package/dist/plan/platform-org.d.ts +45 -1
  47. package/dist/plan/platform-org.js +34 -3
  48. package/dist/plan/platform-providers.d.ts +45 -1
  49. package/dist/plan/platform-providers.js +585 -16
  50. package/dist/plan/providers-readers.d.ts +2 -0
  51. package/dist/plan/providers-readers.js +4 -0
  52. package/dist/plan/rollout.d.ts +52 -0
  53. package/dist/plan/rollout.js +37 -0
  54. package/dist/plan/step-system.d.ts +22 -0
  55. package/dist/plan/step-system.js +38 -0
  56. package/dist/plan.d.ts +3 -0
  57. package/dist/platform.d.ts +22 -3
  58. package/dist/platform.js +6 -0
  59. package/dist/state.d.ts +13 -0
  60. package/dist/store/audit.js +15 -1
  61. package/dist/store/postgres.d.ts +1 -1
  62. package/dist/store/postgres.js +142 -15
  63. package/dist/store/schema.d.ts +202 -5
  64. package/dist/store/schema.js +18 -1
  65. package/dist/store.d.ts +47 -4
  66. package/dist/store.js +9 -1
  67. package/package.json +1 -1
@@ -1,16 +1,38 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
3
  import { validateHosting } from '../org.js';
4
4
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
5
+ import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
6
+ import { StaleLeaseError, } from '../store.js';
7
+ import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
5
8
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
6
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';
7
11
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
8
12
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
13
+ import { entryNamesRetiredScopeKey, entryNamesUndeliveredScopeKey } from './entry-names-key.js';
9
14
  import { readLivePlatformResources, readLiveResources, } from './live.js';
10
15
  import { lockedHostnames } from './locked-hostnames.js';
11
- import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
16
+ import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
12
17
  import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
13
18
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
19
+ /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
20
+ const companyScope = (orgId) => `company:${orgId}`;
21
+ const PLATFORM_SCOPE = 'platform';
22
+ /** A lease nobody renews: long enough for one apply, short enough that a dead worker's scope frees itself. The fence stops a slow worker that outlives it. */
23
+ const DEFAULT_LEASE_TTL_MS = 30 * 60 * 1000;
24
+ /** #441: another apply, destroy, adopt or release holds the lease for this company or the platform. Nothing ran; try again when it finishes. */
25
+ export class MutationInProgressError extends Error {
26
+ scope;
27
+ heldUntil;
28
+ /** @param heldUntil when the holder's lease runs out if it is never released. */
29
+ constructor(scope, heldUntil) {
30
+ super(`another change to ${scope} is in progress (lease until ${heldUntil.toISOString()}); try again when it finishes`);
31
+ this.scope = scope;
32
+ this.heldUntil = heldUntil;
33
+ this.name = 'MutationInProgressError';
34
+ }
35
+ }
14
36
  export class DeclaredOrgNotFoundError extends Error {
15
37
  constructor(orgId) {
16
38
  super(`no declared org ${orgId}`);
@@ -77,6 +99,9 @@ const HOSTNAME_STEPS = new Set([
77
99
  'cloudflare:tunnel',
78
100
  'cloudflare:access',
79
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',
80
105
  ]);
81
106
  /**
82
107
  * The #19 implementation of {@link PlanApply}: computes a plan against live
@@ -96,11 +121,15 @@ export class FoundryPlanApply {
96
121
  jobsScheduler;
97
122
  loginHostBindings;
98
123
  broker;
124
+ edgeWorkers;
99
125
  hostFiles;
100
126
  hostAi;
127
+ hostNames;
101
128
  now;
129
+ leaseTtlMs;
102
130
  appliedVersions;
103
131
  platformDeps;
132
+ runner;
104
133
  constructor(options) {
105
134
  this.store = options.store;
106
135
  this.readers = options.readers;
@@ -113,17 +142,202 @@ export class FoundryPlanApply {
113
142
  this.jobsScheduler = options.jobsScheduler ?? false;
114
143
  this.loginHostBindings = options.loginHostBindings ?? false;
115
144
  this.broker = options.broker ?? false;
145
+ this.edgeWorkers = options.edgeWorkers ?? false;
116
146
  this.hostFiles = options.filesOrganisation ?? false;
117
147
  this.hostAi = options.aiOrganisation ?? false;
148
+ this.hostNames = options.names ?? false;
118
149
  this.now = options.now ?? (() => new Date());
150
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
119
151
  this.appliedVersions = options.appliedVersions;
120
152
  this.platformDeps = options.platform;
153
+ this.runner = options.runner;
121
154
  }
122
155
  async plan(id) {
123
156
  const { plan } = await this.computeFreshPlan(id);
124
157
  await this.audit.recordPlan({ actor: this.actor, orgId: id, plan, at: this.now() });
125
158
  return plan;
126
159
  }
160
+ /**
161
+ * Archon#271: what adopting a live app and database into entry
162
+ * `input.entryKey` would do. A read, like {@link plan}: it reads the project
163
+ * through the reader and changes nothing. A mismatch is reported in the
164
+ * result, with no steps. Needs the apply permission, since it reads a
165
+ * project the caller names.
166
+ */
167
+ async planAdopt(id, input) {
168
+ if (!(await this.authz.canApply(this.actor, id)))
169
+ throw new NotAuthorizedError(this.actor, id);
170
+ const { adoptPlan } = await this.computeAdopt(id, input);
171
+ await this.audit.recordPlan({
172
+ actor: this.actor,
173
+ orgId: id,
174
+ plan: adoptPlan.plan,
175
+ at: this.now(),
176
+ });
177
+ return adoptPlan;
178
+ }
179
+ /**
180
+ * Archon#271: the live apps and databases in `projectUuid` this org has not
181
+ * recorded, for Changes' "Live, not theirs". A read, but it needs the apply
182
+ * permission: it reads a project the caller names.
183
+ */
184
+ async adoptCandidates(id, entryKey, projectUuid) {
185
+ if (!(await this.authz.canApply(this.actor, id)))
186
+ throw new NotAuthorizedError(this.actor, id);
187
+ const org = await this.store.get(id);
188
+ if (!org)
189
+ throw new DeclaredOrgNotFoundError(id);
190
+ const recorded = (await this.store.getAppliedState(id))?.serviceIds ?? {};
191
+ const project = projectUuid ?? recorded.coolify_project;
192
+ const live = project ? ((await this.readers.coolify.listProject?.(project)) ?? []) : [];
193
+ return adoptCandidates(org, entryKey, recorded, live);
194
+ }
195
+ /**
196
+ * Archon#271: records an app and database that already run in Coolify as
197
+ * entry `input.entryKey`'s resources, so the next plan finds them and plans
198
+ * no create. Verifies first and refuses on a mismatch or an entry that is
199
+ * already applied or held by another org. Same permission as {@link apply}.
200
+ * It never calls an executor: nothing in Coolify changes. Nothing redeploys
201
+ * here, but the next normal plan may propose updates to the adopted app
202
+ * that do (see `adopt.ts`); show that plan before applying it. Two
203
+ * concurrent adopts of one resource are not locked against each other.
204
+ */
205
+ async adopt(id, input) {
206
+ const refuse = async (reason) => {
207
+ await this.audit.recordAdoption({
208
+ actor: this.actor,
209
+ orgId: id,
210
+ action: 'adopt',
211
+ entryKey: input.entryKey,
212
+ outcome: 'refused',
213
+ reason,
214
+ at: this.now(),
215
+ });
216
+ };
217
+ if (!(await this.authz.canApply(this.actor, id))) {
218
+ await refuse('not authorized');
219
+ throw new NotAuthorizedError(this.actor, id);
220
+ }
221
+ return this.withMutationLease(companyScope(id), refuse, (fence) => this.withRunnerSerialization(id, refuse, async () => {
222
+ const { adoptPlan, previous } = await this.computeAdopt(id, input);
223
+ const { snapshot } = adoptPlan;
224
+ if (adoptPlan.mismatches.length > 0 || !snapshot) {
225
+ await refuse(adoptPlan.mismatches.join('; '));
226
+ throw new AdoptRefusedError(adoptPlan.mismatches);
227
+ }
228
+ const org = await this.store.get(id);
229
+ if (!org)
230
+ throw new DeclaredOrgNotFoundError(id);
231
+ const serviceIds = {
232
+ ...(previous?.serviceIds ?? {}),
233
+ coolify_project: snapshot.projectUuid,
234
+ [`coolify:app:${appStepKey(org, input.entryKey)}`]: snapshot.application.uuid,
235
+ [`coolify:database:${id}`]: snapshot.database.uuid,
236
+ [adoptedScopeKey(input.entryKey)]: JSON.stringify(snapshot),
237
+ };
238
+ const applied = {
239
+ orgId: id,
240
+ serviceIds,
241
+ checkpoints: previous?.checkpoints ?? [],
242
+ failedFields: previous?.failedFields ?? [],
243
+ lastApplied: previous?.lastApplied ?? null,
244
+ };
245
+ await this.store.putAppliedState(applied, fence);
246
+ await this.audit.recordAdoption({
247
+ actor: this.actor,
248
+ orgId: id,
249
+ action: 'adopt',
250
+ entryKey: input.entryKey,
251
+ outcome: 'adopted',
252
+ at: this.now(),
253
+ });
254
+ return applied;
255
+ }));
256
+ }
257
+ /**
258
+ * Archon#271: the undo of {@link adopt}. Drops the entry's recorded Coolify
259
+ * ids and its `adopted.<entryKey>` snapshot (and `coolify_project` when
260
+ * adopt recorded it and no other Coolify id remains). It leaves the
261
+ * declaration alone: the entry goes back to declared and not applied, so the
262
+ * next plan creates it, exactly as before adopt. The host then adopts it
263
+ * again or removes the entry from the declaration. Deletes and stops nothing
264
+ * in Coolify. Only an adopted entry can be released. Same permission as
265
+ * {@link apply}. Every path writes an audit row.
266
+ */
267
+ async release(id, entryKey) {
268
+ const record = (outcome, reason) => this.audit.recordAdoption({
269
+ actor: this.actor,
270
+ orgId: id,
271
+ action: 'release',
272
+ entryKey,
273
+ outcome,
274
+ ...(reason === undefined ? {} : { reason }),
275
+ at: this.now(),
276
+ });
277
+ if (!(await this.authz.canApply(this.actor, id))) {
278
+ await record('refused', 'not authorized');
279
+ throw new NotAuthorizedError(this.actor, id);
280
+ }
281
+ return this.withMutationLease(companyScope(id), (reason) => record('refused', reason), (fence) => this.withRunnerSerialization(id, (reason) => record('refused', reason), async () => {
282
+ const org = await this.store.get(id);
283
+ if (!org) {
284
+ await record('refused', `no declared org ${id}`);
285
+ throw new DeclaredOrgNotFoundError(id);
286
+ }
287
+ const previous = await this.store.getAppliedState(id);
288
+ const snapshot = parseAdoptSnapshot(previous?.serviceIds[adoptedScopeKey(entryKey)]);
289
+ if (!previous || !snapshot) {
290
+ await record('refused', `entry ${entryKey} was not adopted`);
291
+ throw new AdoptRefusedError([`entry ${entryKey} was not adopted`]);
292
+ }
293
+ const dropped = new Set([
294
+ adoptedScopeKey(entryKey),
295
+ `coolify:app:${appStepKey(org, entryKey)}`,
296
+ `coolify:database:${id}`,
297
+ ]);
298
+ const kept = Object.entries(previous.serviceIds).filter(([k]) => !dropped.has(k));
299
+ const otherCoolify = kept.some(([k]) => k.startsWith('coolify:'));
300
+ const serviceIds = Object.fromEntries(kept.filter(([k]) => !(k === 'coolify_project' && snapshot.projectRecorded && !otherCoolify)));
301
+ const released = { ...previous, serviceIds };
302
+ try {
303
+ await this.store.putAppliedState(released, fence);
304
+ }
305
+ catch (e) {
306
+ if (e instanceof StaleLeaseError)
307
+ throw e;
308
+ await record('refused', e instanceof Error ? e.message : String(e));
309
+ throw e;
310
+ }
311
+ await record('released');
312
+ return released;
313
+ }));
314
+ }
315
+ async computeAdopt(id, input) {
316
+ const org = await this.store.get(id);
317
+ if (!org)
318
+ throw new DeclaredOrgNotFoundError(id);
319
+ const previous = await this.store.getAppliedState(id);
320
+ const recorded = previous?.serviceIds ?? {};
321
+ const project = input.projectUuid ?? recorded.coolify_project;
322
+ const reader = this.readers.coolify;
323
+ const live = project && reader.listProject ? await reader.listProject(project) : null;
324
+ const claimedElsewhere = [];
325
+ for (const [what, uuid] of [
326
+ ['application', input.applicationUuid],
327
+ ['database', input.databaseUuid],
328
+ ['project', project],
329
+ ]) {
330
+ if (!uuid)
331
+ continue;
332
+ const owner = await this.store.findOtherOrgRecordingCoolifyId(uuid, id);
333
+ if (owner)
334
+ claimedElsewhere.push(`${what} ${uuid} is already recorded by org ${owner}`);
335
+ }
336
+ return {
337
+ adoptPlan: planAdoptFrom(org, recorded, input, live, this.now(), claimedElsewhere),
338
+ previous,
339
+ };
340
+ }
127
341
  async apply(plan, confirmedOrgName) {
128
342
  const orgId = plan.orgId;
129
343
  const authorized = await this.authz.canApply(this.actor, orgId);
@@ -131,18 +345,21 @@ export class FoundryPlanApply {
131
345
  await this.refuse(plan, 'not authorized');
132
346
  throw new NotAuthorizedError(this.actor, orgId);
133
347
  }
134
- const { org, plan: freshPlan, detail } = await this.computeFreshPlan(orgId);
135
- if (!plansMatch(plan, freshPlan)) {
136
- await this.refuse(plan, 'stale: live state has moved since this plan was computed');
137
- throw new StalePlanError(orgId);
138
- }
139
- if (planNeedsConfirmation(plan.steps) && confirmedOrgName.trim() !== org.displayName.trim()) {
140
- await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
141
- throw new ConfirmationRequiredError(orgId);
142
- }
143
- const applied = await this.runSteps(org, plan, detail);
144
- await this.refreshPlatformFilesShare();
145
- return applied;
348
+ return this.withMutationLease(companyScope(orgId), (reason) => this.refuse(plan, reason), (fence) => this.withRunnerSerialization(orgId, (reason) => this.refuse(plan, reason), async () => {
349
+ const { org, plan: freshPlan, detail } = await this.computeFreshPlan(orgId);
350
+ if (!plansMatch(plan, freshPlan)) {
351
+ await this.refuse(plan, 'stale: live state has moved since this plan was computed');
352
+ throw new StalePlanError(orgId);
353
+ }
354
+ if (planNeedsConfirmation(plan.steps) &&
355
+ confirmedOrgName.trim() !== org.displayName.trim()) {
356
+ await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
357
+ throw new ConfirmationRequiredError(orgId);
358
+ }
359
+ const applied = await this.runSteps(org, plan, detail, fence);
360
+ await this.refreshPlatformFilesShare();
361
+ return applied;
362
+ }));
146
363
  }
147
364
  /**
148
365
  * After an org apply: gives the platform files runner a changed share-domain
@@ -150,9 +367,10 @@ export class FoundryPlanApply {
150
367
  * declared orgs) without a platform pass. Touches only the share hosts and
151
368
  * redeploys the runner; the platform files step itself never runs here.
152
369
  * Skips quietly for an actor who may not apply the platform, and when the
153
- * platform files step was never applied. There is no platform lock, so the
154
- * service ids are re-read just before the write and only the share-domains
155
- * key is recorded. A failure is rethrown naming that the org part succeeded.
370
+ * platform files step was never applied. It holds the platform lease for
371
+ * the refresh and fences its write with it; while another platform change
372
+ * holds the lease it skips (this class has no log), and the next platform
373
+ * apply does the refresh. Only the share-domains key is recorded. A failure is rethrown naming that the org part succeeded.
156
374
  */
157
375
  async refreshPlatformFilesShare() {
158
376
  const deps = this.platformDeps;
@@ -160,6 +378,9 @@ export class FoundryPlanApply {
160
378
  return;
161
379
  if ((await this.authz.canApplyPlatform?.(this.actor)) !== true)
162
380
  return;
381
+ const lease = await this.store.acquireMutationLease(PLATFORM_SCOPE, randomUUID(), this.leaseTtlMs);
382
+ if (!('token' in lease))
383
+ return;
163
384
  try {
164
385
  const platform = await deps.store.get();
165
386
  if (!platform?.files.enabled)
@@ -171,11 +392,14 @@ export class FoundryPlanApply {
171
392
  const scopeIds = await deps.executor.refreshFilesShare(platform, recorded);
172
393
  if (scopeIds === null)
173
394
  return;
174
- await deps.store.putServiceIds({ ...(await deps.store.getServiceIds()), ...scopeIds });
395
+ await deps.store.putServiceIds({ ...(await deps.store.getServiceIds()), ...scopeIds }, lease);
175
396
  }
176
397
  catch (e) {
177
398
  throw new Error(`org applied; platform files share refresh failed: ${e instanceof Error ? e.message : String(e)}`);
178
399
  }
400
+ finally {
401
+ await this.store.releaseMutationLease(lease).catch(() => undefined);
402
+ }
179
403
  }
180
404
  /**
181
405
  * #167: {@link plan}, for the shared platform (#166) rather than an org --
@@ -216,16 +440,18 @@ export class FoundryPlanApply {
216
440
  await this.refusePlatform(plan, declared.ownerOrgId, 'not authorized');
217
441
  throw new NotAuthorizedError(this.actor, plan.orgId);
218
442
  }
219
- const { platform, plan: freshPlan, detail } = await this.computeFreshPlatformPlan();
220
- if (!plansMatch(plan, freshPlan)) {
221
- await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
222
- throw new PlatformStalePlanError();
223
- }
224
- if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
225
- await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
226
- throw new PlatformConfirmationRequiredError();
227
- }
228
- return this.runPlatformSteps(platform, plan, detail, executor);
443
+ return this.withMutationLease(PLATFORM_SCOPE, (reason) => this.refusePlatform(plan, declared.ownerOrgId, reason), async (fence) => {
444
+ const { platform, plan: freshPlan, detail } = await this.computeFreshPlatformPlan();
445
+ if (!plansMatch(plan, freshPlan)) {
446
+ await this.refusePlatform(plan, platform.ownerOrgId, 'stale: live state has moved since this plan was computed');
447
+ throw new PlatformStalePlanError();
448
+ }
449
+ if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
450
+ await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
451
+ throw new PlatformConfirmationRequiredError();
452
+ }
453
+ return this.runPlatformSteps(platform, plan, detail, executor, fence);
454
+ });
229
455
  }
230
456
  /**
231
457
  * Deletes every resource foundry recorded for `id`, in reverse creation
@@ -238,59 +464,270 @@ export class FoundryPlanApply {
238
464
  * declared org in place with whatever remains recorded in its applied
239
465
  * state, so a retried `destroy` only tears down what's left.
240
466
  */
241
- async destroy(id, confirmedOrgName) {
467
+ async destroy(id, confirmedOrgName, options) {
242
468
  const authorized = await this.authz.canDestroy(this.actor, id);
243
469
  if (!authorized) {
244
470
  await this.refuseDestroy(id, 'not authorized');
245
471
  throw new NotAuthorizedError(this.actor, id);
246
472
  }
473
+ return this.withMutationLease(companyScope(id), (reason) => this.refuseDestroy(id, reason), (fence) => this.withRunnerSerialization(id, (reason) => this.refuseDestroy(id, reason), async () => {
474
+ const org = await this.store.get(id);
475
+ if (!org)
476
+ throw new DeclaredOrgNotFoundError(id);
477
+ if (!org.test) {
478
+ await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
479
+ throw new NotATestOrgError(id);
480
+ }
481
+ if (confirmedOrgName.trim() !== org.displayName.trim()) {
482
+ await this.refuseDestroy(id, "org's name not typed correctly");
483
+ throw new ConfirmationRequiredError(id);
484
+ }
485
+ const state = await this.store.getAppliedState(id);
486
+ const serviceIds = { ...(state?.serviceIds ?? {}) };
487
+ let targets;
488
+ try {
489
+ targets = destroyTargets(serviceIds, id);
490
+ }
491
+ catch (e) {
492
+ await this.refuseDestroy(id, e instanceof Error ? e.message : String(e));
493
+ throw e;
494
+ }
495
+ const descriptions = destroyStepDescriptions(targets);
496
+ const steps = descriptions.map((step) => ({
497
+ step,
498
+ status: 'planned',
499
+ }));
500
+ const persist = () => this.store.putAppliedState({
501
+ orgId: id,
502
+ serviceIds,
503
+ checkpoints: state?.checkpoints ?? [],
504
+ failedFields: state?.failedFields ?? [],
505
+ lastApplied: state?.lastApplied ?? null,
506
+ destroy: { steps: steps.map((s) => ({ ...s })) },
507
+ }, fence);
508
+ // A throwing listener must never change the record, the audit row or the error.
509
+ const emit = (index, status, error) => {
510
+ const step = descriptions[index];
511
+ try {
512
+ options?.onProgress?.({
513
+ step,
514
+ index: index + 1,
515
+ total: steps.length,
516
+ status,
517
+ error,
518
+ });
519
+ }
520
+ catch {
521
+ // ignored on purpose
522
+ }
523
+ };
524
+ let current = 0;
525
+ // True once the step's `started` record is saved, i.e. the step is about to run.
526
+ let running = false;
527
+ try {
528
+ for (; current < steps.length; current++) {
529
+ running = false;
530
+ await this.renewLease(fence);
531
+ steps[current] = { step: descriptions[current], status: 'started' };
532
+ await persist();
533
+ running = true;
534
+ emit(current, 'started');
535
+ const target = targets[current];
536
+ if (target) {
537
+ // A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
538
+ // doc comment -- so nothing here needs to special-case "already
539
+ // gone" itself.
540
+ await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
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
+ }
550
+ steps[current] = {
551
+ step: descriptions[current],
552
+ status: 'finished',
553
+ completedAt: this.now().toISOString(),
554
+ };
555
+ await persist();
556
+ }
557
+ else {
558
+ // The last step: the org and its applied state (and this record) go together.
559
+ await this.store.delete(id, fence);
560
+ }
561
+ emit(current, 'finished');
562
+ }
563
+ }
564
+ catch (e) {
565
+ // A lost lease is audited once, by `withMutationLease`.
566
+ if (e instanceof StaleLeaseError)
567
+ throw e;
568
+ const reason = e instanceof Error ? e.message : String(e);
569
+ if (running) {
570
+ steps[current] = {
571
+ step: descriptions[current],
572
+ status: 'failed',
573
+ error: reason,
574
+ };
575
+ emit(current, 'failed', reason);
576
+ }
577
+ else {
578
+ // The step never ran (its `started` write failed): leave it planned.
579
+ steps[current] = { step: descriptions[current], status: 'planned' };
580
+ }
581
+ // Best effort: the failure the caller sees is the step's, not this write's.
582
+ await persist().catch(() => undefined);
583
+ await this.audit.recordDestroy({
584
+ actor: this.actor,
585
+ orgId: id,
586
+ outcome: 'failed',
587
+ reason,
588
+ at: this.now(),
589
+ });
590
+ throw e;
591
+ }
592
+ await this.audit.recordDestroy({
593
+ actor: this.actor,
594
+ orgId: id,
595
+ outcome: 'destroyed',
596
+ at: this.now(),
597
+ });
598
+ }));
599
+ }
600
+ /** The steps `destroy` runs for `id`, in order. Refuses a non-test org like `destroy` does; writes no audit row since it changes nothing. */
601
+ async planDestroy(id) {
602
+ if (!(await this.authz.canDestroy(this.actor, id))) {
603
+ throw new NotAuthorizedError(this.actor, id);
604
+ }
247
605
  const org = await this.store.get(id);
248
606
  if (!org)
249
607
  throw new DeclaredOrgNotFoundError(id);
250
- if (!org.test) {
251
- await this.refuseDestroy(id, 'org is not marked test; only a test org can be destroyed');
608
+ if (!org.test)
252
609
  throw new NotATestOrgError(id);
253
- }
254
- if (confirmedOrgName.trim() !== org.displayName.trim()) {
255
- await this.refuseDestroy(id, "org's name not typed correctly");
256
- throw new ConfirmationRequiredError(id);
257
- }
258
610
  const state = await this.store.getAppliedState(id);
259
611
  const serviceIds = { ...(state?.serviceIds ?? {}) };
260
- const targets = destroyTargets(serviceIds);
261
- try {
262
- for (const target of targets) {
263
- // A provider 404 counts as deleted -- `StepDestroyer.destroy`'s own
264
- // doc comment -- so nothing here needs to special-case "already
265
- // gone" itself.
266
- await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
267
- delete serviceIds[destroyServiceIdKey(target)];
268
- await this.store.putAppliedState({
269
- orgId: id,
270
- serviceIds,
271
- checkpoints: state?.checkpoints ?? [],
272
- failedFields: state?.failedFields ?? [],
273
- lastApplied: state?.lastApplied ?? null,
274
- });
612
+ return {
613
+ steps: destroyStepDescriptions(destroyTargets(serviceIds, id)),
614
+ kept: keptDescriptions(serviceIds),
615
+ };
616
+ }
617
+ /**
618
+ * #441 (part b): records `fn`'s run in the durable runner, when one is
619
+ * configured, and calls it. Must run with the mutation lease already
620
+ * held -- every one of this method's four callers (`apply`, `destroy`,
621
+ * `adopt`, `release`) calls it from inside its own `withMutationLease`
622
+ * callback, never around it. The reason is `JobRunner.runSteps` itself:
623
+ * it checkpoints a run's one step (the literal id `'mutate'`, the same
624
+ * for all four callers so they share one row per org) by a plain read of
625
+ * the row followed by a later blind write, with nothing to stop two
626
+ * concurrent calls for the same `runId` from both reading "not yet
627
+ * checkpointed" and both calling `fn` -- the runner sequences and
628
+ * checkpoints (its own doc comment), it does not itself exclude a second
629
+ * caller the way the lease's atomic acquire does. Holding the lease first
630
+ * is what makes it safe to reuse an existing, not-yet-succeeded run
631
+ * ({@link JobRunner.currentRun}) rather than refuse it outright: by the
632
+ * time this method runs, this caller is already the lease's sole holder,
633
+ * so any row `currentRun` finds can only be a previous, now-abandoned
634
+ * attempt (the lease's own atomic acquire already refused a second live
635
+ * caller before either of them got here) -- reusing it is the resume #9
636
+ * calls "a human decides when to call it again", not a race.
637
+ *
638
+ * Two *first* attempts for the same org can still race the insert itself
639
+ * when neither has an existing row yet -- `JobRunner.enqueueApply`'s own
640
+ * unique constraint decides it at the database level, a real race rather
641
+ * than one decided by this process's await order, and the loser's
642
+ * {@link AlreadyRunningError} is refused and audited through `refuse`,
643
+ * the same as a lost lease. In practice this needs two lease holders at
644
+ * once to reach it, which the lease itself already prevents; it is this
645
+ * method's defence against a bug in the lease's own acquire path, not
646
+ * the primary lock.
647
+ *
648
+ * `fn`'s result is threaded out through a closure, since
649
+ * {@link RunnerStep.run} returns nothing.
650
+ */
651
+ async withRunnerSerialization(orgId, refuse, fn) {
652
+ if (!this.runner)
653
+ return fn();
654
+ let result;
655
+ const steps = [
656
+ {
657
+ id: 'mutate',
658
+ run: async () => {
659
+ result = await fn();
660
+ },
661
+ },
662
+ ];
663
+ const current = await this.runner.currentRun(orgId);
664
+ let runId;
665
+ if (current) {
666
+ runId = current.id;
667
+ }
668
+ else {
669
+ try {
670
+ ({ runId } = await this.runner.enqueueApply(orgId, steps));
671
+ }
672
+ catch (e) {
673
+ if (e instanceof AlreadyRunningError) {
674
+ await refuse(e.message);
675
+ throw e;
676
+ }
677
+ throw e;
275
678
  }
276
- await this.store.delete(id);
679
+ }
680
+ try {
681
+ await this.runner.runSteps(runId, steps);
277
682
  }
278
683
  catch (e) {
279
- await this.audit.recordDestroy({
280
- actor: this.actor,
281
- orgId: id,
282
- outcome: 'failed',
283
- reason: e instanceof Error ? e.message : String(e),
284
- at: this.now(),
285
- });
684
+ // `StepFailedError` wraps whatever `fn` threw only to say which run
685
+ // and step failed; every caller of `apply`/`destroy`/`adopt`/
686
+ // `release` expects that original error (`StalePlanError`, a
687
+ // provider error, whatever `fn` itself throws), not the runner's
688
+ // wrapper around it. `refuse` already ran inside `fn` (every
689
+ // `withMutationLease` callback audits its own refusal), so this does
690
+ // not call it again.
691
+ if (e instanceof StepFailedError && e.cause !== undefined)
692
+ throw e.cause;
286
693
  throw e;
287
694
  }
288
- await this.audit.recordDestroy({
289
- actor: this.actor,
290
- orgId: id,
291
- outcome: 'destroyed',
292
- at: this.now(),
293
- });
695
+ return result;
696
+ }
697
+ /**
698
+ * #441: runs `fn` while holding the one mutation lease for `scope`, so two
699
+ * `FoundryPlanApply` instances on one store never run provider steps for the
700
+ * same company or platform at once. A second caller is refused with
701
+ * {@link MutationInProgressError}; `fn` gets the lease as the fence its
702
+ * store writes carry, so a worker that lost the lease cannot overwrite the
703
+ * newer holder's state. Both refusals are audited through `refuse`.
704
+ */
705
+ async withMutationLease(scope, refuse, fn) {
706
+ const lease = await this.store.acquireMutationLease(scope, randomUUID(), this.leaseTtlMs);
707
+ if (!('token' in lease)) {
708
+ const held = new MutationInProgressError(scope, lease.heldUntil);
709
+ await refuse(held.message);
710
+ throw held;
711
+ }
712
+ try {
713
+ return await fn(lease);
714
+ }
715
+ catch (e) {
716
+ // The paths inside `fn` rethrow a lost lease without auditing it, so this is its one audit row.
717
+ if (e instanceof StaleLeaseError)
718
+ await refuse('mutation lease lost to a newer holder');
719
+ throw e;
720
+ }
721
+ finally {
722
+ // Best effort: an unreleased lease frees itself when it expires.
723
+ await this.store.releaseMutationLease(lease).catch(() => undefined);
724
+ }
725
+ }
726
+ /** #441: before each provider step, extends the lease; a holder that lost it stops here, before the step runs. */
727
+ async renewLease(fence) {
728
+ if (!(await this.store.renewMutationLease(fence, this.leaseTtlMs))) {
729
+ throw new StaleLeaseError(fence.scope);
730
+ }
294
731
  }
295
732
  async refuse(plan, reason) {
296
733
  await this.audit.recordApply({
@@ -322,7 +759,7 @@ export class FoundryPlanApply {
322
759
  at: this.now(),
323
760
  });
324
761
  }
325
- async runSteps(org, plan, detail) {
762
+ async runSteps(org, plan, detail, fence) {
326
763
  const orgId = plan.orgId;
327
764
  const previous = await this.store.getAppliedState(orgId);
328
765
  const serviceIds = { ...(previous?.serviceIds ?? {}) };
@@ -343,11 +780,13 @@ export class FoundryPlanApply {
343
780
  checkpoints: [...checkpoints],
344
781
  failedFields: previous?.failedFields ?? [],
345
782
  lastApplied: previous?.lastApplied ?? null,
346
- });
783
+ destroy: previous?.destroy,
784
+ }, fence);
347
785
  },
348
786
  };
349
787
  try {
350
788
  for (const planned of detail.plannedSteps) {
789
+ await this.renewLease(fence);
351
790
  const result = await this.executor.execute(planned.action, context);
352
791
  // `system:kind:key`, not just `kind`: two steps can share a `kind`
353
792
  // (an org's several declared ZITADEL apps, #71) and would otherwise
@@ -365,13 +804,20 @@ export class FoundryPlanApply {
365
804
  // #9 (resolved): "it stops at the checkpoint... there is no automatic
366
805
  // rollback." Whatever completed stays recorded; the failure is
367
806
  // rethrown for a human to fix and resume.
368
- await this.store.putAppliedState({
807
+ // A lost lease is audited once, by `withMutationLease`.
808
+ if (e instanceof StaleLeaseError)
809
+ throw e;
810
+ // The failure the caller sees is the step's, not a refused write's.
811
+ await this.store
812
+ .putAppliedState({
369
813
  orgId,
370
814
  serviceIds,
371
815
  checkpoints,
372
816
  failedFields: failedFields(org, detail, checkpoints.length, serviceIds),
373
817
  lastApplied: previous?.lastApplied ?? null,
374
- });
818
+ destroy: previous?.destroy,
819
+ }, fence)
820
+ .catch(() => undefined);
375
821
  await this.audit.recordApply({
376
822
  actor: this.actor,
377
823
  orgId,
@@ -389,8 +835,9 @@ export class FoundryPlanApply {
389
835
  checkpoints,
390
836
  failedFields: failedFields(org, detail, detail.plannedSteps.length, serviceIds),
391
837
  lastApplied,
838
+ destroy: previous?.destroy,
392
839
  };
393
- await this.store.putAppliedState(applied);
840
+ await this.store.putAppliedState(applied, fence);
394
841
  // #253: the single-snapshot write just above replaces whatever
395
842
  // `lastApplied` this org had; this is the point in today's code where a
396
843
  // version is recorded as applied, so it's also where the appended
@@ -425,6 +872,7 @@ export class FoundryPlanApply {
425
872
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
426
873
  loginHostBindings: this.loginHostBindings,
427
874
  broker: this.broker,
875
+ edgeWorkers: this.edgeWorkers,
428
876
  otherSlugs,
429
877
  now: () => this.now().getTime(),
430
878
  archonUrl,
@@ -479,7 +927,7 @@ export class FoundryPlanApply {
479
927
  refs.add(ORG_PLATFORM_KEY_REFS.ai);
480
928
  if (this.jobsScheduler)
481
929
  refs.add(ORG_PLATFORM_KEY_REFS.jobs);
482
- const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys);
930
+ const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys, this.hostNames);
483
931
  // Entry env from the platform's mail key or a shared secret (#T8).
484
932
  let mailKeyHeld = false;
485
933
  try {
@@ -588,7 +1036,7 @@ export class FoundryPlanApply {
588
1036
  * `serviceIds`), so a later plan finds a resource by its recorded id even
589
1037
  * when a provider's list has not caught up with a create.
590
1038
  */
591
- async runPlatformSteps(platform, plan, detail, executor) {
1039
+ async runPlatformSteps(platform, plan, detail, executor, fence) {
592
1040
  const { store } = this.requirePlatformDeps();
593
1041
  const serviceIds = { ...(await store.getServiceIds()) };
594
1042
  const context = {
@@ -597,10 +1045,11 @@ export class FoundryPlanApply {
597
1045
  recordScopeId: (key, id) => {
598
1046
  serviceIds[key] = id;
599
1047
  },
600
- persist: () => store.putServiceIds(serviceIds),
1048
+ persist: () => store.putServiceIds(serviceIds, fence),
601
1049
  };
602
1050
  try {
603
1051
  for (const planned of detail.plannedSteps) {
1052
+ await this.renewLease(fence);
604
1053
  const result = await executor.execute(planned.action, context);
605
1054
  if (result?.resourceId) {
606
1055
  const { system, kind, key } = planned.action;
@@ -609,8 +1058,12 @@ export class FoundryPlanApply {
609
1058
  }
610
1059
  }
611
1060
  catch (e) {
612
- // Whatever completed stays recorded, as an org's apply does.
613
- await store.putServiceIds(serviceIds);
1061
+ // Whatever completed stays recorded, as an org's apply does. A lost
1062
+ // lease is audited once, by `withMutationLease`; any other failure
1063
+ // stays the step's, not a refused write's.
1064
+ if (e instanceof StaleLeaseError)
1065
+ throw e;
1066
+ await store.putServiceIds(serviceIds, fence).catch(() => undefined);
614
1067
  await this.audit.recordApply({
615
1068
  actor: this.actor,
616
1069
  orgId: platform.ownerOrgId,
@@ -622,7 +1075,7 @@ export class FoundryPlanApply {
622
1075
  });
623
1076
  throw e;
624
1077
  }
625
- await store.putServiceIds(serviceIds);
1078
+ await store.putServiceIds(serviceIds, fence);
626
1079
  await this.audit.recordApply({
627
1080
  actor: this.actor,
628
1081
  orgId: platform.ownerOrgId,
@@ -677,7 +1130,7 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
677
1130
  * belongs to is already left in place -- deleting only the rule and not the
678
1131
  * zone is a decision left for whoever wires teardown in, not made here.
679
1132
  */
680
- function destroyTargets(serviceIds) {
1133
+ function destroyTargets(serviceIds, orgId) {
681
1134
  // Reverse-teardown order: everything under one of these prefixes is
682
1135
  // collected in this order, then every ZITADEL app (there can be several,
683
1136
  // #71), then the two scope ids last.
@@ -685,6 +1138,8 @@ function destroyTargets(serviceIds) {
685
1138
  'zitadel:login-host:',
686
1139
  'cloudflare:tunnel:',
687
1140
  'cloudflare:access:',
1141
+ // #318: an entry's edge Worker and its route.
1142
+ 'cloudflare:worker:',
688
1143
  'coolify:database:',
689
1144
  'coolify:app:',
690
1145
  'zitadel:customers:',
@@ -692,20 +1147,80 @@ function destroyTargets(serviceIds) {
692
1147
  'ai:key:',
693
1148
  // F6b: an entry's files key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
694
1149
  'files:key:',
1150
+ // F7 (#317): an entry's names key, `<orgId>:<entry>` -- names has no org-root key.
1151
+ 'names:key:',
695
1152
  ];
696
1153
  const targets = [];
1154
+ const adopted = adoptedResourceIds(serviceIds);
697
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
+ }
698
1171
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
699
1172
  if (!resourceKey.startsWith(prefix))
700
1173
  continue;
1174
+ // Archon#271: foundry did not create an adopted app or database, so it never deletes it.
1175
+ if (adopted.has(resourceKey))
1176
+ continue;
701
1177
  if (prefix === 'ai:key:' && !resourceKey.slice(prefix.length).includes(':'))
702
1178
  continue;
703
1179
  if (prefix === 'files:key:' && !resourceKey.slice(prefix.length).includes(':'))
704
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
+ }
705
1196
  const [system, kind] = prefix.split(':');
706
1197
  targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
707
1198
  }
708
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
+ }
709
1224
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
710
1225
  if (!resourceKey.startsWith('zitadel:app:'))
711
1226
  continue;
@@ -726,6 +1241,38 @@ function destroyTargets(serviceIds) {
726
1241
  }
727
1242
  return targets;
728
1243
  }
1244
+ /** One step per target, then the declared org's own deletion, which has no target. */
1245
+ /** The `serviceIds` keys of the coolify app and database every `adopted.<entry>` record names. */
1246
+ function adoptedResourceIds(serviceIds) {
1247
+ // Fail closed: an adopted record that cannot be read could name a resource destroy must keep.
1248
+ const unreadable = adoptedKeysUnreadable(serviceIds);
1249
+ if (unreadable.length > 0) {
1250
+ throw new Error(`destroy refused: the adopted record ${unreadable.join(', ')} cannot be read, so foundry cannot tell which resources it must keep`);
1251
+ }
1252
+ const uuids = new Set();
1253
+ for (const [key, raw] of Object.entries(serviceIds)) {
1254
+ if (!key.startsWith('adopted.'))
1255
+ continue;
1256
+ const snapshot = parseAdoptSnapshot(raw);
1257
+ if (snapshot) {
1258
+ uuids.add(snapshot.application.uuid);
1259
+ uuids.add(snapshot.database.uuid);
1260
+ }
1261
+ }
1262
+ return new Set(Object.entries(serviceIds)
1263
+ .filter(([k, id]) => (k.startsWith('coolify:app:') || k.startsWith('coolify:database:')) && uuids.has(id))
1264
+ .map(([k]) => k));
1265
+ }
1266
+ /** Lines for adopted resources `destroy` keeps, so the operator sees them beside the deletes. */
1267
+ function keptDescriptions(serviceIds) {
1268
+ return [...adoptedResourceIds(serviceIds)].map((k) => `keep ${k.split(':')[0]} ${k.split(':')[1]} ${k.split(':').slice(2).join(':')} (adopted, not deleted)`);
1269
+ }
1270
+ function destroyStepDescriptions(targets) {
1271
+ return [
1272
+ ...targets.map((t) => `delete ${t.system} ${t.kind} ${t.key}`),
1273
+ 'delete the declared org',
1274
+ ];
1275
+ }
729
1276
  /** The `AppliedState.serviceIds` key a completed {@link DestroyTarget} should be removed from. */
730
1277
  function destroyServiceIdKey(target) {
731
1278
  if (target.kind === 'project' || target.kind === 'org')
@@ -746,7 +1293,6 @@ const STEP_FIELDS = {
746
1293
  'coolify:chat': 'chat.usesDomain',
747
1294
  'coolify:calls': 'calls.enabled',
748
1295
  'coolify:calls-service': 'calls.enabled',
749
- 'files:storage': 'storage',
750
1296
  'ai:budget': 'ai',
751
1297
  'ai:key': 'ai',
752
1298
  'cms:site': 'site',
@@ -756,6 +1302,7 @@ const STEP_FIELDS = {
756
1302
  'cloudflare:zone': 'hosting.usesDomain',
757
1303
  'cloudflare:access': 'hosting.accessEmails',
758
1304
  'cloudflare:ratelimit': 'hosting.usesDomain',
1305
+ 'cloudflare:worker': 'hosting.entries.edge',
759
1306
  };
760
1307
  function stepField(action) {
761
1308
  // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
@@ -765,6 +1312,9 @@ function stepField(action) {
765
1312
  // The org's own files key (F6b) is keyed `<orgId>`, an entry's `<orgId>:<entry>`.
766
1313
  if (action.system === 'files' && action.kind === 'key')
767
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';
768
1318
  // An org declares several issuer apps, each keyed by its name (#71).
769
1319
  if (action.system === 'zitadel' && action.kind === 'app') {
770
1320
  return `hosting.issuerApps.${action.key}`;
@@ -813,6 +1363,17 @@ function failedFields(org, detail, completed, serviceIds) {
813
1363
  }
814
1364
  }
815
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
+ }
816
1377
  // The same for a jobs registration whose credentials never reached the host.
817
1378
  if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
818
1379
  !serviceIds[`jobs:registration:${org.id}`]) {