@wtfalch/foundry 0.17.1 → 0.19.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 (44) hide show
  1. package/dist/bootstrap/declare.d.ts +11 -2
  2. package/dist/bootstrap/declare.js +20 -11
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0045_names_to_edge.sql +107 -0
  6. package/dist/org.d.ts +17 -17
  7. package/dist/org.js +48 -58
  8. package/dist/plan/agora.d.ts +25 -0
  9. package/dist/plan/agora.js +25 -0
  10. package/dist/plan/boule.d.ts +8 -0
  11. package/dist/plan/boule.js +8 -0
  12. package/dist/plan/diff.d.ts +3 -1
  13. package/dist/plan/diff.js +115 -52
  14. package/dist/plan/entry-edge-key.d.ts +79 -0
  15. package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
  16. package/dist/plan/executor.d.ts +87 -42
  17. package/dist/plan/executor.js +277 -120
  18. package/dist/plan/index.d.ts +2 -2
  19. package/dist/plan/index.js +1 -1
  20. package/dist/plan/live.d.ts +3 -3
  21. package/dist/plan/login-host.d.ts +15 -11
  22. package/dist/plan/login-host.js +16 -16
  23. package/dist/plan/plan-apply.d.ts +6 -5
  24. package/dist/plan/plan-apply.js +40 -38
  25. package/dist/plan/platform-diff.d.ts +22 -22
  26. package/dist/plan/platform-diff.js +45 -45
  27. package/dist/plan/platform-org-ports.d.ts +1 -0
  28. package/dist/plan/platform-org-ports.js +3 -0
  29. package/dist/plan/platform-org.d.ts +8 -8
  30. package/dist/plan/platform-org.js +7 -7
  31. package/dist/plan/platform-providers.d.ts +17 -16
  32. package/dist/plan/platform-providers.js +136 -118
  33. package/dist/plan/providers-readers.d.ts +34 -11
  34. package/dist/plan/providers-readers.js +72 -15
  35. package/dist/plan/step-system.js +1 -1
  36. package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
  37. package/dist/plan/{edge-worker.js → worker.js} +92 -54
  38. package/dist/platform.d.ts +3 -3
  39. package/dist/platform.js +1 -1
  40. package/dist/store/postgres.js +7 -7
  41. package/dist/store/schema.d.ts +6 -6
  42. package/dist/store/schema.js +4 -4
  43. package/package.json +1 -1
  44. package/dist/plan/entry-names-key.d.ts +0 -73
@@ -7,7 +7,7 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
9
  import { coolifyDomains, filesShareHostname } from './hostnames.js';
10
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
10
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
11
11
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
12
12
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
13
13
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -94,8 +94,8 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
94
94
  * #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
95
95
  * host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
96
96
  *
97
- * #478 adds `coolify:app` keyed {@link PLATFORM_NAMES_KEY}, the names host
98
- * ({@link provisionPlatformNames}), once a host passes {@link PlatformNamesDeps}.
97
+ * #478 adds `coolify:app` keyed {@link PLATFORM_EDGE_KEY}, the edge host
98
+ * ({@link provisionPlatformEdge}), once a host passes {@link PlatformEdgeDeps}.
99
99
  *
100
100
  * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
101
101
  * ({@link provisionPlatformArchon}), once a host passes
