@wtfalch/foundry 0.60.0 → 0.61.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 (218) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +10 -5
  44. package/dist/index.js +5 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +46 -42
  181. package/dist/plan/worker-app-database.js +150 -153
  182. package/dist/plan/worker-app.d.ts +5 -65
  183. package/dist/plan/worker-app.js +28 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +1 -11
  213. package/dist/worker-app-config.js +4 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. package/package.json +18 -1
@@ -36,10 +36,31 @@ export declare function isAllowed(kind: StageKind, name: string): boolean;
36
36
  export declare const SHARED_WITH_PROD: Readonly<Record<string, string>>;
37
37
  /** Whether `name` may equal production's value. */
38
38
  export declare const isSharedWithProd: (name: string) => boolean;
39
+ /** The live app's own sign-in values. Stage must hold none of them. */
40
+ export interface LiveSignIn {
41
+ /** The live customers organisation's id. */
42
+ customersOrgId?: string;
43
+ /** The live app's client ids. */
44
+ clientIds: readonly string[];
45
+ /** The live workspaces keys. */
46
+ workspacesKeys: readonly string[];
47
+ /** The live apps' MCP audiences (the ZITADEL project ids). */
48
+ mcpAudiences?: readonly string[];
49
+ /** The live apps' MCP resources (their public MCP addresses). */
50
+ mcpResources?: readonly string[];
51
+ }
52
+ /** What a stage with its own sign-in says about it, for the isolation check. */
53
+ export interface StageSignInGuard {
54
+ /** Stage signs in its own test customers organisation, so its organisation id may differ from production's. */
55
+ customers: boolean;
56
+ live: LiveSignIn;
57
+ }
39
58
  export interface StageIsolationInput {
40
59
  kind: StageKind;
41
60
  prod: Surface;
42
61
  stage: Surface;
62
+ /** Present for a stage with its own sign-in (a workspace). */
63
+ signIn?: StageSignInGuard;
43
64
  /** The mail env stage is allowed to hold: the sink's wiring. Empty: stage holds no mail env. */
44
65
  sinkMail?: Readonly<Record<string, string>>;
45
66
  }
