@wtfalch/foundry 0.59.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 +47 -40
  181. package/dist/plan/worker-app-database.js +150 -152
  182. package/dist/plan/worker-app.d.ts +6 -65
  183. package/dist/plan/worker-app.js +29 -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 +6 -10
  213. package/dist/worker-app-config.js +22 -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,27 +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 and revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table.
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.
75
67
  */
76
- ensureRuntimeRole(input: {
68
+ ensureSealerRole?(input: {
77
69
  owner: HyperdriveOrigin;
78
- runtime: {
70
+ sealer: {
79
71
  user: string;
80
72
  password: string;
81
73
  };
82
- appendOnly: string[];
74
+ statements: string[];
83
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>;
84
78
  }
85
- export declare const WORKER_APP_MIGRATION_TIMEOUT_MS = 300000;
86
- export declare const MIGRATIONS_ASSET_NAME = "migrations.json";
87
79
  /** The Hyperdrive config, `{name, id}` as JSON. No secret. */
88
80
  export declare const workerAppHyperdriveScopeKey: (entryKey: string) => string;
89
81
  /** The migration ids applied, a JSON list, for the plan and the operator. The database's ledger is the fact. */
@@ -94,24 +86,6 @@ export declare const workerAppDbRouteMarkerScopeKey: (entryKey: string) => strin
94
86
  export declare const hyperdriveName: (scriptName: string) => string;
95
87
  /** The tunnel hostname of an entry's database route: `<8 hex of org id>-<key>-hd.<zone>`. */
96
88
  export declare const hyperdriveHostname: (org: DeclaredOrg, entryKey: string, zone: string) => string;
97
- /**
98
- * Reads a release's `migrations.json`. `null` text is an absent file: no migrations. A file that is
99
- * not a list of `{id, sql}` with unique ids is refused, naming no SQL.
100
- */
101
- export declare function parseMigrations(text: string | null): DatabaseMigration[];
102
- /**
103
- * A present `migrations.json` runs as the database owner, so its bytes must be the ones the
104
- * declaration pinned. No file needs no digest; a file with no declared digest, or a different
105
- * one, is refused before it is parsed. The message names neither the SQL nor the wanted digest.
106
- */
107
- export declare function verifyMigrationsDigest(text: string | null, declared: string | undefined): void;
108
- /** The URL of `migrations.json` beside the module: the same release, another file. */
109
- export declare const migrationsUrl: (artifactUrl: string) => string;
110
- /**
111
- * The migrations the database does not hold, in order. The applied ledger must be the first
112
- * migrations of the file: a missing or reordered one is refused (migrations are append-only).
113
- */
114
- export declare function unappliedDatabaseMigrations(all: readonly DatabaseMigration[], applied: readonly string[]): DatabaseMigration[];
115
89
  export interface WorkerAppDatabaseDeps {
116
90
  port: WorkerAppDatabasePort;
117
91
  tunnels: Pick<TunnelPort, 'putTunnelConfig'>;
@@ -133,12 +107,19 @@ export interface WorkerAppDatabaseDeps {
133
107
  resolve(ref: string): Promise<string | null | undefined>;
134
108
  put?(ref: string, value: string): Promise<void>;
135
109
  };
110
+ /** One plain line for the operator's log (never a secret). */
111
+ log?: (line: string) => void;
136
112
  /** Test hook: the timeout of a call, in ms. */
137
113
  timeoutMs?: number;
138
114
  /** Test hook: the timeout of one migration, in ms. */
139
115
  migrationTimeoutMs?: number;
140
116
  }
141
- 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
+ }
142
123
  /**
143
124
  * Makes the database true and returns the Hyperdrive config id for the `HYPERDRIVE` binding.
144
125
  * Secrets (the owner and runtime passwords, the Access service token) go only to the port and the
@@ -149,11 +130,38 @@ export declare function applyWorkerAppDatabase(deps: WorkerAppDatabaseDeps, org:
149
130
  artifactUrl: string;
150
131
  database: {
151
132
  appendOnly: string[];
133
+ grants?: string[];
152
134
  migrationsSha256?: string;
153
135
  };
154
136
  }, context: StepExecutionContext, zoneApex: string): Promise<{
155
137
  hyperdriveId: string;
156
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>;
157
165
  /** Deletes the entry's Hyperdrive config, found by the recorded name, and clears the record. */
158
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>;
159
167
  /**
@@ -164,5 +172,4 @@ export declare function deleteWorkerAppHyperdrive(port: Pick<WorkerAppDatabasePo
164
172
  * ids are cleared last, so a failed run is repeated by the next one. With `context` null (org
165
173
  * destroy) the ids are not cleared: destroy drops them with the org.
166
174
  */
167
- 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>;
168
- 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,38 +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
- }));
268
155
  // 6. Hyperdrive, with the runtime credentials only.
269
156
  const name = hyperdriveName(scriptName);
270
157
  const { id } = await call('upsert the Hyperdrive config', () => port.upsertHyperdrive({
@@ -283,12 +170,121 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
283
170
  await context.persistScopeIds();
284
171
  return { hyperdriveId: id };
285
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
+ }
286
282
  /**
287
283
  * The Access service token Hyperdrive and the migration runner present. Minted once: its id is
288
284
  * recorded under an undelivered key and persisted before the secret is held, the delivered id
289
285
  * replaces it after the hold. A token minted and never held is never minted again.
290
286
  */
291
- async function ensureServiceToken(deps, org, entry, scriptName, at, call, context, put) {
287
+ export async function ensureServiceToken(deps, org, entry, scriptName, at, call, context, put) {
292
288
  const keys = remoteDatabaseKeys(entry.key);
293
289
  const ref = workerAppSecretRef(org.id, entry.key, 'HYPERDRIVE_ACCESS');
294
290
  const heldKeys = deps.heldKeys;
@@ -404,8 +400,10 @@ export async function deleteWorkerAppDatabaseRoute(database, heldKeys, orgId, en
404
400
  const zone = await call('find the zone', () => database.zones.findZoneByApex(hostname.slice(hostname.indexOf('.') + 1)));
405
401
  if (zone) {
406
402
  const records = await call('look for the DNS record', () => database.dns.findDnsRecord(zone.name, hostname, 'CNAME'));
403
+ const ownTunnel = recorded[`cloudflare:tunnel:${orgId}`];
407
404
  for (const record of records) {
408
- 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`)
409
407
  continue;
410
408
  await call('delete the DNS record', () => tolerateGone(() => database.dns.deleteDnsRecord(zone.name, record.id)));
411
409
  }