@wtfalch/foundry 0.7.1 → 0.9.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 (54) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.js +1 -5
  4. package/dist/bootstrap/versions.d.ts +8 -0
  5. package/dist/bootstrap/versions.js +31 -0
  6. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  7. package/dist/calls-sfu-deploy-support.js +1 -1
  8. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  9. package/dist/calls-sfu-ssh-adapter.js +1 -1
  10. package/dist/calls-sfu.d.ts +5 -4
  11. package/dist/calls-sfu.js +5 -4
  12. package/dist/index.d.ts +4 -5
  13. package/dist/index.js +6 -3
  14. package/dist/migrations/0024_audit_events.sql +1 -1
  15. package/dist/migrations/0025_audit_display.sql +1 -1
  16. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  17. package/dist/migrations/0027_audit_chain.sql +1 -1
  18. package/dist/migrations/0028_audit_rls.sql +2 -2
  19. package/dist/migrations/0029_hosting_entries.sql +109 -0
  20. package/dist/migrations-dir.d.ts +9 -0
  21. package/dist/migrations-dir.js +10 -0
  22. package/dist/org.d.ts +59 -27
  23. package/dist/org.js +98 -26
  24. package/dist/plan/calls-matrix.d.ts +2 -0
  25. package/dist/plan/calls-matrix.js +2 -0
  26. package/dist/plan/diff.d.ts +3 -1
  27. package/dist/plan/diff.js +100 -32
  28. package/dist/plan/executor.d.ts +324 -19
  29. package/dist/plan/executor.js +764 -34
  30. package/dist/plan/index.d.ts +1 -1
  31. package/dist/plan/index.js +1 -1
  32. package/dist/plan/login-host.d.ts +24 -0
  33. package/dist/plan/login-host.js +21 -0
  34. package/dist/plan/plan-apply.d.ts +2 -0
  35. package/dist/plan/plan-apply.js +26 -10
  36. package/dist/plan/platform-diff.d.ts +102 -12
  37. package/dist/plan/platform-diff.js +236 -15
  38. package/dist/plan/platform-providers.d.ts +43 -1
  39. package/dist/plan/platform-providers.js +245 -33
  40. package/dist/plan/zitadel-system-api.d.ts +64 -0
  41. package/dist/plan/zitadel-system-api.js +125 -0
  42. package/dist/plan.d.ts +43 -0
  43. package/dist/plan.js +16 -1
  44. package/dist/platform.d.ts +1 -1
  45. package/dist/store/audit.d.ts +13 -17
  46. package/dist/store/audit.js +14 -18
  47. package/dist/store/postgres.d.ts +14 -0
  48. package/dist/store/postgres.js +15 -18
  49. package/dist/store/schema.d.ts +20 -1
  50. package/dist/store/schema.js +7 -1
  51. package/package.json +6 -3
  52. package/versions.json +1 -1
  53. package/dist/migrate.d.ts +0 -38
  54. package/dist/migrate.js +0 -47
@@ -1,11 +1,12 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
- import { VERSIONS } from '../bootstrap/versions.js';
3
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
4
4
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
5
5
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
6
6
  import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