@@ -51,6 +72,9 @@ export interface StageIsolationInput {
51
72
  * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
52
73
  * - A4 names: `resource_shared`, `host_is_prod_internal`.
53
74
  * - A5 mail: `mail_not_sink`, `mail_token`.
75
+ * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
76
+ * `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
77
+ * id, the live workspaces key, or a live MCP audience or resource.
54
78
  */
55
79
  export declare function assertStageIsolated(input: StageIsolationInput): string[];
56
80
  /** The sha256 of the stage env name set plus the resource set: what `bg.<id>.stageisolation` records. */
@@ -87,6 +87,11 @@ export const STAGE_ALLOWLIST = {
87
87
  'JOBS_SYNC_KEY',
88
88
  'ANALYTICS_URL',
89
89
  'ANALYTICS_SITE_KEY',
90
+ 'WORKSPACES_URL',
91
+ 'WORKSPACES_API_KEY',
92
+ 'MCP_AUDIENCE',
93
+ 'MCP_RESOURCE',
94
+ 'MCP_ISSUER',
90
95
  ],
91
96
  };
92
97
  /** Names no stage holds, whatever the app. */
@@ -159,6 +164,8 @@ export const SHARED_WITH_PROD = {
159
164
  AI_URL: 'the ai service is shared; stage reaches it only with its own organisation and key',
160
165
  JOBS_SCHEDULER_URL: 'the jobs service is shared; stage reaches it only with its own project and signing secret',
161
166
  ANALYTICS_URL: 'the analytics collector is shared; stage reports under its own site key',
167
+ MCP_ISSUER: 'the sign-in service is shared by decision; the issuer is a public address, not a credential',
168
+ WORKSPACES_URL: "the broker is shared; stage's own workspaces key opens its own product tenant, which holds no live customers' workspaces",
162
169
  };
163
170
  /** Whether `name` may equal production's value. */
164
171
  export const isSharedWithProd = (name) => Object.keys(SHARED_WITH_PROD).some((p) => globMatches(p, name));
@@ -174,6 +181,9 @@ const hostOf = (value) => URL_HOST.exec(value)?.[1]?.toLowerCase() ?? null;
174
181
  * {@link SHARED_WITH_PROD} differs from production's), `live_mode_key`.
175
182
  * - A4 names: `resource_shared`, `host_is_prod_internal`.
176
183
  * - A5 mail: `mail_not_sink`, `mail_token`.
184
+ * - A6 sign-in (with `signIn`): `live_customers_org`, `live_client_id`, `live_workspaces_key`,
185
+ * `live_mcp_audience`, `live_mcp_resource` when stage holds the live customers organisation id, a live client
186
+ * id, the live workspaces key, or a live MCP audience or resource.
177
187
  */
178
188
  export function assertStageIsolated(input) {
179
189
  const { kind, prod, stage } = input;
@@ -211,9 +221,27 @@ export function assertStageIsolated(input) {
211
221
  out.add(`env_not_allowed:${name}`);
212
222
  if (item.value !== undefined && LIVE_MODE_KEY.test(item.value))
213
223
  out.add(`live_mode_key:${name}`);
224
+ if (input.signIn && item.value !== undefined) {
225
+ const { live } = input.signIn;
226
+ if (name === 'AUTH_ORGANIZATION_ID' && item.value === live.customersOrgId) {
227
+ out.add(`live_customers_org:${name}`);
228
+ }
229
+ if (name === 'AUTH_CLIENT_ID' && live.clientIds.includes(item.value)) {
230
+ out.add(`live_client_id:${name}`);
231
+ }
232
+ if (name === 'WORKSPACES_API_KEY' && live.workspacesKeys.includes(item.value)) {
233
+ out.add(`live_workspaces_key:${name}`);
234
+ }
235
+ if (name === 'MCP_AUDIENCE' && live.mcpAudiences?.includes(item.value)) {
236
+ out.add(`live_mcp_audience:${name}`);
237
+ }
238
+ if (name === 'MCP_RESOURCE' && live.mcpResources?.includes(item.value)) {
239
+ out.add(`live_mcp_resource:${name}`);
240
+ }
241
+ }
214
242
  if (item.class === 'literal')
215
243
  continue;
216
- const shared = isSharedWithProd(name);
244
+ const shared = isSharedWithProd(name) && !(input.signIn?.customers && name === 'AUTH_ORGANIZATION_ID');
217
245
  const counterpart = prodByName.get(name);
218
246
  if (shared) {
219
247
  const same = item.value !== undefined && counterpart?.value !== undefined
@@ -1,9 +1,11 @@
1
1
  import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import { type DatabaseMigration, MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, type WorkerAppMigrationRunner, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest } from '../worker-app-migrations.js';
2
3
  import type { AccessPort } from './edge-access.js';
3
4
  import type { DnsPort } from './edge-dns.js';
4
5
  import type { TunnelPort } from './edge-tunnels.js';
5
6
  import type { ZonePort } from './edge-zones.js';
6
7
  import type { StepExecutionContext } from './plan-apply.js';
8
+ export { type DatabaseMigration, MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, type WorkerAppMigrationRunner, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest, };
7
9
  /**
8
10
  * C2 (campaign 2026-10-15): a Worker app with `workerApp.database` reaches the org's Coolify
9
11
  * Postgres through a Hyperdrive config. The database host is Docker-internal, so Hyperdrive
@@ -35,18 +37,13 @@ export interface HyperdriveOrigin {
35
37
  caPem: string;
36
38
  };
37
39
  }
38
- /** One migration of a release's `migrations.json`. */
39
- export interface DatabaseMigration {
40
- id: string;
41
- sql: string;
42
- }
43
40
  /**
44
41
  * The calls a Worker app database needs beyond Cloudflare's Access, tunnel and DNS. Edge's
45
42
  * Hyperdrive SDK (`client.platform.hyperdrive`) and the SQL runner behind the tunnel
46
43
  * (`@wtfalch/db`'s migrate and `ensureRuntimeRole`) are the host's to implement; foundry only
47
44
  * calls this port. Every method is idempotent.
48
45
  */
49
- export interface WorkerAppDatabasePort {
46
+ export interface WorkerAppDatabasePort extends WorkerAppMigrationRunner<HyperdriveOrigin> {
50
47
  /** Creates or updates the Hyperdrive config `name`; returns its id. */
51
48
  upsertHyperdrive(input: {
52
49
  name: string;
@@ -63,29 +60,22 @@ export interface WorkerAppDatabasePort {
63
60
  }): Promise<{
64
61
  id: string;
65
62
  }>;
66
- /** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
67
- fetchReleaseAsset(url: string): Promise<string | null>;
68
- /** The migration ids the database's ledger holds, in the order applied. */
69
- appliedMigrationIds(owner: HyperdriveOrigin): Promise<string[]>;
70
- /** Runs `migration.sql` and records `migration.id` in the ledger in one transaction. */
71
- applyMigration(owner: HyperdriveOrigin, migration: DatabaseMigration): Promise<void>;
72
63
  /**
73
- * Creates or updates the `runtime.user` role with `runtime.password`, grants it DML on the
74
- * tables, revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table, and grants
75
- * it EXECUTE on each `grants` function.
64
+ * `uses.audit`: runs the sealing role's SQL (`audit/sealer-role.ts`) as the database owner. Idempotent: it makes
65
+ * the role or sets its password again, and grants. Optional: a host without it gets no sealing role, the entry's
66
+ * `audit:ledger` step stays blocked and says so.
76
67
  */
77
- ensureRuntimeRole(input: {
68
+ ensureSealerRole?(input: {
78
69
  owner: HyperdriveOrigin;
79
- runtime: {
70
+ sealer: {
80
71
  user: string;
81
72
  password: string;
82
73
  };
83
- appendOnly: string[];
84
- grants: string[];
74
+ statements: string[];
85
75
  }): Promise<void>;
76
+ /** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
77
+ fetchReleaseAsset(url: string): Promise<string | null>;
86
78
  }
87
- export declare const WORKER_APP_MIGRATION_TIMEOUT_MS = 300000;
88
- export declare const MIGRATIONS_ASSET_NAME = "migrations.json";
89
79
  /** The Hyperdrive config, `{name, id}` as JSON. No secret. */
90
80
  export declare const workerAppHyperdriveScopeKey: (entryKey: string) => string;
91
81
  /** The migration ids applied, a JSON list, for the plan and the operator. The database's ledger is the fact. */
@@ -96,24 +86,6 @@ export declare const workerAppDbRouteMarkerScopeKey: (entryKey: string) => strin
96
86
  export declare const hyperdriveName: (scriptName: string) => string;
97
87
  /** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
98
88
  export declare const hyperdriveHostname: (org: DeclaredOrg, entryKey: string, zone: string) => string;
99
- /**
100
- * Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
101
- * not a list of `{id, sql}` with unique ids is refused, naming no SQL.
102
- */
103
- export declare function parseMigrations(text: string | null): DatabaseMigration[];
104
- /**
105
- * A present `migrations.json` runs as the database owner, so its bytes must be the ones the
106
- * declaration pinned. No file needs no digest; a file with no declared digest, or a different
107
- * one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
108
- */
109
- export declare function verifyMigrationsDigest(text: string | null, declared: string | undefined): void;
110
- /** The URL of `migrations.json` beside the module: the same release, another file. */
111
- export declare const migrationsUrl: (artifactUrl: string) => string;
112
- /**
113
- * The migrations the database does not hold, in order. The applied ledger must be the first
114
- * migrations of the file: a missing or reordered one is refused (migrations are append-only).
115
- */
116
- export declare function unappliedDatabaseMigrations(all: readonly DatabaseMigration[], applied: readonly string[]): DatabaseMigration[];
117
89
  export interface WorkerAppDatabaseDeps {
118
90
  port: WorkerAppDatabasePort;
119
91
  tunnels: Pick<TunnelPort, 'putTunnelConfig'>;
@@ -135,12 +107,19 @@ export interface WorkerAppDatabaseDeps {
135
107
  resolve(ref: string): Promise<string | null | undefined>;
136
108
  put?(ref: string, value: string): Promise<void>;
137
109
  };
110
+ /** One plain line for the operator's log (never a secret). */
111
+ log?: (line: string) => void;
138
112
  /** Test hook: the timeout of a call, in ms. */
139
113
  timeoutMs?: number;
140
114
  /** Test hook: the timeout of one migration, in ms. */
141
115
  migrationTimeoutMs?: number;
142
116
  }
143
- type Call = <T>(label: string, task: () => Promise<T>, ms?: number) => Promise<T>;
117
+ export type Call = <T>(label: string, task: () => Promise<T>, ms?: number) => Promise<T>;
118
+ export interface HeldAccess {
119
+ id: string;
120
+ clientId: string;
121
+ clientSecret: string;
122
+ }
144
123
  /**
145
124
  * Makes the database true and returns the Hyperdrive config id for the `HYPERDRIVE` binding.
146
125
  * Secrets (the owner and runtime passwords, the Access service token) go only to the port and the
@@ -157,6 +136,32 @@ export declare function applyWorkerAppDatabase(deps: WorkerAppDatabaseDeps, org:
157
136
  }, context: StepExecutionContext, zoneApex: string): Promise<{
158
137
  hyperdriveId: string;
159
138
  }>;
139
+ export interface DatabaseRouteInput {
140
+ /** The scope-id family (`bgdb.<entryKey>.*`) the route is recorded under. */
141
+ entryKey: string;
142
+ accessName: string;
143
+ hostname: string;
144
+ /** The database host the tunnel reaches, inside the Docker network. */
145
+ upstreamHost: string;
146
+ /** Part of the applied marker: a change of database rewrites the route. */
147
+ markerDatabase: string;
148
+ tunnelId: string;
149
+ zoneApex: string;
150
+ tokenId: string;
151
+ at: string;
152
+ }
153
+ /**
154
+ * The Access app (service-token policy), the tunnel TCP rule and the proxied CNAME that put a
155
+ * database behind `hostname`. Idempotent; the ids are recorded and persisted as they appear.
156
+ * Shared by a Worker app's database (above) and the company's apps database (F4, #925).
157
+ */
158
+ export declare function ensureDatabaseRoute(deps: Pick<WorkerAppDatabaseDeps, 'tunnels' | 'access' | 'dns' | 'zones'>, call: Call, context: StepExecutionContext, input: DatabaseRouteInput): Promise<void>;
159
+ /**
160
+ * The Access service token Hyperdrive and the migration runner present. Minted once: its id is
161
+ * recorded under an undelivered key and persisted before the secret is held, the delivered id
162
+ * replaces it after the hold. A token minted and never held is never minted again.
163
+ */
164
+ export declare function ensureServiceToken(deps: Pick<WorkerAppDatabaseDeps, 'access' | 'heldKeys' | 'secret'>, org: DeclaredOrg, entry: Pick<HostingEntry, 'key'>, scriptName: string, at: string, call: Call, context: StepExecutionContext, put: (ref: string, value: string) => Promise<void>): Promise<HeldAccess>;
160
165
  /** Deletes the entry's Hyperdrive config, found by the recorded name, and clears the record. */
161
166
  export declare function deleteWorkerAppHyperdrive(port: Pick<WorkerAppDatabasePort, 'deleteHyperdrive'> | undefined, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
162
167
  /**
@@ -167,5 +172,4 @@ export declare function deleteWorkerAppHyperdrive(port: Pick<WorkerAppDatabasePo
167
172
  * ids are cleared last, so a failed run is repeated by the next one. With `context` null (org
168
173
  * destroy) the ids are not cleared: destroy drops them with the org.
169
174
  */
170
- export declare function deleteWorkerAppDatabaseRoute(database: WorkerAppDatabaseDeps | undefined, heldKeys: Pick<NonNullable<WorkerAppDatabaseDeps['heldKeys']>, 'put'> | undefined, orgId: string, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
171
- export {};
175
+ export declare function deleteWorkerAppDatabaseRoute(database: Pick<WorkerAppDatabaseDeps, 'tunnels' | 'access' | 'dns' | 'zones'> | undefined, heldKeys: Pick<NonNullable<WorkerAppDatabaseDeps['heldKeys']>, 'put'> | undefined, orgId: string, entryKey: string, recorded: Readonly<Record<string, string>>, context: Pick<StepExecutionContext, 'recordScopeId' | 'persistScopeIds'> | null, call: Call): Promise<void>;
@@ -1,12 +1,12 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
+ import { auditMigrations } from '../audit/migrations.js';
3
+ import { auditSealerRef, auditSealerScopeKey, auditSealerUndeliveredScopeKey, sealerRoleName, sealerRoleSql, } from '../audit/sealer-role.js';
4
+ import { MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest, } from '../worker-app-migrations.js';
2
5
  import { REMOTE_DATABASE_PORT, mergeTcpIngress, recordedHttpIngress, recordedTcpRoutes, remoteDatabaseKeys, } from './remote-database.js';
3
6
  import { WORKER_APP_DB_SCOPE_KEYS } from './worker-app-db.js';
4
7
  import { WORKER_APP_CALL_TIMEOUT_MS, withCallTimeout, workerAppSecretRef } from './worker-app.js';
5
- export const WORKER_APP_MIGRATION_TIMEOUT_MS = 300_000;
6
- export const MIGRATIONS_ASSET_NAME = 'migrations.json';
7
- const MIGRATIONS_MAX_CHARS = 4_000_000;
8
- const MIGRATIONS_MAX_COUNT = 1000;
9
- const MIGRATION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
8
+ // The pure parts moved to `src/worker-app-migrations.ts` (FM4); they stay exported from here.
9
+ export { MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIMEOUT_MS, migrationsUrl, parseMigrations, runWorkerAppMigrations, unappliedDatabaseMigrations, verifyMigrationsDigest, };
10
10
  const HOLD_DELIVERY_ATTEMPTS = 3;
11
11
  // ---- scope keys ------------------------------------------------------------
12
12
  /** The Hyperdrive config, `{name, id}` as JSON. No secret. */
@@ -19,78 +19,6 @@ export const workerAppDbRouteMarkerScopeKey = (entryKey) => `workerapp_dbroute_$
19
19
  export const hyperdriveName = (scriptName) => `${scriptName}-db`;
20
20
  /** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
21
21
  export const hyperdriveHostname = (org, entryKey, zone) => `${createHash('sha256').update(org.id).digest('hex').slice(0, 8)}-${entryKey}-hd.${zone}`;
22
- // ---- parsing ---------------------------------------------------------------
23
- /**
24
- * Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
25
- * not a list of `{id, sql}` with unique ids is refused, naming no SQL.
26
- */
27
- export function parseMigrations(text) {
28
- if (text === null)
29
- return [];
30
- if (text.length > MIGRATIONS_MAX_CHARS) {
31
- throw new Error(`${MIGRATIONS_ASSET_NAME} is larger than ${MIGRATIONS_MAX_CHARS} characters`);
32
- }
33
- let parsed;
34
- try {
35
- parsed = JSON.parse(text);
36
- }
37
- catch {
38
- throw new Error(`${MIGRATIONS_ASSET_NAME} is not JSON`);
39
- }
40
- if (!Array.isArray(parsed) || parsed.length > MIGRATIONS_MAX_COUNT) {
41
- throw new Error(`${MIGRATIONS_ASSET_NAME} must be a list of at most ${MIGRATIONS_MAX_COUNT} migrations`);
42
- }
43
- const seen = new Set();
44
- return parsed.map((item, index) => {
45
- const record = item;
46
- if (typeof item !== 'object' ||
47
- item === null ||
48
- Array.isArray(item) ||
49
- Object.keys(item).some((key) => key !== 'id' && key !== 'sql') ||
50
- typeof record?.id !== 'string' ||
51
- !MIGRATION_ID.test(record.id) ||
52
- typeof record.sql !== 'string' ||
53
- record.sql.trim() === '') {
54
- throw new Error(`${MIGRATIONS_ASSET_NAME} entry ${index} must be { id, sql } with an id of letters, digits, '.', '_' or '-' and a non-empty sql`);
55
- }
56
- if (seen.has(record.id)) {
57
- throw new Error(`${MIGRATIONS_ASSET_NAME} names migration '${record.id}' twice`);
58
- }
59
- seen.add(record.id);
60
- return { id: record.id, sql: record.sql };
61
- });
62
- }
63
- /**
64
- * A present `migrations.json` runs as the database owner, so its bytes must be the ones the
65
- * declaration pinned. No file needs no digest; a file with no declared digest, or a different
66
- * one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
67
- */
68
- export function verifyMigrationsDigest(text, declared) {
69
- if (text === null)
70
- return;
71
- if (!declared) {
72
- throw new Error(`${MIGRATIONS_ASSET_NAME} is present and workerApp.database.migrationsSha256 is not declared`);
73
- }
74
- if (createHash('sha256').update(text, 'utf8').digest('hex') !== declared) {
75
- throw new Error(`${MIGRATIONS_ASSET_NAME} does not match workerApp.database.migrationsSha256`);
76
- }
77
- }
78
- /** The URL of `migrations.json` beside the module: the same release, another file. */
79
- export const migrationsUrl = (artifactUrl) => artifactUrl.slice(0, artifactUrl.lastIndexOf('/') + 1) + MIGRATIONS_ASSET_NAME;
80
- /**
81
- * The migrations the database does not hold, in order. The applied ledger must be the first
82
- * migrations of the file: a missing or reordered one is refused (migrations are append-only).
83
- */
84
- export function unappliedDatabaseMigrations(all, applied) {
85
- const appliedSet = new Set(applied);
86
- const head = new Set(all.slice(0, applied.length).map((m) => m.id));
87
- for (const id of appliedSet) {
88
- if (!head.has(id)) {
89
- throw new Error(`migration '${id}' was applied and is no longer in the same place in ${MIGRATIONS_ASSET_NAME}; migrations are append-only`);
90
- }
91
- }
92
- return all.filter((m) => !appliedSet.has(m.id));
93
- }
94
22
  const parseHeldAccess = (raw) => {
95
23
  if (!raw)
96
24
  return null;
@@ -141,7 +69,6 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
141
69
  if (!heldKeys?.put)
142
70
  throw new Error(`${at}: needs a held-key store that can put`);
143
71
  const put = heldKeys.put;
144
- const keys = remoteDatabaseKeys(entry.key);
145
72
  // The foundry-made TLS database (worker-app-db) when recorded, else the legacy Coolify database.
146
73
  const databaseId = context.recordedIds()[WORKER_APP_DB_SCOPE_KEYS.app] ||
147
74
  context.recordedIds()[`coolify:database:${org.id}`];
@@ -158,49 +85,17 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
158
85
  // 1. The service token: minted once (rule 1), its secret held before anything else uses it.
159
86
  const access = await ensureServiceToken(deps, org, entry, scriptName, at, call, context, put);
160
87
  // 2. The Access gate and the route behind it.
161
- const zone = await call('find the zone', () => deps.zones.findZoneByApex(zoneApex));
162
- if (!zone)
163
- throw new Error(`${at}: zone ${zoneApex} is not live`);
164
- let accessAppId = context.recordedIds()[keys.access];
165
- if (!accessAppId) {
166
- const live = (await call('list Access apps', () => deps.access.listAccessApps())).find((a) => a.domain === hostname);
167
- accessAppId = (live ??
168
- (await call('create the Access app', () => deps.access.createAccessApp({
169
- name: `${scriptName}-db-access`,
170
- domain: hostname,
171
- type: 'self_hosted',
172
- })))).id;
173
- context.recordScopeId(keys.access, accessAppId);
174
- await context.persistScopeIds();
175
- }
176
- const appId = accessAppId;
177
- const policies = await call('list Access policies', () => deps.access.listAccessPolicies(appId));
178
- const hasPolicy = policies.some((p) => p.decision === 'non_identity' &&
179
- (p.include ?? []).some((r) => r.service_token?.token_id === access.id));
180
- if (!hasPolicy) {
181
- await call('create the Access policy', () => deps.access.createAccessPolicy(appId, {
182
- name: 'hyperdrive',
183
- decision: 'non_identity',
184
- include: [{ service_token: { token_id: access.id } }],
185
- }));
186
- }
187
- const marker = JSON.stringify({ hostname, database: databaseId });
188
- if (context.recordedIds()[workerAppDbRouteMarkerScopeKey(entry.key)] !== marker) {
189
- context.recordScopeId(keys.route, JSON.stringify({ hostname, service: `tcp://${owner.host}:${REMOTE_DATABASE_PORT}` }));
190
- await call('write the tunnel route', () => deps.tunnels.putTunnelConfig(tunnelId, {
191
- ingress: mergeTcpIngress(recordedHttpIngress(context.recordedIds()), recordedTcpRoutes(context.recordedIds())),
192
- }));
193
- if ((await call('look for the DNS record', () => deps.dns.findDnsRecord(zone.name, hostname, 'CNAME'))).length === 0) {
194
- await call('create the DNS record', () => deps.dns.createDnsRecord(zone.name, {
195
- type: 'CNAME',
196
- name: hostname,
197
- content: `${tunnelId}.cfargotunnel.com`,
198
- proxied: true,
199
- }));
200
- }
201
- context.recordScopeId(workerAppDbRouteMarkerScopeKey(entry.key), marker);
202
- await context.persistScopeIds();
203
- }
88
+ await ensureDatabaseRoute(deps, call, context, {
89
+ entryKey: entry.key,
90
+ accessName: `${scriptName}-db-access`,
91
+ hostname,
92
+ upstreamHost: owner.host,
93
+ markerDatabase: databaseId,
94
+ tunnelId,
95
+ zoneApex,
96
+ tokenId: access.id,
97
+ at,
98
+ });
204
99
  // 3. The runtime password: minted once and held, so a rerun hands out the same one.
205
100
  const runtimeUser = `${owner.database}_rt`;
206
101
  const rtRef = workerAppSecretRef(org.id, entry.key, 'DATABASE_RT_PASSWORD');
@@ -233,39 +128,30 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
233
128
  accessClientSecret: access.clientSecret,
234
129
  ...(tls ? { tls } : {}),
235
130
  };
236
- // 4. Migrations with the owner credentials. A failure stops the deploy; ids already applied stay.
131
+ // 4. Migrations with the owner credentials, then 5. the runtime role. A failure stops the
132
+ // deploy; ids already applied stay.
237
133
  const text = await call('fetch the release migrations', () => port.fetchReleaseAsset(migrationsUrl(want.artifactUrl)));
238
- let migrations;
239
- try {
240
- verifyMigrationsDigest(text, want.database.migrationsSha256);
241
- migrations = parseMigrations(text);
242
- }
243
- catch (error) {
244
- // The message names the file and the entry index, never SQL.
245
- throw new Error(`${at}: ${error.message}`);
246
- }
247
- const applied = await call('read the applied migrations', () => port.appliedMigrationIds(ownerOrigin));
248
- let pending;
249
- try {
250
- pending = unappliedDatabaseMigrations(migrations, applied);
251
- }
252
- catch (error) {
253
- throw new Error(`${at}: ${error.message}`);
134
+ await runWorkerAppMigrations(port, ownerOrigin, text, want.database, { user: runtimeUser, password: runtimePassword }, {
135
+ at,
136
+ call,
137
+ ...(deps.migrationTimeoutMs !== undefined
138
+ ? { migrationTimeoutMs: deps.migrationTimeoutMs }
139
+ : {}),
140
+ // `uses.audit`: the pinned audit migrations, so an app need not ship them. One it ships itself is skipped.
141
+ ...(entry.uses?.audit === true ? { platform: auditMigrations() } : {}),
142
+ ...(deps.log ? { log: deps.log } : {}),
143
+ onApplied: async (done) => {
144
+ context.recordScopeId(workerAppMigrationsScopeKey(entry.key), JSON.stringify(done));
145
+ await context.persistScopeIds();
146
+ },
147
+ });
148
+ // 5b. `uses.audit`: the sealing role, apart from the runtime role, after the audit tables exist.
149
+ if (entry.uses?.audit === true && port.ensureSealerRole) {
150
+ await ensureAuditSealer(deps, org, entry, owner.database, ownerOrigin, at, call, context, put);
254
151
  }
255
- const done = [...applied];
256
- for (const migration of pending) {
257
- await call(`apply migration '${migration.id}'`, () => port.applyMigration(ownerOrigin, migration), deps.migrationTimeoutMs ?? WORKER_APP_MIGRATION_TIMEOUT_MS);
258
- done.push(migration.id);
259
- context.recordScopeId(workerAppMigrationsScopeKey(entry.key), JSON.stringify(done));
260
- await context.persistScopeIds();
152
+ else if (entry.uses?.audit === true) {
153
+ deps.log?.(`${at}: the sealing role was not made, the host's database port cannot make one (no ensureSealerRole); the audit log stays unregistered`);
261
154
  }
262
- // 5. The runtime role, after the migrations so the tables exist for the revokes.
263
- await call('ensure the runtime role', () => port.ensureRuntimeRole({
264
- owner: ownerOrigin,
265
- runtime: { user: runtimeUser, password: runtimePassword },
266
- appendOnly: [...want.database.appendOnly],
267
- grants: [...(want.database.grants ?? [])],
268
- }));
269
155
  // 6. Hyperdrive, with the runtime credentials only.
270
156
  const name = hyperdriveName(scriptName);
271
157
  const { id } = await call('upsert the Hyperdrive config', () => port.upsertHyperdrive({
@@ -284,12 +170,121 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
284
170
  await context.persistScopeIds();
285
171
  return { hyperdriveId: id };
286
172
  }
173
+ /**
174
+ * The Access app (service-token policy), the tunnel TCP rule and the proxied CNAME that put a
175
+ * database behind `hostname`. Idempotent; the ids are recorded and persisted as they appear.
176
+ * Shared by a Worker app's database (above) and the company's apps database (F4, #925).
177
+ */
178
+ export async function ensureDatabaseRoute(deps, call, context, input) {
179
+ const { hostname, tunnelId, zoneApex, entryKey } = input;
180
+ const keys = remoteDatabaseKeys(entryKey);
181
+ const zone = await call('find the zone', () => deps.zones.findZoneByApex(zoneApex));
182
+ if (!zone)
183
+ throw new Error(`${input.at}: zone ${zoneApex} is not live`);
184
+ let accessAppId = context.recordedIds()[keys.access];
185
+ if (!accessAppId) {
186
+ const live = (await call('list Access apps', () => deps.access.listAccessApps())).find((a) => a.domain === hostname);
187
+ accessAppId = (live ??
188
+ (await call('create the Access app', () => deps.access.createAccessApp({
189
+ name: input.accessName,
190
+ domain: hostname,
191
+ type: 'self_hosted',
192
+ })))).id;
193
+ context.recordScopeId(keys.access, accessAppId);
194
+ await context.persistScopeIds();
195
+ }
196
+ const appId = accessAppId;
197
+ const policies = await call('list Access policies', () => deps.access.listAccessPolicies(appId));
198
+ const hasPolicy = policies.some((p) => p.decision === 'non_identity' &&
199
+ (p.include ?? []).some((r) => r.service_token?.token_id ===
200
+ input.tokenId));
201
+ if (!hasPolicy) {
202
+ await call('create the Access policy', () => deps.access.createAccessPolicy(appId, {
203
+ name: 'hyperdrive',
204
+ decision: 'non_identity',
205
+ include: [{ service_token: { token_id: input.tokenId } }],
206
+ }));
207
+ }
208
+ const marker = JSON.stringify({ hostname, database: input.markerDatabase });
209
+ if (context.recordedIds()[workerAppDbRouteMarkerScopeKey(entryKey)] !== marker) {
210
+ context.recordScopeId(keys.route, JSON.stringify({ hostname, service: `tcp://${input.upstreamHost}:${REMOTE_DATABASE_PORT}` }));
211
+ await call('write the tunnel route', () => deps.tunnels.putTunnelConfig(tunnelId, {
212
+ ingress: mergeTcpIngress(recordedHttpIngress(context.recordedIds()), recordedTcpRoutes(context.recordedIds())),
213
+ }));
214
+ const existingRecords = await call('look for the DNS record', () => deps.dns.findDnsRecord(zone.name, hostname, 'CNAME'));
215
+ const mine = `${tunnelId}.cfargotunnel.com`;
216
+ if (existingRecords.some((r) => r.content !== mine)) {
217
+ throw new Error(`${input.at}: a DNS record named ${hostname} already exists and does not point at this company's tunnel; foundry will not reuse or change it. Delete that record in Cloudflare if it is not needed, then apply again`);
218
+ }
219
+ if (existingRecords.length === 0) {
220
+ await call('create the DNS record', () => deps.dns.createDnsRecord(zone.name, {
221
+ type: 'CNAME',
222
+ name: hostname,
223
+ content: `${tunnelId}.cfargotunnel.com`,
224
+ proxied: true,
225
+ }));
226
+ }
227
+ context.recordScopeId(workerAppDbRouteMarkerScopeKey(entryKey), marker);
228
+ await context.persistScopeIds();
229
+ }
230
+ }
231
+ /**
232
+ * `uses.audit` (foundry#929): the role the platform seals this entry's ledger as. Its password is minted once
233
+ * (rule 1): the role name is recorded as undelivered and persisted before the password is held, and a password
234
+ * minted and never held is never minted again. The password reaches only the held keys and the port; the
235
+ * recorded delivered key holds the role name alone.
236
+ */
237
+ async function ensureAuditSealer(deps, org, entry, database, ownerOrigin, at, call, context, put) {
238
+ const ensure = deps.port.ensureSealerRole;
239
+ const heldKeys = deps.heldKeys;
240
+ if (!ensure)
241
+ return;
242
+ const user = sealerRoleName(database);
243
+ const ref = auditSealerRef(org.id, entry.key);
244
+ const undeliveredKey = auditSealerUndeliveredScopeKey(entry.key);
245
+ let password = (await call('read the held sealing password', () => heldKeys.resolve(ref))) ?? '';
246
+ if (!password) {
247
+ const lost = context.recordedIds()[undeliveredKey];
248
+ if (lost) {
249
+ throw new Error(`${at}: the sealing password for role ${lost} was minted and never reached the held-key store; clear the recorded ${undeliveredKey} to mint it again`);
250
+ }
251
+ password = randomBytes(24).toString('base64url');
252
+ deps.secret(password);
253
+ context.recordScopeId(undeliveredKey, user);
254
+ await context.persistScopeIds();
255
+ const minted = password;
256
+ let stored = false;
257
+ for (let attempt = 1; attempt <= HOLD_DELIVERY_ATTEMPTS && !stored; attempt++) {
258
+ try {
259
+ await call('hold the sealing password', () => put(ref, minted));
260
+ stored = true;
261
+ }
262
+ catch {
263
+ // Retried; the cause may echo the secret and is dropped.
264
+ }
265
+ }
266
+ if (!stored) {
267
+ throw new Error(`${at}: the sealing password for role ${user} could not be held after ${HOLD_DELIVERY_ATTEMPTS} attempts; clear the recorded ${undeliveredKey} to mint it again`);
268
+ }
269
+ context.recordScopeId(undeliveredKey, '');
270
+ await context.persistScopeIds();
271
+ }
272
+ deps.secret(password);
273
+ const sealer = { user, password };
274
+ await call('make the sealing role', () => ensure.call(deps.port, {
275
+ owner: ownerOrigin,
276
+ sealer,
277
+ statements: sealerRoleSql(user, password).statements,
278
+ }));
279
+ context.recordScopeId(auditSealerScopeKey(entry.key), user);
280
+ await context.persistScopeIds();
281
+ }
287
282
  /**
288
283
  * The Access service token Hyperdrive and the migration runner present. Minted once: its id is
289
284
  * recorded under an undelivered key and persisted before the secret is held, the delivered id
290
285
  * replaces it after the hold. A token minted and never held is never minted again.
291
286
  */
292
- async function ensureServiceToken(deps, org, entry, scriptName, at, call, context, put) {
287
+ export async function ensureServiceToken(deps, org, entry, scriptName, at, call, context, put) {
293
288
  const keys = remoteDatabaseKeys(entry.key);
294
289
  const ref = workerAppSecretRef(org.id, entry.key, 'HYPERDRIVE_ACCESS');
295
290
  const heldKeys = deps.heldKeys;
@@ -405,8 +400,10 @@ export async function deleteWorkerAppDatabaseRoute(database, heldKeys, orgId, en
405
400
  const zone = await call('find the zone', () => database.zones.findZoneByApex(hostname.slice(hostname.indexOf('.') + 1)));
406
401
  if (zone) {
407
402
  const records = await call('look for the DNS record', () => database.dns.findDnsRecord(zone.name, hostname, 'CNAME'));
403
+ const ownTunnel = recorded[`cloudflare:tunnel:${orgId}`];
408
404
  for (const record of records) {
409
- if (!record.content.endsWith('.cfargotunnel.com'))
405
+ // Only this company's own tunnel's record; anything else at that name is not ours to delete.
406
+ if (!ownTunnel || record.content !== `${ownTunnel}.cfargotunnel.com`)
410
407
  continue;
411
408
  await call('delete the DNS record', () => tolerateGone(() => database.dns.deleteDnsRecord(zone.name, record.id)));
412
409
  }