@@ -116,7 +116,7 @@ export class FoundryPlatformStepExecutor {
116
116
  integrations;
117
117
  files;
118
118
  ai;
119
- names;
119
+ edge;
120
120
  archon;
121
121
  backup;
122
122
  constructor(cloudflare, declaredOrgStore, options = {}) {
@@ -130,7 +130,7 @@ export class FoundryPlatformStepExecutor {
130
130
  this.integrations = options.integrations;
131
131
  this.files = options.files;
132
132
  this.ai = options.ai;
133
- this.names = options.names;
133
+ this.edge = options.edge;
134
134
  this.archon = options.archon;
135
135
  this.backup = options.backup;
136
136
  }
@@ -217,14 +217,14 @@ export class FoundryPlatformStepExecutor {
217
217
  blocked.ai =
218
218
  "the platform's AI host is blocked, not planned: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)";
219
219
  }
220
- if (platform.names.enabled && !this.names) {
221
- blocked.names =
222
- 'the names host is blocked, not planned: the host built the platform executor without its names dependencies';
220
+ if (platform.edge.enabled && !this.edge) {
221
+ blocked.edge =
222
+ 'the edge host is blocked, not planned: the host built the platform executor without its edge dependencies';
223
223
  }
224
- else if (platform.names.enabled && this.names) {
225
- const namesBlocked = await namesBlockedReason(this.names);
226
- if (namesBlocked)
227
- blocked.names = namesBlocked;
224
+ else if (platform.edge.enabled && this.edge) {
225
+ const edgeBlocked = await edgeBlockedReason(this.edge);
226
+ if (edgeBlocked)
227
+ blocked.edge = edgeBlocked;
228
228
  }
229
229
  if (platform.archon.enabled) {
230
230
  if (!this.archon) {
@@ -375,11 +375,11 @@ export class FoundryPlatformStepExecutor {
375
375
  }
376
376
  return provisionPlatformAi(context, this.ai);
377
377
  }
378
- if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_NAMES_KEY) {
379
- if (!this.names) {
380
- throw new Error('platform:names needs FoundryPlatformStepExecutor to be constructed with its names dependencies (#478)');
378
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_EDGE_KEY) {
379
+ if (!this.edge) {
380
+ throw new Error('platform:edge needs FoundryPlatformStepExecutor to be constructed with its edge dependencies (#478)');
381
381
  }
382
- return provisionPlatformNames(context, this.names);
382
+ return provisionPlatformEdge(context, this.edge);
383
383
  }
384
384
  if (action.system === 'coolify' &&
385
385
  action.kind === 'app' &&
@@ -2330,11 +2330,14 @@ const PLATFORM_ARCHON_PROJECT = 'platform';
2330
2330
  const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
2331
2331
  const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
2332
2332
  /**
2333
- * The database Coolify creates for Archon. Archon's own
2334
- * `drizzle/0001_runtime_role.sql` names its runtime role
2335
- * `current_database() || '_rt'`, so {@link ARCHON_RUNTIME_ROLE} follows.
2333
+ * The database Archon runs on. Live platform-archon moved to `archon_v2` on
2334
+ * the same server (2026-10-02, the move onto `@wtfalch/db`), while its Coolify
2335
+ * resource still names `archon` as its first database. So both URLs built
2336
+ * here set this database themselves, never `internal_db_url`'s path.
2337
+ * Archon's own migrations grant to `current_database() || '_rt'`, so
2338
+ * {@link ARCHON_RUNTIME_ROLE} follows.
2336
2339
  */
2337
- const ARCHON_POSTGRES_DB = 'archon';
2340
+ const ARCHON_POSTGRES_DB = 'archon_v2';
2338
2341
  const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
2339
2342
  /** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
2340
2343
  const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
@@ -2668,11 +2671,12 @@ async function provisionPlatformArchon(context, deps) {
2668
2671
  // The owner URL must name the adopted database's host, or the app's two
2669
2672
  // database URLs would point at different databases.
2670
2673
  const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
2674
+ const ownerUrl = archonDatabaseUrl(db.internal_db_url);
2671
2675
  if (liveOwnerUrl) {
2672
2676
  assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
2673
2677
  }
2674
2678
  else {
2675
- envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
2679
+ envs.push({ key: 'DATABASE_URL_OWNER', value: ownerUrl });
2676
2680
  }
2677
2681
  const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
2678
2682
  if (!liveReportingSite) {
@@ -2692,7 +2696,7 @@ async function provisionPlatformArchon(context, deps) {
2692
2696
  // reflects the env Archon actually ends up running with, not just this
2693
2697
  // run's delta.
2694
2698
  const archonEnvHashOverrides = new Map([
2695
- ['DATABASE_URL_OWNER', liveOwnerUrl ?? db.internal_db_url],
2699
+ ['DATABASE_URL_OWNER', liveOwnerUrl ?? ownerUrl],
2696
2700
  ['REPORTING_SITE', liveReportingSite ?? 'archon'],
2697
2701
  ]);
2698
2702
  const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
@@ -3625,10 +3629,9 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
3625
3629
  return null;
3626
3630
  }
3627
3631
  let host;
3628
- let pathname;
3629
3632
  let search;
3630
3633
  try {
3631
- ({ host, pathname, search } = new URL(dbInternalUrl));
3634
+ ({ host, search } = new URL(dbInternalUrl));
3632
3635
  }
3633
3636
  catch {
3634
3637
  // Node's `ERR_INVALID_URL` carries the whole input, credential included, so
@@ -3636,7 +3639,19 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
3636
3639
  throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
3637
3640
  }
3638
3641
  const password = randomBytes(24).toString('base64url');
3639
- return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
3642
+ return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}/${ARCHON_POSTGRES_DB}${search}`;
3643
+ }
3644
+ /** `internalUrl` on {@link ARCHON_POSTGRES_DB}. The error never carries the URL: it holds a credential. */
3645
+ function archonDatabaseUrl(internalUrl) {
3646
+ let url;
3647
+ try {
3648
+ url = new URL(internalUrl);
3649
+ }
3650
+ catch {
3651
+ throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL_OWNER. Check the database in Coolify and apply again');
3652
+ }
3653
+ url.pathname = `/${ARCHON_POSTGRES_DB}`;
3654
+ return url.toString();
3640
3655
  }
3641
3656
  /**
3642
3657
  * Thrown by {@link createAiManagementKeyMinter} (never by a test's own
@@ -3720,8 +3735,9 @@ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
3720
3735
  * What Archon's management key may do in ai: exactly `ai`'s own
3721
3736
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
3722
3737
  * configuration only (offerings, provisioning, offers, budgets, usage, cost)
3723
- * plus deleting (archiving) an organisation, never connections, runs or
3724
- * webhooks. ai's `/v1/platform/keys` route refuses any permission outside
3738
+ * plus deleting (archiving) an organisation and setting another
3739
+ * organisation's webhook address and budget, never connections, runs or
3740
+ * webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
3725
3741
  * this same list, so a request for more than this is a 403, not a wider key.
3726
3742
  */
3727
3743
  export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
@@ -3732,6 +3748,8 @@ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
3732
3748
  'ai.organisations:provision',
3733
3749
  'ai.organisations:offer',
3734
3750
  'ai.organisations:delete',
3751
+ 'ai.organisations:webhook',
3752
+ 'ai.organisations:budget',
3735
3753
  'ai.keys:issue',
3736
3754
  'ai.budgets:read',
3737
3755
  'ai.budgets:update',
@@ -5105,74 +5123,74 @@ shareDomains) {
5105
5123
  await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5106
5124
  return { resourceId: runnerApp.appId };
5107
5125
  }
5108
- /** https://github.com/wtfalch/names -- root Dockerfile, one `runner` target (see {@link provisionPlatformNames}'s own doc comment for why there is no `migration` target any more). */
5109
- const NAMES_REPO = 'wtfalch/names';
5110
- const PLATFORM_NAMES_PROJECT = 'platform';
5111
- const PLATFORM_NAMES_DB_NAME = 'platform-names-db';
5112
- const NAMES_APP_NAME = 'names';
5126
+ /** https://github.com/wtfalch/edge -- root Dockerfile, one `runner` target (see {@link provisionPlatformEdge}'s own doc comment for why there is no `migration` target any more). */
5127
+ const EDGE_REPO = 'wtfalch/edge';
5128
+ const PLATFORM_EDGE_PROJECT = 'platform';
5129
+ const PLATFORM_EDGE_DB_NAME = 'platform-edge-db';
5130
+ const EDGE_APP_NAME = 'edge';
5113
5131
  /** The app's fixed `custom_internal_name`, the way `platform:files`' runner names itself. */
5114
- const NAMES_RUNNER_INTERNAL_NAME = 'platform-names';
5132
+ const EDGE_RUNNER_INTERNAL_NAME = 'platform-edge';
5115
5133
  /** The runner's port (its Dockerfile: `PORT=3000`, `EXPOSE 3000`). */
5116
- const NAMES_PORT = '3000';
5134
+ const EDGE_PORT = '3000';
5117
5135
  /** The Postgres database name, and the owner role's name per the host's own `.env.example`. */
5118
- const NAMES_DB = 'names';
5119
- const NAMES_OWNER_ROLE = 'names_owner';
5136
+ const EDGE_DB = 'names';
5137
+ const EDGE_OWNER_ROLE = 'names_owner';
5120
5138
  /** The restricted runtime role `migrate.mjs`'s own `ensureRuntimeRole` creates or syncs at every boot. */
5121
- const NAMES_RUNTIME_ROLE = 'names_rt';
5122
- const NAMES_TUNNEL_NAME = 'platform-names-tunnel';
5123
- /** #197: the one Coolify app that runs `cloudflared` for {@link NAMES_TUNNEL_NAME}. */
5124
- const NAMES_CLOUDFLARED_APP_NAME = 'platform-names-cloudflared';
5125
- const PLATFORM_NAMES_PROJECT_SCOPE_KEY = 'coolify_project_platform_names';
5126
- const PLATFORM_NAMES_DB_SCOPE_KEY = 'coolify_platform_names_database';
5127
- const PLATFORM_NAMES_APP_SCOPE_KEY = 'coolify_platform_names_app';
5128
- const PLATFORM_NAMES_TUNNEL_SCOPE_KEY = 'cloudflare_platform_names_tunnel';
5129
- const PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_names_connector_app';
5139
+ const EDGE_RUNTIME_ROLE = 'names_rt';
5140
+ const EDGE_TUNNEL_NAME = 'platform-edge-tunnel';
5141
+ /** #197: the one Coolify app that runs `cloudflared` for {@link EDGE_TUNNEL_NAME}. */
5142
+ const EDGE_CLOUDFLARED_APP_NAME = 'platform-edge-cloudflared';
5143
+ const PLATFORM_EDGE_PROJECT_SCOPE_KEY = 'coolify_project_platform_edge';
5144
+ const PLATFORM_EDGE_DB_SCOPE_KEY = 'coolify_platform_edge_database';
5145
+ const PLATFORM_EDGE_APP_SCOPE_KEY = 'coolify_platform_edge_app';
5146
+ const PLATFORM_EDGE_TUNNEL_SCOPE_KEY = 'cloudflare_platform_edge_tunnel';
5147
+ const PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_edge_connector_app';
5130
5148
  /**
5131
5149
  * Written only when absent, never overwritten -- the `PLATFORM_FILES_RUNNER_DEFAULTS`
5132
5150
  * convention, so an operator's own cap or rate survives every apply. Values
5133
5151
  * are the host's own `.env.example` defaults.
5134
5152
  */
5135
- const PLATFORM_NAMES_RUNNER_DEFAULTS = [
5153
+ const PLATFORM_EDGE_RUNNER_DEFAULTS = [
5136
5154
  { key: 'MAX_CLAIMS_PER_ORGANISATION', value: '100' },
5137
- { key: 'NAMES_RATE_BURST', value: '10' },
5138
- { key: 'NAMES_RATE_PER_SECOND', value: '0.5' },
5155
+ { key: 'EDGE_RATE_BURST', value: '10' },
5156
+ { key: 'EDGE_RATE_PER_SECOND', value: '0.5' },
5139
5157
  ];
5140
5158
  /**
5141
- * Why `coolify:app` keyed {@link PLATFORM_NAMES_KEY} cannot be planned yet,
5142
- * or `null` when it can -- the host built no names dependencies (checked by
5159
+ * Why `coolify:app` keyed {@link PLATFORM_EDGE_KEY} cannot be planned yet,
5160
+ * or `null` when it can -- the host built no edge dependencies (checked by
5143
5161
  * the caller, `FoundryPlatformStepExecutor.blockedSteps`, before this is
5144
5162
  * even called), no Cloudflare API token held under
5145
- * {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} (CLAUDE.md's "Human steps"),
5163
+ * {@link PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} (CLAUDE.md's "Human steps"),
5146
5164
  * or no Cloudflare account id supplied beside it.
5147
5165
  */
5148
- async function namesBlockedReason(names) {
5166
+ async function edgeBlockedReason(names) {
5149
5167
  let token;
5150
5168
  try {
5151
- token = (await names.heldKeys?.resolve(PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF)) ?? null;
5169
+ token = (await names.heldKeys?.resolve(PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF)) ?? null;
5152
5170
  }
5153
5171
  catch {
5154
5172
  token = null;
5155
5173
  }
5156
5174
  if (!token) {
5157
- return `the names host is blocked, not planned: no Cloudflare API token is held under ${PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} yet (the names service's own token, tunnel edit + DNS edit, never the platform's) -- a human step`;
5175
+ return `the edge host is blocked, not planned: no Cloudflare API token is held under ${PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} yet (the edge service's own token, tunnel edit + DNS edit, never the platform's) -- a human step`;
5158
5176
  }
5159
5177
  if (!names.cloudflareAccountId) {
5160
- return 'the names host is blocked, not planned: the host supplies no Cloudflare account id (PlatformNamesDeps.cloudflareAccountId) for the token above';
5178
+ return 'the edge host is blocked, not planned: the host supplies no Cloudflare account id (PlatformEdgeDeps.cloudflareAccountId) for the token above';
5161
5179
  }
5162
5180
  return null;
5163
5181
  }
5164
5182
  /**
5165
- * #478: makes `coolify:app` keyed {@link PLATFORM_NAMES_KEY} true -- the
5166
- * names host (https://github.com/wtfalch/names), its own database, and
5167
- * foundry's own platform key, reached at `platform.names.hostname` through
5183
+ * #478: makes `coolify:app` keyed {@link PLATFORM_EDGE_KEY} true -- the
5184
+ * edge host (https://github.com/wtfalch/edge), its own database, and
5185
+ * foundry's own platform key, reached at `platform.edge.hostname` through
5168
5186
  * a Cloudflare tunnel and DNS record (the public-service shape
5169
5187
  * {@link provisionPlatformMail}/{@link provisionPlatformAuth} use, not
5170
- * `platform:files`' internal-only one -- `NAMES_URL` has to be reachable by
5188
+ * `platform:files`' internal-only one -- `EDGE_URL` has to be reachable by
5171
5189
  * every org's own app, which runs outside this Coolify network, and by
5172
5190
  * Archon).
5173
5191
  *
5174
5192
  * One Coolify app only, unlike `platform:files`/`platform:ai`'s separate
5175
- * `migrate` app: names' own Dockerfile dropped its `migration` target
5193
+ * `migrate` app: edge' own Dockerfile dropped its `migration` target
5176
5194
  * (its own comment: no generated package's Coolify app ever built or ran
5177
5195
  * that stage, which once shipped `wtfalch/cms` with an empty database and
5178
5196
  * no runtime role). `apps/host/scripts/start.sh` now runs `migrate.mjs`
@@ -5188,27 +5206,27 @@ async function namesBlockedReason(names) {
5188
5206
  * restricted `names_rt` role is never created here -- `migrate.mjs`'s own
5189
5207
  * `ensureRuntimeRole` call creates or syncs it and sets its password from
5190
5208
  * `DATABASE_URL` at every boot, so this function only has to mint that
5191
- * password once and reuse it (`ensureNamesRuntimeUrl`, the
5209
+ * password once and reuse it (`ensureEdgeRuntimeUrl`, the
5192
5210
  * `ensureFilesRuntimeUrl` shape) -- never rotated, since the role's
5193
5211
  * password is set from it on every migration.
5194
5212
  *
5195
5213
  * The platform key follows `platform:files`' management-key discipline
5196
5214
  * (CLAUDE.md rule 1): a random secret is minted once, its id recorded
5197
- * undelivered and persisted before {@link PlatformNamesDeps.deliverSecret}
5215
+ * undelivered and persisted before {@link PlatformEdgeDeps.deliverSecret}
5198
5216
  * is even tried, and the guard recorded only once delivery AND the env
5199
- * write (the SHA-256 hash, as `NAMES_PLATFORM_KEY_HASH`) both succeeded. The
5217
+ * write (the SHA-256 hash, as `EDGE_PLATFORM_KEY_HASH`) both succeeded. The
5200
5218
  * secret itself is never written to the host's own env, logged, returned
5201
5219
  * or put in an error -- only its hash ever reaches names, and the full
5202
- * value goes to {@link PlatformNamesDeps.deliverSecret} under
5203
- * {@link PLATFORM_NAMES_KEY_REF} alone, for Archon's own `NamesPort` to read
5204
- * back later when it mints or revokes an entry's own names key.
5220
+ * value goes to {@link PlatformEdgeDeps.deliverSecret} under
5221
+ * {@link PLATFORM_EDGE_KEY_REF} alone, for Archon's own `EdgePort` to read
5222
+ * back later when it mints or revokes an entry's own edge key.
5205
5223
  *
5206
5224
  * `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` on the runner are never
5207
- * minted here either: they are the names service's own credential (never
5208
- * Archon's), read back from {@link PlatformNamesDeps.heldKeys} under
5209
- * {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} once a human has put one there
5225
+ * minted here either: they are the edge service's own credential (never
5226
+ * Archon's), read back from {@link PlatformEdgeDeps.heldKeys} under
5227
+ * {@link PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} once a human has put one there
5210
5228
  * (the issue's own "Human steps") -- `blockedSteps`
5211
- * ({@link namesBlockedReason}) reports the step blocked, not planned, until
5229
+ * ({@link edgeBlockedReason}) reports the step blocked, not planned, until
5212
5230
  * then.
5213
5231
  *
5214
5232
  * Every part is safe to run again after a throw partway, `platform:files`'
@@ -5220,49 +5238,49 @@ async function namesBlockedReason(names) {
5220
5238
  * app redeploys, with no new secret minted.
5221
5239
  *
5222
5240
  * Known gaps, left for review:
5223
- * - `NAMES_MAX_QUEUE_DEPTH` (the host's `.env.example`) is not set; the host
5241
+ * - `EDGE_MAX_QUEUE_DEPTH` (the host's `.env.example`) is not set; the host
5224
5242
  * falls back to its own default.
5225
- * - `NAMES_TRUST_CF_CONNECTING_IP` is always written `true` rather than
5243
+ * - `EDGE_TRUST_CF_CONNECTING_IP` is always written `true` rather than
5226
5244
  * left to the host's own default, since this step always puts the
5227
5245
  * runner behind the Cloudflare tunnel it also creates.
5228
5246
  */
5229
- async function provisionPlatformNames(context, deps) {
5247
+ async function provisionPlatformEdge(context, deps) {
5230
5248
  const { platform } = context;
5231
- const hostname = platform.names.hostname;
5232
- const gitRef = platform.names.gitRef;
5249
+ const hostname = platform.edge.hostname;
5250
+ const gitRef = platform.edge.gitRef;
5233
5251
  const recorded = context.recordedIds();
5234
5252
  const runtime = deps.runtime ?? {};
5235
5253
  const sleep = runtime.sleep ?? defaultSleep;
5236
5254
  const attempts = runtime.deliverAttempts ?? 3;
5237
- const cloudflareToken = await deps.heldKeys?.resolve(PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF);
5255
+ const cloudflareToken = await deps.heldKeys?.resolve(PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF);
5238
5256
  if (!cloudflareToken || !deps.cloudflareAccountId) {
5239
- throw new Error(`platform:names needs the names service's own Cloudflare API token (held under ${PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF}) and account id (PlatformNamesDeps.cloudflareAccountId); names never becomes healthy without them`);
5257
+ throw new Error(`platform:edge needs the edge service's own Cloudflare API token (held under ${PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF}) and account id (PlatformEdgeDeps.cloudflareAccountId); names never becomes healthy without them`);
5240
5258
  }
5241
5259
  // Never plan-and-throw (CLAUDE.md rule 2): the planner reports a lost
5242
5260
  // secret and plans nothing; this is the same rule for a caller that
5243
5261
  // reaches the step anyway.
5244
- const lostKey = recorded[PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
5245
- if (lostKey && !recorded[PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY]) {
5246
- throw new Error(`platform:names: the platform key (${lostKey}) was minted but never delivered; remove the recorded key ${PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
5262
+ const lostKey = recorded[PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
5263
+ if (lostKey && !recorded[PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY]) {
5264
+ throw new Error(`platform:edge: the platform key (${lostKey}) was minted but never delivered; remove the recorded key ${PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
5247
5265
  }
5248
5266
  const record = async (key, id) => {
5249
5267
  context.recordScopeId(key, id);
5250
5268
  await context.persist?.();
5251
5269
  };
5252
- let projectUuid = recorded[PLATFORM_NAMES_PROJECT_SCOPE_KEY];
5270
+ let projectUuid = recorded[PLATFORM_EDGE_PROJECT_SCOPE_KEY];
5253
5271
  if (!projectUuid) {
5254
- const found = await deps.coolify.findProjectByName(PLATFORM_NAMES_PROJECT);
5272
+ const found = await deps.coolify.findProjectByName(PLATFORM_EDGE_PROJECT);
5255
5273
  projectUuid = found
5256
5274
  ? found.uuid
5257
- : (await deps.coolify.createProject(PLATFORM_NAMES_PROJECT)).uuid;
5258
- await record(PLATFORM_NAMES_PROJECT_SCOPE_KEY, projectUuid);
5275
+ : (await deps.coolify.createProject(PLATFORM_EDGE_PROJECT)).uuid;
5276
+ await record(PLATFORM_EDGE_PROJECT_SCOPE_KEY, projectUuid);
5259
5277
  }
5260
5278
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
5261
5279
  const server = await resolvePlatformServer(deps.coolify);
5262
5280
  // ---- database ----------------------------------------------------------
5263
- let dbId = recorded[PLATFORM_NAMES_DB_SCOPE_KEY];
5281
+ let dbId = recorded[PLATFORM_EDGE_DB_SCOPE_KEY];
5264
5282
  if (!dbId) {
5265
- const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_NAMES_DB_NAME && db.environment_id === environment.id);
5283
+ const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_EDGE_DB_NAME && db.environment_id === environment.id);
5266
5284
  if (found) {
5267
5285
  dbId = found.uuid;
5268
5286
  }
@@ -5272,20 +5290,20 @@ async function provisionPlatformNames(context, deps) {
5272
5290
  server_uuid: server.uuid,
5273
5291
  environment_name: environment.name,
5274
5292
  environment_uuid: environment.uuid,
5275
- name: PLATFORM_NAMES_DB_NAME,
5276
- postgres_db: NAMES_DB,
5277
- postgres_user: NAMES_OWNER_ROLE,
5293
+ name: PLATFORM_EDGE_DB_NAME,
5294
+ postgres_db: EDGE_DB,
5295
+ postgres_user: EDGE_OWNER_ROLE,
5278
5296
  });
5279
5297
  dbId = created.uuid;
5280
5298
  }
5281
- await record(PLATFORM_NAMES_DB_SCOPE_KEY, dbId);
5299
+ await record(PLATFORM_EDGE_DB_SCOPE_KEY, dbId);
5282
5300
  }
5283
5301
  const db = await getDatabaseStarted(deps.coolify, dbId);
5284
5302
  if (!db.internal_db_url) {
5285
- throw new Error('coolify:app for the platform (names): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
5303
+ throw new Error('coolify:app for the platform (edge): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
5286
5304
  }
5287
5305
  // ---- the app -------------------------------------------------------------
5288
- const app = await findOrCreatePlatformApp(deps.coolify, NAMES_APP_NAME, environment, async () => {
5306
+ const app = await findOrCreatePlatformApp(deps.coolify, EDGE_APP_NAME, environment, async () => {
5289
5307
  const githubApp = await deps.coolify.requireGithubApp();
5290
5308
  return deps.coolify.createGithubAppApplication({
5291
5309
  project_uuid: projectUuid,
@@ -5293,41 +5311,41 @@ async function provisionPlatformNames(context, deps) {
5293
5311
  environment_name: environment.name,
5294
5312
  environment_uuid: environment.uuid,
5295
5313
  github_app_uuid: githubApp.uuid,
5296
- git_repository: NAMES_REPO,
5314
+ git_repository: EDGE_REPO,
5297
5315
  git_branch: gitRef,
5298
5316
  build_pack: 'dockerfile',
5299
- name: NAMES_APP_NAME,
5317
+ name: EDGE_APP_NAME,
5300
5318
  });
5301
5319
  });
5302
5320
  const appId = app.appId;
5303
- await record(PLATFORM_NAMES_APP_SCOPE_KEY, appId);
5321
+ await record(PLATFORM_EDGE_APP_SCOPE_KEY, appId);
5304
5322
  // Set on every run, not only after a create: a throw right after the
5305
5323
  // create must not leave the app on the wrong target for good, and a
5306
5324
  // changed `gitRef` has to reach an app made on an earlier one.
5307
5325
  await deps.coolify.updateApplication(appId, {
5308
5326
  dockerfile_target_build: 'runner',
5309
- custom_internal_name: NAMES_RUNNER_INTERNAL_NAME,
5327
+ custom_internal_name: EDGE_RUNNER_INTERNAL_NAME,
5310
5328
  git_branch: gitRef,
5311
5329
  });
5312
- const runtimeUrl = await ensureNamesRuntimeUrl(deps.coolify, appId, db.internal_db_url);
5330
+ const runtimeUrl = await ensureEdgeRuntimeUrl(deps.coolify, appId, db.internal_db_url);
5313
5331
  // ---- the platform key, once ----------------------------------------------
5314
5332
  // The recorded `set` id *is* the hash (never read back from Coolify's own
5315
5333
  // env, which can be missing or stale on a re-apply): the hash is never
5316
5334
  // secret, so it doubles as both the undelivered id and the value the base
5317
5335
  // env below writes, with nothing extra to compute or a live read that
5318
5336
  // could fall back to a blank value.
5319
- let keyHash = recorded[PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY] ?? '';
5337
+ let keyHash = recorded[PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY] ?? '';
5320
5338
  if (!keyHash) {
5321
5339
  const secret = randomBytes(32).toString('base64url');
5322
5340
  keyHash = createHash('sha256').update(secret).digest('hex');
5323
- await record(PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
5324
- await deliverWithRetry(deps.deliverSecret, PLATFORM_NAMES_KEY_REF, secret, attempts, sleep, 'the names platform key', 'platform:names');
5341
+ await record(PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
5342
+ await deliverWithRetry(deps.deliverSecret, PLATFORM_EDGE_KEY_REF, secret, attempts, sleep, 'the edge platform key', 'platform:edge');
5325
5343
  // Recorded as soon as delivery succeeds, before the env write below (the
5326
5344
  // base env block, further down, is what actually writes the hash) --
5327
5345
  // so a failed or crashed env write is simply retried on the next apply
5328
5346
  // instead of leaving an undelivered secret with no `set` partner and no
5329
5347
  // way to recover but a hand edit of the recorded ids.
5330
- await record(PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY, keyHash);
5348
+ await record(PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, keyHash);
5331
5349
  }
5332
5350
  // ---- base env, written on every run --------------------------------------
5333
5351
  await deps.coolify.bulkUpsertEnvs(appId, [
@@ -5336,18 +5354,18 @@ async function provisionPlatformNames(context, deps) {
5336
5354
  { key: 'APP_URL', value: `https://${hostname}` },
5337
5355
  { key: 'CLOUDFLARE_API_TOKEN', value: cloudflareToken },
5338
5356
  { key: 'CLOUDFLARE_ACCOUNT_ID', value: deps.cloudflareAccountId },
5339
- { key: 'NAMES_TRUST_CF_CONNECTING_IP', value: 'true' },
5357
+ { key: 'EDGE_TRUST_CF_CONNECTING_IP', value: 'true' },
5340
5358
  { key: 'NODE_ENV', value: 'production' },
5341
- { key: 'NAMES_PLATFORM_KEY_HASH', value: keyHash },
5342
- ...(await missingEnvs(deps.coolify, appId, PLATFORM_NAMES_RUNNER_DEFAULTS)),
5359
+ { key: 'EDGE_PLATFORM_KEY_HASH', value: keyHash },
5360
+ ...(await missingEnvs(deps.coolify, appId, PLATFORM_EDGE_RUNNER_DEFAULTS)),
5343
5361
  ]);
5344
5362
  // ---- tunnel and DNS -------------------------------------------------------
5345
- const existingTunnel = await deps.cloudflare.findTunnelByName(NAMES_TUNNEL_NAME);
5346
- const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(NAMES_TUNNEL_NAME));
5347
- await record(PLATFORM_NAMES_TUNNEL_SCOPE_KEY, tunnel.id);
5363
+ const existingTunnel = await deps.cloudflare.findTunnelByName(EDGE_TUNNEL_NAME);
5364
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(EDGE_TUNNEL_NAME));
5365
+ await record(PLATFORM_EDGE_TUNNEL_SCOPE_KEY, tunnel.id);
5348
5366
  await deps.cloudflare.putTunnelConfig(tunnel.id, {
5349
5367
  ingress: [
5350
- { hostname, service: `http://${NAMES_RUNNER_INTERNAL_NAME}:${NAMES_PORT}` },
5368
+ { hostname, service: `http://${EDGE_RUNNER_INTERNAL_NAME}:${EDGE_PORT}` },
5351
5369
  { service: 'http_status:404' },
5352
5370
  ],
5353
5371
  });
@@ -5356,16 +5374,16 @@ async function provisionPlatformNames(context, deps) {
5356
5374
  coolify: deps.coolify,
5357
5375
  cloudflare: deps.cloudflare,
5358
5376
  tunnelId: tunnel.id,
5359
- appName: NAMES_CLOUDFLARED_APP_NAME,
5377
+ appName: EDGE_CLOUDFLARED_APP_NAME,
5360
5378
  projectUuid,
5361
5379
  environment,
5362
5380
  resolveServer: async () => server,
5363
- recordedConnectorAppId: recorded[PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY] ?? null,
5364
- recordAppId: (connectorAppId) => record(PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
5381
+ recordedConnectorAppId: recorded[PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY] ?? null,
5382
+ recordAppId: (connectorAppId) => record(PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
5365
5383
  });
5366
5384
  const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
5367
5385
  if (!zoneId) {
5368
- throw new Error(`platform:names: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
5386
+ throw new Error(`platform:edge: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
5369
5387
  }
5370
5388
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
5371
5389
  const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
@@ -5390,10 +5408,10 @@ async function provisionPlatformNames(context, deps) {
5390
5408
  // helper (deployment-record check, then the `/api/health` probe) can run
5391
5409
  // in one call -- unlike `platform:mail`, nothing here needs to happen
5392
5410
  // between the two phases.
5393
- await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:names', `names stays at ${recorded[PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5411
+ await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:edge', `edge stays at ${recorded[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5394
5412
  // Last: a run that failed anywhere above is planned again.
5395
- context.recordScopeId(PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5396
- await record(PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5413
+ context.recordScopeId(PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5414
+ await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5397
5415
  return { resourceId: appId };
5398
5416
  }
5399
5417
  /**
@@ -5403,7 +5421,7 @@ async function provisionPlatformNames(context, deps) {
5403
5421
  * throws a message with no part of the URL in it, since the URL carries the
5404
5422
  * owner's password.
5405
5423
  */
5406
- async function ensureNamesRuntimeUrl(coolify, appId, dbInternalUrl) {
5424
+ async function ensureEdgeRuntimeUrl(coolify, appId, dbInternalUrl) {
5407
5425
  const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
5408
5426
  if (existing)
5409
5427
  return existing.value;
@@ -5414,10 +5432,10 @@ async function ensureNamesRuntimeUrl(coolify, appId, dbInternalUrl) {
5414
5432
  ({ host, pathname, search } = new URL(dbInternalUrl));
5415
5433
  }
5416
5434
  catch {
5417
- throw new Error("coolify:app for the platform (names): the database's internal_db_url could not be parsed to build the runtime DATABASE_URL");
5435
+ throw new Error("coolify:app for the platform (edge): the database's internal_db_url could not be parsed to build the runtime DATABASE_URL");
5418
5436
  }
5419
5437
  const password = randomBytes(24).toString('base64url');
5420
- return `postgres://${NAMES_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
5438
+ return `postgres://${EDGE_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
5421
5439
  }
5422
5440
  function aiKeys(deps) {
5423
5441
  const keys = deps?.keys;
@@ -1,6 +1,6 @@
1
1
  import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
- import type { ZitadelClient } from '@wtfalch/providers/zitadel';
3
+ import { type ZitadelClient } from '@wtfalch/providers/zitadel';
4
4
  import type { OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
@@ -53,15 +53,17 @@ export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
53
53
  * resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
54
54
  * below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
55
55
  * now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
56
- * `zitadel_org` and `zitadel_project`, so a resolver built with one of those
57
- * three `serviceKey`s returns the real id once that step has run. A
58
- * Cloudflare zone is the one scope id nothing writes yet: `executor.ts`'s
59
- * `cloudflareZone` records its result only under `cloudflare:zone:<domain>`
60
- * (a resource id, read back by {@link storeRecordedIds}), not under a plain
61
- * `cloudflare_zone` scope key — a resolver built with that `serviceKey`
62
- * still returns `null` for every org. That is the correct "nothing live yet"
63
- * answer: a reader passed `null` returns `[]`, which `computePlanDetail`
64
- * (`diff.ts`) already reads as "no live resource".
56
+ * `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
57
+ * resolver built with one of those `serviceKey`s returns the real id once
58
+ * that step has run. Before #510, `cloudflareZone` recorded its result only
59
+ * under `cloudflare:zone:<domain>` (a resource id, read back by
60
+ * {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
61
+ * key — so a resolver built with that `serviceKey` always returned `null`,
62
+ * `ProvidersCloudflareReader.listResources` always returned `[]`, and
63
+ * `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
64
+ * even once it was live. A scope key that genuinely has nothing recorded yet
65
+ * still correctly resolves to `null`: a reader passed `null` returns `[]`,
66
+ * which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
65
67
  */
66
68
  export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
67
69
  /**
@@ -112,7 +114,8 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
112
114
  /**
113
115
  * Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
114
116
  * resource per project and per project grant in a ZITADEL org (v1 scope —
115
- * see that method's own doc comment).
117
+ * see that method's own doc comment), plus (#510) a synthesized `issuer`
118
+ * resource once the org's own ZITADEL org is confirmed live.
116
119
  */
117
120
  export declare class ProvidersZitadelReader implements ZitadelReader {
118
121
  private readonly client;
@@ -120,6 +123,26 @@ export declare class ProvidersZitadelReader implements ZitadelReader {
120
123
  private readonly resolveRecordedIds;
121
124
  constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
122
125
  listResources(orgId: OrgId): Promise<LiveResource[]>;
126
+ /**
127
+ * #510: `client.listResources` reports only what is *inside* a ZITADEL org
128
+ * (its projects and grants), never the org record itself, so nothing ever
129
+ * matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
130
+ * every plan re-proposed "provision the org's own ZITADEL org" after a
131
+ * clean apply, even once `zitadel_org` was recorded and live. `orgId` here
132
+ * is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
133
+ * `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
134
+ * org's issuer", and ZITADEL's own org id is an implementation detail of
135
+ * where it lives, the same distinction {@link applyRecordedIds} draws for
136
+ * every other recorded resource.
137
+ *
138
+ * `resolveScope` only reads what was last recorded; it never confirms the
139
+ * org still exists in ZITADEL. A 404 here means the recorded id no longer
140
+ * resolves (the org was removed out of band) and reads as "not live" — the
141
+ * same `[]` answer a never-recorded scope already gives. Any other failure
142
+ * propagates, same as every other call in this reader. An org ZITADEL still
143
+ * returns but marks `ORG_STATE_REMOVED` is not live either.
144
+ */
145
+ private liveIssuer;
123
146
  }
124
147
  /**
125
148
  * Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`