7
- import { 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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_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_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_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_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_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, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
7
+ import { 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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_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_ZITADEL_APPLIED_IMAGE_TAG_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_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_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, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
8
8
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
9
+ import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
9
10
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
10
11
  function apexZoneKey(apex) {
11
12
  return `cloudflare:zone:${apex}`;
@@ -93,6 +94,10 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
93
94
  * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
94
95
  * ({@link provisionPlatformArchon}), once a host passes
95
96
  * {@link PlatformArchonDeps}.
97
+ *
98
+ * #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
99
+ * platform database ({@link provisionPlatformBackup}), once a host passes
100
+ * {@link PlatformBackupDeps}.
96
101
  */
97
102
  export class FoundryPlatformStepExecutor {
98
103
  cloudflare;
@@ -106,6 +111,7 @@ export class FoundryPlatformStepExecutor {
106
111
  files;
107
112
  ai;
108
113
  archon;
114
+ backup;
109
115
  constructor(cloudflare, declaredOrgStore, options = {}) {
110
116
  this.cloudflare = cloudflare;
111
117
  this.declaredOrgStore = declaredOrgStore;
@@ -118,6 +124,7 @@ export class FoundryPlatformStepExecutor {
118
124
  this.files = options.files;
119
125
  this.ai = options.ai;
120
126
  this.archon = options.archon;
127
+ this.backup = options.backup;
121
128
  }
122
129
  async blockedSteps(platform, recordedIds = {}) {
123
130
  const blocked = {};
@@ -131,8 +138,19 @@ export class FoundryPlatformStepExecutor {
131
138
  }
132
139
  else if (platform.auth.enabled && this.auth) {
133
140
  const { reason: authBlocked, otherInputsMissing } = await authBrokerBlockedReason(this.auth, recordedIds);
134
- if (authBlocked)
141
+ if (authBlocked) {
135
142
  blocked.auth = authBlocked;
143
+ }
144
+ else {
145
+ // #248: the same "block, not throw" rule mail's own tag check
146
+ // follows. `VERSIONS.zitadel` is a real pin today, so this never
147
+ // fires in practice -- it is here so a future bump that lands a
148
+ // floating tag is caught the same way mail's would be.
149
+ const zitadelTagBlocked = floatingTagBlocked('ZITADEL', VERSIONS.zitadel, 'v4.17.3');
150
+ if (zitadelTagBlocked) {
151
+ blocked.auth = `the platform's sign-in is blocked, not planned: ${zitadelTagBlocked}`;
152
+ }
153
+ }
136
154
  // #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
137
155
  if (otherInputsMissing.length > 0)
138
156
  blocked.authOtherInputsMissing = otherInputsMissing;
@@ -198,6 +216,14 @@ export class FoundryPlatformStepExecutor {
198
216
  }
199
217
  }
200
218
  }
219
+ // Only once a database actually exists to back up -- the same
220
+ // "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
221
+ // itself follows for "no database", so an otherwise-unrelated platform
222
+ // with no backup deps wired carries no standing blocked note.
223
+ if (!this.backup &&
224
+ PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
225
+ blocked.backupExecutorMissing = true;
226
+ }
201
227
  return blocked;
202
228
  }
203
229
  /**
@@ -271,6 +297,12 @@ export class FoundryPlatformStepExecutor {
271
297
  if (action.system === 'coolify' && action.kind === 'jobs') {
272
298
  return this.coolifyJobs(action, context);
273
299
  }
300
+ if (action.system === 'coolify' && action.kind === 'backup') {
301
+ if (!this.backup) {
302
+ throw new Error(`platform:backup:${action.key} needs FoundryPlatformStepExecutor to be constructed with its backup dependencies (#257)`);
303
+ }
304
+ return provisionPlatformBackup(action, context, this.backup);
305
+ }
274
306
  if (action.system === 'sfu' && action.kind === 'livekit') {
275
307
  return this.executeSfu();
276
308
  }
@@ -464,7 +496,7 @@ const AUTH_BROKER_PORT = '3000';
464
496
  * falls through to the login app's own ingress rule below (login v2 answers
465
497
  * everything else, including the root).
466
498
  */
467
- const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
499
+ const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known)(/|$)';
468
500
  /**
469
501
  * `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
470
502
  * (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
@@ -484,6 +516,14 @@ const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
484
516
  /** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
485
517
  const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
486
518
  const PLATFORM_AUTH_ORG_NAME = 'foundry';
519
+ /**
520
+ * #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
521
+ * delivered under, once. A host reads it back to build `zitadelForInstance`
522
+ * (see {@link provisionPlatformAuth}'s doc comment). Distinct from the admin
523
+ * key delivered under `zitadel:platform:admin`.
524
+ */
525
+ export const PLATFORM_AUTH_INSTANCE_BEARER_REF = 'zitadel:platform:instanceBearer';
526
+ const PLATFORM_AUTH_BEARER_USER_NAME = 'foundry-instance-owner';
487
527
  /**
488
528
  * Retries `action` up to `attempts` times, sleeping increasingly between
489
529
  * tries -- the same shape the admin machine user's own delivery retry (just
@@ -727,8 +767,24 @@ async function liveLoginClientTokenHash(coolify, appId) {
727
767
  * first), so it will not start (`parseApps` throws on an empty list) until
728
768
  * then either. Safe to run again: the next apply that registers an app
729
769
  * redeploys the broker with a non-empty list.
730
- * - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
731
- * bearer token for the new instance, which foundry cannot read from it.
770
+ * - ~~Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
771
+ * bearer token for the new instance, which foundry cannot read from it.~~
772
+ * Closed by #283: see "The instance bearer" below.
773
+ *
774
+ * The instance bearer (#283): the container creates no instance of its own
775
+ * (`ZITADEL_FIRSTINSTANCE_SKIP`). foundry generates an RSA keypair, puts the
776
+ * public half in `ZITADEL_SYSTEMAPIUSERS` (a system API user with
777
+ * SYSTEM_OWNER only), and once the container answers calls ZITADEL's system
778
+ * API `CreateInstance` with a machine owner (IAM_OWNER of the new instance)
779
+ * signed by the private half, which lives in memory for that run only. The
780
+ * owner's PAT comes back once and is handed to
781
+ * {@link PlatformAuthDeps.deliverSecret} under
782
+ * {@link PLATFORM_AUTH_INSTANCE_BEARER_REF}, before anything else uses it. A
783
+ * host builds `zitadelForInstance` as `(url) => createZitadelAdminMinter({
784
+ * url, token: <the secret delivered under that ref> })` plus its own org
785
+ * calls; `zitadelForInstance` is first called after the delivery. No human
786
+ * admin is created: a person who needs console access is added by the host
787
+ * through that bearer.
732
788
  *
733
789
  * #197: a `cloudflared` connector for this tunnel is run
734
790
  * ({@link ensureCloudflaredConnector}), so `hostname` answers.
@@ -809,6 +865,15 @@ async function provisionPlatformAuth(context, deps) {
809
865
  await deps.coolify.updateApplication(zitadelAppId, {
810
866
  custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
811
867
  });
868
+ // #251: only on create, never unconditionally -- nothing here patches
869
+ // `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
870
+ // already running, so that is the only moment this tag is actually live.
871
+ // Recording it every run, the way the app id above is, would make
872
+ // `versionNotAppliedLine` compare a bumped `VERSIONS.zitadel` against a
873
+ // value that was never applied to the running app.
874
+ if (zitadelApp.justCreated) {
875
+ await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
876
+ }
812
877
  // ---- the zitadel-login app -----------------------------------------------
813
878
  const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
814
879
  const server = await resolvePlatformServer(deps.coolify);
@@ -859,7 +924,19 @@ async function provisionPlatformAuth(context, deps) {
859
924
  // same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
860
925
  // this package mints itself rather than receiving from a provider.
861
926
  const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
862
- const adminPassword = await ensureGeneratedEnv(deps.coolify, zitadelAppId, 'ZITADEL_ADMIN_PASSWORD', generateAdminPassword);
927
+ // #283: the instance is created once, with the system API, by a platform
928
+ // whose admin machine user is not recorded yet (one that already has it was
929
+ // built from a first instance and keeps it). The keypair is regenerated on
930
+ // any run that still has to create it, so the private half never needs to
931
+ // be held anywhere between runs.
932
+ const lostInstance = recorded[PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY];
933
+ if (lostInstance &&
934
+ !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
935
+ !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
936
+ throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`));
937
+ }
938
+ const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
939
+ const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
863
940
  const zitadelEnvs = [
864
941
  { key: 'ZITADEL_PORT', value: AUTH_ZITADEL_PORT },
865
942
  { key: 'ZITADEL_EXTERNALDOMAIN', value: hostname },
@@ -867,8 +944,7 @@ async function provisionPlatformAuth(context, deps) {
867
944
  { key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
868
945
  { key: 'ZITADEL_TLS_ENABLED', value: 'false' },
869
946
  { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
870
- { key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME', value: 'zitadel-admin' },
871
- { key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED', value: 'false' },
947
+ { key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
872
948
  { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
873
949
  { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
874
950
  { key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
@@ -881,8 +957,12 @@ async function provisionPlatformAuth(context, deps) {
881
957
  ];
882
958
  if (masterkey)
883
959
  zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
884
- if (adminPassword)
885
- zitadelEnvs.push({ key: 'ZITADEL_ADMIN_PASSWORD', value: adminPassword });
960
+ if (systemKeys) {
961
+ zitadelEnvs.push({
962
+ key: 'ZITADEL_SYSTEMAPIUSERS',
963
+ value: systemApiUsersEnv(systemKeys.publicKey),
964
+ });
965
+ }
886
966
  const loginEnvs = [
887
967
  {
888
968
  key: 'ZITADEL_API_URL',
@@ -1011,6 +1091,50 @@ async function provisionPlatformAuth(context, deps) {
1011
1091
  // a caller that reaches the step anyway.
1012
1092
  throw new Error(`platform:auth: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1013
1093
  }
1094
+ if (systemKeys) {
1095
+ // ZITADEL answers /debug/healthz without an instance; the openid
1096
+ // document below only exists once the instance does.
1097
+ await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
1098
+ // Sent exactly once. Anything that may have reached ZITADEL leaves a
1099
+ // marker first, so the next run refuses instead of creating again.
1100
+ let created;
1101
+ try {
1102
+ created = await createZitadelInstance({
1103
+ customDomain: hostname,
1104
+ privateKey: systemKeys.privateKey,
1105
+ instanceName: PLATFORM_AUTH_ORG_NAME,
1106
+ firstOrgName: PLATFORM_AUTH_ORG_NAME,
1107
+ owner: { userName: PLATFORM_AUTH_BEARER_USER_NAME, name: 'Foundry (instance owner)' },
1108
+ }, { fetch: runtime.fetch, now: runtime.now ?? Date.now });
1109
+ }
1110
+ catch (error) {
1111
+ if (error instanceof CreateInstanceError && error.kind !== 'rejected') {
1112
+ await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, 'unknown');
1113
+ }
1114
+ throw error;
1115
+ }
1116
+ const { instanceId, pat } = created;
1117
+ // Recorded before the PAT leaves this function, like the admin key below.
1118
+ await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
1119
+ const attempts = runtime.deliverAttempts ?? 3;
1120
+ for (let attempt = 1;; attempt += 1) {
1121
+ try {
1122
+ await deps.deliverSecret(PLATFORM_AUTH_INSTANCE_BEARER_REF, pat);
1123
+ break;
1124
+ }
1125
+ catch (error) {
1126
+ if (attempt >= attempts) {
1127
+ const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
1128
+ pat,
1129
+ ]);
1130
+ throw new Error(`platform:auth: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`)}`);
1131
+ }
1132
+ await sleep(1000 * attempt);
1133
+ }
1134
+ }
1135
+ // The marker stays recorded but is ignored once this is set.
1136
+ await record(PLATFORM_AUTH_INSTANCE_SCOPE_KEY, instanceId);
1137
+ }
1014
1138
  // Minting needs a running instance behind the tunnel: the first apply
1015
1139
  // gets here right after the deploy.
1016
1140
  await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
@@ -1520,12 +1644,17 @@ async function provisionPlatformMail(context, deps) {
1520
1644
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
1521
1645
  return { resourceId: appId };
1522
1646
  }
1523
- /** Why the Stalwart image tag cannot be used, or `null`. A floating tag cannot be re-pulled by comparing tags. */
1647
+ /**
1648
+ * Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
1649
+ * over the estate-wide {@link floatingTagBlocked} -- the mail step's own
1650
+ * message, naming `gitRef` since that is the declared field this tag
1651
+ * actually comes from.
1652
+ */
1524
1653
  function mailImageTagBlocked(tag) {
1525
- if (tag.trim() === '' || tag.trim().toLowerCase() === 'latest') {
1526
- return `the platform's mail server is blocked, not planned: the Stalwart image tag "${tag}" floats; declare a pinned release (for example v0.16.24) as the mail gitRef`;
1527
- }
1528
- return null;
1654
+ const reason = floatingTagBlocked('Stalwart', tag, 'v0.16.24');
1655
+ return reason
1656
+ ? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
1657
+ : null;
1529
1658
  }
1530
1659
  /** Why the server's address cannot be the MX A record's content, or `null`. */
1531
1660
  function mailServerIpBlocked(ip) {
@@ -1933,7 +2062,7 @@ async function provisionPlatformArchon(context, deps) {
1933
2062
  // response was lost, or one made for another hostname: its client id
1934
2063
  // cannot be read back (`listApps` does not carry it), so it is deleted and
1935
2064
  // made again, as `zitadelApp` does.
1936
- const orphan = (await zitadel.listApps(org.id, project.id)).find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
2065
+ const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP &&
1937
2066
  a.appType === 'web' &&
1938
2067
  a.state !== 'APP_STATE_REMOVED');
1939
2068
  if (orphan) {
@@ -3597,10 +3726,6 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
3597
3726
  }
3598
3727
  return randomBytes(24).toString('base64');
3599
3728
  }
3600
- /** `docs/deploy.md`: "ZITADEL_ADMIN_PASSWORD: 8+, upper, lower, digit, symbol". */
3601
- function generateAdminPassword() {
3602
- return `${randomBytes(18).toString('base64')}Aa1!`;
3603
- }
3604
3729
  /**
3605
3730
  * #171: how the host signs a files management request with the key this step
3606
3731
  * delivers under {@link FILES_MANAGEMENT_KEY_REF} (files'
@@ -3836,7 +3961,7 @@ async function provisionPlatformFiles(context, deps) {
3836
3961
  // An app by this name here has no recorded secret (no undelivered id, or
3837
3962
  // the throw above would have fired): a crash between the create and the
3838
3963
  // record. Nothing was delivered, so it is deleted and made again.
3839
- const stale = (await zitadel.listApps(org.id, project.id)).filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
3964
+ const stale = (await zitadel.listApps(org.id, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
3840
3965
  for (const app of stale) {
3841
3966
  try {
3842
3967
  await zitadel.deleteApp(org.id, project.id, app.id);
@@ -4339,6 +4464,61 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
4339
4464
  await sleep(interval);
4340
4465
  }
4341
4466
  }
4467
+ /** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
4468
+ const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
4469
+ /** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
4470
+ const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
4471
+ /** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
4472
+ const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
4473
+ /**
4474
+ * #257: schedules a nightly R2 backup of one platform database --
4475
+ * `planBackups` (`platform-diff.ts`) plans this step once per service, each
4476
+ * carrying that service's own database id as `action.desired.databaseId`
4477
+ * (recorded by that service's own `coolify:app` step; never minted here).
4478
+ * The R2 bucket is registered as a Coolify S3 storage once, by name, and
4479
+ * its uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} so
4480
+ * every service's own backup step -- and a rerun of this one -- reuses it
4481
+ * rather than registering a second storage entry under the same name.
4482
+ * `createDatabaseBackup` itself is the idempotency boundary for the
4483
+ * schedule: an existing enabled, `save_s3` schedule is left alone rather
4484
+ * than replaced, since Coolify has no "upsert" for this call and a second
4485
+ * schedule would dump the same database twice a night.
4486
+ */
4487
+ async function provisionPlatformBackup(action, context, deps) {
4488
+ const { coolify, r2 } = deps;
4489
+ const databaseId = action.desired.databaseId;
4490
+ const recorded = context.recordedIds();
4491
+ let storageUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
4492
+ if (!storageUuid) {
4493
+ const found = (await coolify.listS3Storages()).find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
4494
+ if (found) {
4495
+ storageUuid = found.uuid;
4496
+ }
4497
+ else {
4498
+ const created = await coolify.createS3Storage({
4499
+ name: PLATFORM_BACKUP_S3_STORAGE_NAME,
4500
+ endpoint: r2.endpoint,
4501
+ bucket: r2.bucket,
4502
+ region: r2.region ?? 'auto',
4503
+ key: r2.accessKeyId,
4504
+ secret: r2.secretAccessKey,
4505
+ is_usable: true,
4506
+ });
4507
+ storageUuid = created.uuid;
4508
+ }
4509
+ context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, storageUuid);
4510
+ }
4511
+ const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
4512
+ if (!existing) {
4513
+ await coolify.createDatabaseBackup(databaseId, {
4514
+ frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
4515
+ enabled: true,
4516
+ save_s3: true,
4517
+ s3_storage_uuid: storageUuid,
4518
+ });
4519
+ }
4520
+ return undefined;
4521
+ }
4342
4522
  /**
4343
4523
  * The real {@link PlatformCloudflareReader}: the platform's apex zone as
4344
4524
  * `planApexZone` expects to see it, and nothing else. The zone's DNS records
@@ -4393,23 +4573,55 @@ export class ProvidersPlatformCoolifyReader {
4393
4573
  this.platformStore = platformStore;
4394
4574
  }
4395
4575
  async listResources() {
4396
- const recordedId = (await this.platformStore.getServiceIds())[JOBS_APP_KEY];
4397
- if (!recordedId)
4398
- return [];
4399
- try {
4400
- const app = await this.client.getApplication(recordedId);
4401
- return [
4402
- {
4576
+ const recorded = await this.platformStore.getServiceIds();
4577
+ const resources = [];
4578
+ const recordedAppId = recorded[JOBS_APP_KEY];
4579
+ if (recordedAppId) {
4580
+ try {
4581
+ const app = await this.client.getApplication(recordedAppId);
4582
+ resources.push({
4403
4583
  system: 'coolify',
4404
4584
  kind: 'jobs',
4405
4585
  key: JOBS_SCHEDULER_KEY,
4406
4586
  config: { uuid: app.uuid, status: app.status },
4407
- },
4408
- ];
4587
+ });
4588
+ }
4589
+ catch {
4590
+ // A recorded id Coolify no longer has reads as not-live (see this
4591
+ // class's own doc comment); the scheduler, not every other resource
4592
+ // this reader checks.
4593
+ }
4409
4594
  }
4410
- catch {
4411
- return [];
4595
+ // #257: one backup-schedule resource per service whose database is
4596
+ // recorded, found the only way a schedule can be: listed off that
4597
+ // database itself, since Coolify names no schedule of its own.
4598
+ for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
4599
+ const databaseId = recorded[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
4600
+ if (!databaseId)
4601
+ continue;
4602
+ try {
4603
+ const backups = await this.client.listDatabaseBackups(databaseId);
4604
+ const scheduled = backups.find((backup) => backup.enabled && backup.save_s3);
4605
+ if (scheduled) {
4606
+ resources.push({
4607
+ system: 'coolify',
4608
+ kind: 'backup',
4609
+ key: service,
4610
+ config: { uuid: scheduled.uuid, frequency: scheduled.frequency },
4611
+ });
4612
+ }
4613
+ }
4614
+ catch {
4615
+ // A Coolify error (or a recorded database id Coolify no longer has)
4616
+ // reads as not-live, the same as the jobs app above -- `databaseId`
4617
+ // is still recorded, so `planBackups` plans the step again rather
4618
+ // than reporting it blocked; `provisionPlatformBackup`'s own
4619
+ // `listDatabaseBackups` check (run again there) is what keeps a
4620
+ // transient failure here from scheduling a second backup once the
4621
+ // first one did succeed.
4622
+ }
4412
4623
  }
4624
+ return resources;
4413
4625
  }
4414
4626
  }
4415
4627
  /**
@@ -0,0 +1,64 @@
1
+ /**
2
+ * #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
3
+ * (`POST /system/v1/instances/_create`, permission `system.instance.write`,
4
+ * https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
5
+ * creates the instance with a machine owner (IAM_OWNER of that instance) and
6
+ * returns that owner's personal access token (`pat`) once, inline. foundry
7
+ * authenticates to the system API with a short-lived RS256 JWT signed by a
8
+ * keypair it generates: the public half goes into the container as a system
9
+ * API user, the private half stays in memory for one run. The calls follow
10
+ * ZITADEL's source and proto and have not been run against a live instance.
11
+ */
12
+ /** The system API user's name; it is the JWT's `iss` and `sub`. */
13
+ export declare const SYSTEM_API_USER_NAME = "foundry";
14
+ /** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
15
+ export declare function generateSystemApiKeypair(): {
16
+ publicKey: string;
17
+ privateKey: string;
18
+ };
19
+ /**
20
+ * The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
21
+ * shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
22
+ * PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
23
+ */
24
+ export declare function systemApiUsersEnv(publicKeyPem: string): string;
25
+ /** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
26
+ export declare function signSystemApiJwt(privateKeyPem: string, audience: string, nowMs: number): string;
27
+ /**
28
+ * How a create failed. `rejected`: ZITADEL refused it for good before doing
29
+ * anything (bad key, bad request), so nothing exists. `exists`: an instance
30
+ * for the domain already exists. `uncertain`: the request may have reached
31
+ * ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
32
+ * may exist. The caller records a marker for the last two.
33
+ */
34
+ export declare class CreateInstanceError extends Error {
35
+ readonly kind: 'rejected' | 'exists' | 'uncertain';
36
+ constructor(kind: 'rejected' | 'exists' | 'uncertain', message: string);
37
+ }
38
+ /** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
39
+ export declare function instanceWithoutBearerMessage(hostname: string, detail: string): string;
40
+ export interface CreateInstanceRuntime {
41
+ fetch?: typeof fetch;
42
+ now: () => number;
43
+ /** Fetch timeout for the one create call (default 30s). */
44
+ timeoutMs?: number;
45
+ }
46
+ /**
47
+ * Creates the platform's ZITADEL instance on `customDomain` with one machine
48
+ * owner and returns its id and the owner's PAT. Sends exactly one request,
49
+ * never retries, and signs a fresh 60-second JWT for it. The PAT is returned
50
+ * to the caller only, never logged or put in an error.
51
+ */
52
+ export declare function createZitadelInstance(input: {
53
+ customDomain: string;
54
+ privateKey: string;
55
+ instanceName: string;
56
+ firstOrgName: string;
57
+ owner: {
58
+ userName: string;
59
+ name: string;
60
+ };
61
+ }, runtime: CreateInstanceRuntime): Promise<{
62
+ instanceId: string;
63
+ pat: string;
64
+ }>;
@@ -0,0 +1,125 @@
1
+ import { createSign, generateKeyPairSync } from 'node:crypto';
2
+ /**
3
+ * #283: foundry's way into a brand-new ZITADEL instance. ZITADEL's system API
4
+ * (`POST /system/v1/instances/_create`, permission `system.instance.write`,
5
+ * https://github.com/zitadel/zitadel/blob/main/proto/zitadel/system.proto)
6
+ * creates the instance with a machine owner (IAM_OWNER of that instance) and
7
+ * returns that owner's personal access token (`pat`) once, inline. foundry
8
+ * authenticates to the system API with a short-lived RS256 JWT signed by a
9
+ * keypair it generates: the public half goes into the container as a system
10
+ * API user, the private half stays in memory for one run. The calls follow
11
+ * ZITADEL's source and proto and have not been run against a live instance.
12
+ */
13
+ /** The system API user's name; it is the JWT's `iss` and `sub`. */
14
+ export const SYSTEM_API_USER_NAME = 'foundry';
15
+ /** A fresh RSA keypair: the public key (PEM) for ZITADEL, the private key (PKCS#8 PEM) for signing. */
16
+ export function generateSystemApiKeypair() {
17
+ return generateKeyPairSync('rsa', {
18
+ modulusLength: 2048,
19
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
20
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
21
+ });
22
+ }
23
+ /**
24
+ * The value of `ZITADEL_SYSTEMAPIUSERS`: JSON in ZITADEL's `SystemAPIUsers`
25
+ * shape (`cmd/defaults.yaml`), `KeyData` being the base64 of the public key
26
+ * PEM. With no `Memberships` ZITADEL grants exactly System/SYSTEM_OWNER.
27
+ */
28
+ export function systemApiUsersEnv(publicKeyPem) {
29
+ return JSON.stringify({
30
+ [SYSTEM_API_USER_NAME]: { KeyData: Buffer.from(publicKeyPem).toString('base64') },
31
+ });
32
+ }
33
+ const b64url = (input) => Buffer.from(input).toString('base64url');
34
+ /** A 1-minute RS256 JWT for the system API: `iss` = `sub` = the user name, `aud` = the instance URL. */
35
+ export function signSystemApiJwt(privateKeyPem, audience, nowMs) {
36
+ const iat = Math.floor(nowMs / 1000);
37
+ const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
38
+ const body = b64url(JSON.stringify({
39
+ iss: SYSTEM_API_USER_NAME,
40
+ sub: SYSTEM_API_USER_NAME,
41
+ aud: audience,
42
+ iat,
43
+ exp: iat + 60,
44
+ }));
45
+ const signature = createSign('RSA-SHA256').update(`${head}.${body}`).sign(privateKeyPem);
46
+ return `${head}.${body}.${b64url(signature)}`;
47
+ }
48
+ /**
49
+ * How a create failed. `rejected`: ZITADEL refused it for good before doing
50
+ * anything (bad key, bad request), so nothing exists. `exists`: an instance
51
+ * for the domain already exists. `uncertain`: the request may have reached
52
+ * ZITADEL (timeout, network error, 5xx, an unreadable answer), so an instance
53
+ * may exist. The caller records a marker for the last two.
54
+ */
55
+ export class CreateInstanceError extends Error {
56
+ kind;
57
+ constructor(kind, message) {
58
+ super(message);
59
+ this.kind = kind;
60
+ this.name = 'CreateInstanceError';
61
+ }
62
+ }
63
+ /** What a person does when an instance exists without a delivered bearer. Nothing is deleted automatically. */
64
+ export function instanceWithoutBearerMessage(hostname, detail) {
65
+ return `platform:auth: a ZITADEL instance may already exist for ${hostname} without a delivered bearer (${detail}). To recover, delete the instance with the system API (DELETE /system/v1/instances/{id}) or redeploy the ZITADEL database, remove the recorded key zitadel_platform_instance_undelivered, then apply again`;
66
+ }
67
+ /**
68
+ * Creates the platform's ZITADEL instance on `customDomain` with one machine
69
+ * owner and returns its id and the owner's PAT. Sends exactly one request,
70
+ * never retries, and signs a fresh 60-second JWT for it. The PAT is returned
71
+ * to the caller only, never logged or put in an error.
72
+ */
73
+ export async function createZitadelInstance(input, runtime) {
74
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
75
+ const url = `https://${input.customDomain}`;
76
+ let response;
77
+ try {
78
+ response = await fetchImpl(`${url}/system/v1/instances/_create`, {
79
+ method: 'POST',
80
+ signal: AbortSignal.timeout(runtime.timeoutMs ?? 30_000),
81
+ headers: {
82
+ authorization: `Bearer ${signSystemApiJwt(input.privateKey, url, runtime.now())}`,
83
+ 'content-type': 'application/json',
84
+ },
85
+ body: JSON.stringify({
86
+ instanceName: input.instanceName,
87
+ firstOrgName: input.firstOrgName,
88
+ customDomain: input.customDomain,
89
+ machine: {
90
+ userName: input.owner.userName,
91
+ name: input.owner.name,
92
+ personalAccessToken: {
93
+ expirationDate: new Date(runtime.now() + 10 * 365 * 86_400_000).toISOString(),
94
+ },
95
+ },
96
+ }),
97
+ });
98
+ }
99
+ catch {
100
+ throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the create request timed out or failed'));
101
+ }
102
+ if (response.status === 401) {
103
+ throw new CreateInstanceError('rejected', 'platform:auth: system API key rejected (HTTP 401); nothing was created');
104
+ }
105
+ if (response.status === 409) {
106
+ throw new CreateInstanceError('exists', instanceWithoutBearerMessage(input.customDomain, 'ZITADEL answered 409, already exists'));
107
+ }
108
+ if (!response.ok) {
109
+ // A 4xx is a refusal before anything was created; a 5xx may not be.
110
+ throw new CreateInstanceError(response.status < 500 ? 'rejected' : 'uncertain', response.status < 500
111
+ ? `platform:auth: ZITADEL refused to create the instance (HTTP ${response.status}); nothing was created`
112
+ : instanceWithoutBearerMessage(input.customDomain, `ZITADEL answered HTTP ${response.status}`));
113
+ }
114
+ let body = {};
115
+ try {
116
+ body = (await response.json());
117
+ }
118
+ catch {
119
+ // Handled below.
120
+ }
121
+ if (!body.instanceId || !body.pat) {
122
+ throw new CreateInstanceError('uncertain', instanceWithoutBearerMessage(input.customDomain, 'the answer had no instance id or token'));
123
+ }
124
+ return { instanceId: body.instanceId, pat: body.pat };
125
+ }