@wtfalch/foundry 0.43.0 → 0.44.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/index.d.ts +3 -3
  2. package/dist/index.js +1 -1
  3. package/dist/migrations/0057_org_customers.sql +29 -0
  4. package/dist/org.d.ts +42 -0
  5. package/dist/org.js +52 -26
  6. package/dist/plan/boule.d.ts +43 -2
  7. package/dist/plan/boule.js +48 -1
  8. package/dist/plan/broker-entries.d.ts +28 -0
  9. package/dist/plan/broker-entries.js +46 -1
  10. package/dist/plan/diff.d.ts +14 -0
  11. package/dist/plan/diff.js +176 -18
  12. package/dist/plan/executor.d.ts +100 -2
  13. package/dist/plan/executor.js +273 -16
  14. package/dist/plan/hostnames.d.ts +7 -0
  15. package/dist/plan/hostnames.js +17 -2
  16. package/dist/plan/locked-hostnames.d.ts +11 -0
  17. package/dist/plan/locked-hostnames.js +13 -0
  18. package/dist/plan/mcp.d.ts +57 -0
  19. package/dist/plan/mcp.js +131 -0
  20. package/dist/plan/org-names.d.ts +2 -2
  21. package/dist/plan/org-names.js +16 -1
  22. package/dist/plan/plan-apply.d.ts +26 -0
  23. package/dist/plan/plan-apply.js +45 -5
  24. package/dist/plan/proxy-labels.js +2 -0
  25. package/dist/plan/worker-app-database.d.ts +148 -0
  26. package/dist/plan/worker-app-database.js +424 -0
  27. package/dist/plan/worker-app.d.ts +320 -0
  28. package/dist/plan/worker-app.js +792 -0
  29. package/dist/plan/zitadel-machine-user.d.ts +13 -1
  30. package/dist/plan/zitadel-machine-user.js +80 -0
  31. package/dist/requested-changes.d.ts +12 -3
  32. package/dist/requested-changes.js +22 -0
  33. package/dist/store/postgres.js +12 -6
  34. package/dist/store/schema.d.ts +34 -0
  35. package/dist/store/schema.js +3 -0
  36. package/dist/worker-app-config.d.ts +151 -0
  37. package/dist/worker-app-config.js +438 -0
  38. package/package.json +1 -1
@@ -0,0 +1,424 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { REMOTE_DATABASE_PORT, mergeTcpIngress, recordedHttpIngress, recordedTcpRoutes, remoteDatabaseKeys, } from './remote-database.js';
3
+ import { WORKER_APP_CALL_TIMEOUT_MS, withCallTimeout, workerAppSecretRef } from './worker-app.js';
4
+ export const WORKER_APP_MIGRATION_TIMEOUT_MS = 300_000;
5
+ export const MIGRATIONS_ASSET_NAME = 'migrations.json';
6
+ const MIGRATIONS_MAX_CHARS = 4_000_000;
7
+ const MIGRATIONS_MAX_COUNT = 1000;
8
+ const MIGRATION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
9
+ const HOLD_DELIVERY_ATTEMPTS = 3;
10
+ // ---- scope keys ------------------------------------------------------------
11
+ /** The Hyperdrive config, `{name, id}` as JSON. No secret. */
12
+ export const workerAppHyperdriveScopeKey = (entryKey) => `workerapp_hyperdrive_${entryKey}`;
13
+ /** The migration ids applied, a JSON list, for the plan and the operator. The database's ledger is the fact. */
14
+ export const workerAppMigrationsScopeKey = (entryKey) => `workerapp_dbmig_${entryKey}`;
15
+ /** The applied route marker (`{hostname, database}`). */
16
+ export const workerAppDbRouteMarkerScopeKey = (entryKey) => `workerapp_dbroute_${entryKey}`;
17
+ /** The Hyperdrive config name of an entry: the Worker's script name plus `-db`. */
18
+ export const hyperdriveName = (scriptName) => `${scriptName}-db`;
19
+ /** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
20
+ export const hyperdriveHostname = (org, entryKey, zone) => `${createHash('sha256').update(org.id).digest('hex').slice(0, 8)}-${entryKey}-hd.${zone}`;
21
+ // ---- parsing ---------------------------------------------------------------
22
+ /**
23
+ * Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
24
+ * not a list of `{id, sql}` with unique ids is refused, naming no SQL.
25
+ */
26
+ export function parseMigrations(text) {
27
+ if (text === null)
28
+ return [];
29
+ if (text.length > MIGRATIONS_MAX_CHARS) {
30
+ throw new Error(`${MIGRATIONS_ASSET_NAME} is larger than ${MIGRATIONS_MAX_CHARS} characters`);
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(text);
35
+ }
36
+ catch {
37
+ throw new Error(`${MIGRATIONS_ASSET_NAME} is not JSON`);
38
+ }
39
+ if (!Array.isArray(parsed) || parsed.length > MIGRATIONS_MAX_COUNT) {
40
+ throw new Error(`${MIGRATIONS_ASSET_NAME} must be a list of at most ${MIGRATIONS_MAX_COUNT} migrations`);
41
+ }
42
+ const seen = new Set();
43
+ return parsed.map((item, index) => {
44
+ const record = item;
45
+ if (typeof item !== 'object' ||
46
+ item === null ||
47
+ Array.isArray(item) ||
48
+ Object.keys(item).some((key) => key !== 'id' && key !== 'sql') ||
49
+ typeof record?.id !== 'string' ||
50
+ !MIGRATION_ID.test(record.id) ||
51
+ typeof record.sql !== 'string' ||
52
+ record.sql.trim() === '') {
53
+ throw new Error(`${MIGRATIONS_ASSET_NAME} entry ${index} must be { id, sql } with an id of letters, digits, '.', '_' or '-' and a non-empty sql`);
54
+ }
55
+ if (seen.has(record.id)) {
56
+ throw new Error(`${MIGRATIONS_ASSET_NAME} names migration '${record.id}' twice`);
57
+ }
58
+ seen.add(record.id);
59
+ return { id: record.id, sql: record.sql };
60
+ });
61
+ }
62
+ /**
63
+ * A present `migrations.json` runs as the database owner, so its bytes must be the ones the
64
+ * declaration pinned. No file needs no digest; a file with no declared digest, or a different
65
+ * one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
66
+ */
67
+ export function verifyMigrationsDigest(text, declared) {
68
+ if (text === null)
69
+ return;
70
+ if (!declared) {
71
+ throw new Error(`${MIGRATIONS_ASSET_NAME} is present and workerApp.database.migrationsSha256 is not declared`);
72
+ }
73
+ if (createHash('sha256').update(text, 'utf8').digest('hex') !== declared) {
74
+ throw new Error(`${MIGRATIONS_ASSET_NAME} does not match workerApp.database.migrationsSha256`);
75
+ }
76
+ }
77
+ /** The URL of `migrations.json` beside the module: the same release, another file. */
78
+ export const migrationsUrl = (artifactUrl) => artifactUrl.slice(0, artifactUrl.lastIndexOf('/') + 1) + MIGRATIONS_ASSET_NAME;
79
+ /**
80
+ * The migrations the database does not hold, in order. The applied ledger must be the first
81
+ * migrations of the file: a missing or reordered one is refused (migrations are append-only).
82
+ */
83
+ export function unappliedDatabaseMigrations(all, applied) {
84
+ const appliedSet = new Set(applied);
85
+ const head = new Set(all.slice(0, applied.length).map((m) => m.id));
86
+ for (const id of appliedSet) {
87
+ if (!head.has(id)) {
88
+ throw new Error(`migration '${id}' was applied and is no longer in the same place in ${MIGRATIONS_ASSET_NAME}; migrations are append-only`);
89
+ }
90
+ }
91
+ return all.filter((m) => !appliedSet.has(m.id));
92
+ }
93
+ const parseHeldAccess = (raw) => {
94
+ if (!raw)
95
+ return null;
96
+ try {
97
+ const held = JSON.parse(raw);
98
+ return typeof held.id === 'string' &&
99
+ typeof held.clientId === 'string' &&
100
+ typeof held.clientSecret === 'string' &&
101
+ held.clientSecret !== ''
102
+ ? held
103
+ : null;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ };
109
+ /** Connection parts of the owner URL; the message names nothing from it. */
110
+ function parseOwnerUrl(url, at) {
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(url);
114
+ }
115
+ catch {
116
+ throw new Error(`${at}: the database reports no usable connection URL`);
117
+ }
118
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
119
+ if (!parsed.hostname || !database || !parsed.username) {
120
+ throw new Error(`${at}: the database reports no usable connection URL`);
121
+ }
122
+ return {
123
+ host: parsed.hostname,
124
+ database,
125
+ user: decodeURIComponent(parsed.username),
126
+ password: decodeURIComponent(parsed.password),
127
+ };
128
+ }
129
+ /**
130
+ * Makes the database true and returns the Hyperdrive config id for the `HYPERDRIVE` binding.
131
+ * Secrets (the owner and runtime passwords, the Access service token) go only to the port and the
132
+ * held-key store, never into an error, a recorded id or a return value. Each outside call is
133
+ * bounded by a timeout; the error names the call, never the cause.
134
+ */
135
+ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want, context, zoneApex) {
136
+ const at = `cloudflare:worker-app for ${org.slug} entry '${entry.key}' database`;
137
+ const ms = deps.timeoutMs ?? WORKER_APP_CALL_TIMEOUT_MS;
138
+ const call = (label, task, over) => withCallTimeout(over ?? ms, `${at}: ${label}`, task);
139
+ const { port, heldKeys } = deps;
140
+ if (!heldKeys?.put)
141
+ throw new Error(`${at}: needs a held-key store that can put`);
142
+ const put = heldKeys.put;
143
+ const keys = remoteDatabaseKeys(entry.key);
144
+ const databaseId = context.recordedIds()[`coolify:database:${org.id}`];
145
+ if (!databaseId)
146
+ throw new Error(`${at}: ${org.slug}'s database is not applied yet`);
147
+ const tunnelId = context.recordedIds()[`cloudflare:tunnel:${org.id}`];
148
+ if (!tunnelId)
149
+ throw new Error(`${at}: ${org.slug}'s Cloudflare tunnel is not applied yet`);
150
+ const ownerUrl = await call('read the database address', () => deps.ownerUrl(databaseId));
151
+ deps.secret(ownerUrl);
152
+ const owner = parseOwnerUrl(ownerUrl, at);
153
+ deps.secret(owner.password);
154
+ const hostname = hyperdriveHostname(org, entry.key, zoneApex);
155
+ // 1. The service token: minted once (rule 1), its secret held before anything else uses it.
156
+ const access = await ensureServiceToken(deps, org, entry, scriptName, at, call, context, put);
157
+ // 2. The Access gate and the route behind it.
158
+ const zone = await call('find the zone', () => deps.zones.findZoneByApex(zoneApex));
159
+ if (!zone)
160
+ throw new Error(`${at}: zone ${zoneApex} is not live`);
161
+ let accessAppId = context.recordedIds()[keys.access];
162
+ if (!accessAppId) {
163
+ const live = (await call('list Access apps', () => deps.access.listAccessApps())).find((a) => a.domain === hostname);
164
+ accessAppId = (live ??
165
+ (await call('create the Access app', () => deps.access.createAccessApp({
166
+ name: `${scriptName}-db-access`,
167
+ domain: hostname,
168
+ type: 'self_hosted',
169
+ })))).id;
170
+ context.recordScopeId(keys.access, accessAppId);
171
+ await context.persistScopeIds();
172
+ }
173
+ const appId = accessAppId;
174
+ const policies = await call('list Access policies', () => deps.access.listAccessPolicies(appId));
175
+ const hasPolicy = policies.some((p) => p.decision === 'non_identity' &&
176
+ (p.include ?? []).some((r) => r.service_token?.token_id === access.id));
177
+ if (!hasPolicy) {
178
+ await call('create the Access policy', () => deps.access.createAccessPolicy(appId, {
179
+ name: 'hyperdrive',
180
+ decision: 'non_identity',
181
+ include: [{ service_token: { token_id: access.id } }],
182
+ }));
183
+ }
184
+ const marker = JSON.stringify({ hostname, database: databaseId });
185
+ if (context.recordedIds()[workerAppDbRouteMarkerScopeKey(entry.key)] !== marker) {
186
+ context.recordScopeId(keys.route, JSON.stringify({ hostname, service: `tcp://${owner.host}:${REMOTE_DATABASE_PORT}` }));
187
+ await call('write the tunnel route', () => deps.tunnels.putTunnelConfig(tunnelId, {
188
+ ingress: mergeTcpIngress(recordedHttpIngress(context.recordedIds()), recordedTcpRoutes(context.recordedIds())),
189
+ }));
190
+ if ((await call('look for the DNS record', () => deps.dns.findDnsRecord(zone.name, hostname, 'CNAME'))).length === 0) {
191
+ await call('create the DNS record', () => deps.dns.createDnsRecord(zone.name, {
192
+ type: 'CNAME',
193
+ name: hostname,
194
+ content: `${tunnelId}.cfargotunnel.com`,
195
+ proxied: true,
196
+ }));
197
+ }
198
+ context.recordScopeId(workerAppDbRouteMarkerScopeKey(entry.key), marker);
199
+ await context.persistScopeIds();
200
+ }
201
+ // 3. The runtime password: minted once and held, so a rerun hands out the same one.
202
+ const runtimeUser = `${owner.database}_rt`;
203
+ const rtRef = workerAppSecretRef(org.id, entry.key, 'DATABASE_RT_PASSWORD');
204
+ let runtimePassword = (await call('read the held runtime password', () => heldKeys.resolve(rtRef))) ?? '';
205
+ if (!runtimePassword) {
206
+ runtimePassword = randomBytes(24).toString('base64url');
207
+ const minted = runtimePassword;
208
+ await call('hold the runtime password', () => put(rtRef, minted));
209
+ }
210
+ deps.secret(runtimePassword);
211
+ const ownerOrigin = {
212
+ host: hostname,
213
+ database: owner.database,
214
+ user: owner.user,
215
+ password: owner.password,
216
+ accessClientId: access.clientId,
217
+ accessClientSecret: access.clientSecret,
218
+ };
219
+ // 4. Migrations with the owner credentials. A failure stops the deploy; ids already applied stay.
220
+ const text = await call('fetch the release migrations', () => port.fetchReleaseAsset(migrationsUrl(want.artifactUrl)));
221
+ let migrations;
222
+ try {
223
+ verifyMigrationsDigest(text, want.database.migrationsSha256);
224
+ migrations = parseMigrations(text);
225
+ }
226
+ catch (error) {
227
+ // The message names the file and the entry index, never SQL.
228
+ throw new Error(`${at}: ${error.message}`);
229
+ }
230
+ const applied = await call('read the applied migrations', () => port.appliedMigrationIds(ownerOrigin));
231
+ let pending;
232
+ try {
233
+ pending = unappliedDatabaseMigrations(migrations, applied);
234
+ }
235
+ catch (error) {
236
+ throw new Error(`${at}: ${error.message}`);
237
+ }
238
+ const done = [...applied];
239
+ for (const migration of pending) {
240
+ await call(`apply migration '${migration.id}'`, () => port.applyMigration(ownerOrigin, migration), deps.migrationTimeoutMs ?? WORKER_APP_MIGRATION_TIMEOUT_MS);
241
+ done.push(migration.id);
242
+ context.recordScopeId(workerAppMigrationsScopeKey(entry.key), JSON.stringify(done));
243
+ await context.persistScopeIds();
244
+ }
245
+ // 5. The runtime role, after the migrations so the tables exist for the revokes.
246
+ await call('ensure the runtime role', () => port.ensureRuntimeRole({
247
+ owner: ownerOrigin,
248
+ runtime: { user: runtimeUser, password: runtimePassword },
249
+ appendOnly: [...want.database.appendOnly],
250
+ }));
251
+ // 6. Hyperdrive, with the runtime credentials only.
252
+ const name = hyperdriveName(scriptName);
253
+ const { id } = await call('upsert the Hyperdrive config', () => port.upsertHyperdrive({
254
+ name,
255
+ origin: {
256
+ host: hostname,
257
+ database: owner.database,
258
+ user: runtimeUser,
259
+ password: runtimePassword,
260
+ accessClientId: access.clientId,
261
+ accessClientSecret: access.clientSecret,
262
+ },
263
+ }));
264
+ context.recordScopeId(workerAppHyperdriveScopeKey(entry.key), JSON.stringify({ name, id }));
265
+ await context.persistScopeIds();
266
+ return { hyperdriveId: id };
267
+ }
268
+ /**
269
+ * The Access service token Hyperdrive and the migration runner present. Minted once: its id is
270
+ * recorded under an undelivered key and persisted before the secret is held, the delivered id
271
+ * replaces it after the hold. A token minted and never held is never minted again.
272
+ */
273
+ async function ensureServiceToken(deps, org, entry, scriptName, at, call, context, put) {
274
+ const keys = remoteDatabaseKeys(entry.key);
275
+ const ref = workerAppSecretRef(org.id, entry.key, 'HYPERDRIVE_ACCESS');
276
+ const heldKeys = deps.heldKeys;
277
+ const lost = context.recordedIds()[keys.undelivered];
278
+ if (lost) {
279
+ throw new Error(`${at}: service token ${lost} was minted and its secret never reached the held-key store; delete the token in Cloudflare and clear the recorded id before minting again`);
280
+ }
281
+ const held = parseHeldAccess(await call('read the held service token', () => heldKeys.resolve(ref)));
282
+ if (held) {
283
+ deps.secret(held.clientSecret);
284
+ deps.secret(held.clientId);
285
+ return held;
286
+ }
287
+ const recordedToken = context.recordedIds()[keys.token];
288
+ if (recordedToken) {
289
+ throw new Error(`${at}: service token ${recordedToken} is recorded and its secret is not held; delete the token in Cloudflare and clear the recorded id to mint it again`);
290
+ }
291
+ const tokenName = `${hyperdriveName(scriptName)}-access`;
292
+ if (await call('look for the service token', () => deps.access.findServiceTokenByName(tokenName))) {
293
+ throw new Error(`${at}: a service token named ${tokenName} exists and foundry holds no secret for it; delete it in Cloudflare before applying again`);
294
+ }
295
+ const minted = await call('mint the service token', () => deps.access.createServiceToken(tokenName));
296
+ deps.secret(minted.clientSecret);
297
+ deps.secret(minted.clientId);
298
+ context.recordScopeId(keys.undelivered, minted.id);
299
+ await context.persistScopeIds();
300
+ const value = {
301
+ id: minted.id,
302
+ clientId: minted.clientId,
303
+ clientSecret: minted.clientSecret,
304
+ };
305
+ let stored = false;
306
+ for (let attempt = 1; attempt <= HOLD_DELIVERY_ATTEMPTS && !stored; attempt++) {
307
+ try {
308
+ await call('hold the service token', () => put(ref, JSON.stringify(value)));
309
+ stored = true;
310
+ }
311
+ catch {
312
+ // Retried; the cause may echo the secret and is dropped.
313
+ }
314
+ }
315
+ if (!stored) {
316
+ throw new Error(`${at}: service token ${minted.id} could not be held after ${HOLD_DELIVERY_ATTEMPTS} attempts; delete the token in Cloudflare and clear the recorded id before minting again`);
317
+ }
318
+ context.recordScopeId(keys.token, minted.id);
319
+ context.recordScopeId(keys.undelivered, '');
320
+ await context.persistScopeIds();
321
+ return value;
322
+ }
323
+ /** Deletes the entry's Hyperdrive config, found by the recorded name, and clears the record. */
324
+ export async function deleteWorkerAppHyperdrive(port, entryKey, recorded, context, call) {
325
+ const raw = recorded[workerAppHyperdriveScopeKey(entryKey)];
326
+ if (!raw)
327
+ return;
328
+ let name;
329
+ try {
330
+ name = JSON.parse(raw).name;
331
+ }
332
+ catch {
333
+ name = undefined;
334
+ }
335
+ if (!name)
336
+ return;
337
+ if (!port) {
338
+ throw new Error(`the Hyperdrive config ${name} is recorded and this executor was built without a WorkerAppDatabasePort; cannot delete it`);
339
+ }
340
+ const hyperdrive = name;
341
+ await call('delete the Hyperdrive config', () => port.deleteHyperdrive(hyperdrive));
342
+ if (context) {
343
+ context.recordScopeId(workerAppHyperdriveScopeKey(entryKey), '');
344
+ await context.persistScopeIds();
345
+ }
346
+ }
347
+ const isGone = (error) => error?.status === 404;
348
+ /** Runs `task`; an answer of "already gone" (404) counts as done, so a rerun converges. */
349
+ async function tolerateGone(task) {
350
+ try {
351
+ await task();
352
+ }
353
+ catch (error) {
354
+ if (!isGone(error))
355
+ throw error;
356
+ }
357
+ }
358
+ /**
359
+ * Removes the Cloudflare state the database route made, in the order that never leaves a name
360
+ * pointing at nothing: the DNS record, the tunnel rule (the tunnel config is rewritten without
361
+ * it), the Access app, the service token (also one minted and never delivered), then the held
362
+ * token and runtime password and the recorded ids. Each step tolerates "already gone", and the
363
+ * ids are cleared last, so a failed run is repeated by the next one. With `context` null (org
364
+ * destroy) the ids are not cleared: destroy drops them with the org.
365
+ */
366
+ export async function deleteWorkerAppDatabaseRoute(database, heldKeys, orgId, entryKey, recorded, context, call) {
367
+ const keys = remoteDatabaseKeys(entryKey);
368
+ const route = recorded[keys.route];
369
+ const accessApp = recorded[keys.access];
370
+ const tokens = [recorded[keys.token], recorded[keys.undelivered]].filter((id) => Boolean(id));
371
+ if (!route && !accessApp && tokens.length === 0)
372
+ return;
373
+ if (!database) {
374
+ throw new Error('the database route is recorded and this executor was built without a WorkerAppDatabasePort; cannot delete it');
375
+ }
376
+ if (route) {
377
+ let hostname;
378
+ try {
379
+ hostname = JSON.parse(route).hostname;
380
+ }
381
+ catch {
382
+ hostname = undefined;
383
+ }
384
+ if (typeof hostname === 'string' && hostname.includes('.')) {
385
+ // The hostname is `<label>-hd.<zone apex>`, one label deep.
386
+ const zone = await call('find the zone', () => database.zones.findZoneByApex(hostname.slice(hostname.indexOf('.') + 1)));
387
+ if (zone) {
388
+ const records = await call('look for the DNS record', () => database.dns.findDnsRecord(zone.name, hostname, 'CNAME'));
389
+ for (const record of records) {
390
+ if (!record.content.endsWith('.cfargotunnel.com'))
391
+ continue;
392
+ await call('delete the DNS record', () => tolerateGone(() => database.dns.deleteDnsRecord(zone.name, record.id)));
393
+ }
394
+ }
395
+ }
396
+ const tunnelId = recorded[`cloudflare:tunnel:${orgId}`];
397
+ if (tunnelId) {
398
+ const without = { ...recorded, [keys.route]: '' };
399
+ await call('remove the tunnel route', () => tolerateGone(() => database.tunnels.putTunnelConfig(tunnelId, {
400
+ ingress: mergeTcpIngress(recordedHttpIngress(without), recordedTcpRoutes(without)),
401
+ })));
402
+ }
403
+ }
404
+ if (accessApp) {
405
+ await call('delete the Access app', () => tolerateGone(() => database.access.deleteAccessApp(accessApp)));
406
+ }
407
+ for (const id of tokens) {
408
+ await call('delete the service token', () => tolerateGone(() => database.access.removeServiceToken(id)));
409
+ }
410
+ if (!context)
411
+ return;
412
+ // The store has no delete: an empty value reads as "not held", so a redeclared app mints again.
413
+ if (heldKeys?.put) {
414
+ const put = heldKeys.put;
415
+ for (const name of ['HYPERDRIVE_ACCESS', 'DATABASE_RT_PASSWORD']) {
416
+ await call('clear a held secret', () => put(workerAppSecretRef(orgId, entryKey, name), ''));
417
+ }
418
+ }
419
+ for (const key of [keys.route, keys.access, keys.token, keys.undelivered]) {
420
+ context.recordScopeId(key, '');
421
+ }
422
+ context.recordScopeId(workerAppDbRouteMarkerScopeKey(entryKey), '');
423
+ await context.persistScopeIds();
424
+ }