@wtfalch/foundry 0.23.0 → 0.24.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.
@@ -197,6 +197,12 @@ export interface FoundryPlatformStepExecutorOptions {
197
197
  * heartbeat checks reported as blocked rather than planned.
198
198
  */
199
199
  heartbeats?: PlatformHeartbeatsDeps;
200
+ /**
201
+ * #604: dependencies for the `cloudflare:access-provider` step -- optional
202
+ * for the same reason as `auth`; edge and sign-in applied with this unset
203
+ * gets the step reported as blocked.
204
+ */
205
+ accessProvider?: PlatformAccessProviderDeps;
200
206
  }
201
207
  /**
202
208
  * The real {@link PlatformStepExecutor} for the two steps built so far:
@@ -279,6 +285,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
279
285
  private readonly backup?;
280
286
  private readonly hc?;
281
287
  private readonly heartbeats?;
288
+ private readonly accessProvider?;
282
289
  constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list' | 'get'>, options?: FoundryPlatformStepExecutorOptions);
283
290
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
284
291
  auth?: string;
@@ -290,6 +297,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
290
297
  edge?: string;
291
298
  archon?: string;
292
299
  mail?: string;
300
+ accessProvider?: string;
293
301
  backupExecutorMissing?: boolean;
294
302
  hc?: string;
295
303
  heartbeatsExecutorMissing?: boolean;
@@ -959,6 +967,22 @@ export interface PlatformEdgeDeps {
959
967
  /** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
960
968
  runtime?: PlatformEdgeRuntime;
961
969
  }
970
+ /** The Access calls the provider step makes. */
971
+ export type PlatformAccessProviderPort = Pick<AccessPort, 'readiness' | 'ensureProvider'>;
972
+ /** #604: dependencies for {@link provisionPlatformAccessProvider}. */
973
+ export interface PlatformAccessProviderDeps {
974
+ /** Edge's Access calls, at its recorded address (see {@link PlatformAccessFactory}). */
975
+ access: (edgeUrl: string) => PlatformAccessProviderPort | Promise<PlatformAccessProviderPort>;
976
+ /** Must target the platform's own ZITADEL instance, or `zitadelForInstance` must be set. */
977
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject' | 'listApps' | 'deleteApp'> & Required<Pick<ZitadelPort, 'createOidcAppWithSecret'>>;
978
+ /** {@link PlatformArchonDeps.zitadelForInstance}: the ZITADEL client for the sign-in at `issuerUrl`. */
979
+ zitadelForInstance?: (issuerUrl: string) => PlatformAccessProviderDeps['zitadel'];
980
+ /** Sleep and delivery tries for the one call that carries the secret; real ones by default. */
981
+ runtime?: {
982
+ sleep?: (ms: number) => Promise<void>;
983
+ deliverAttempts?: number;
984
+ };
985
+ }
962
986
  export interface PlatformHcRuntime {
963
987
  fetch?: typeof fetch;
964
988
  sleep?: (ms: number) => Promise<void>;
@@ -6,9 +6,10 @@ import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  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
+ import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
9
10
  import { healthCheckSettings } from './health-check-settings.js';
10
11
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
11
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, 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_DEPLOYMENT_SCOPE_KEY, 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_INTERNAL_URL_SCOPE_KEY, 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_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_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_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
12
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_DEPLOYMENT_SCOPE_KEY, 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_INTERNAL_URL_SCOPE_KEY, 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_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_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_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
12
13
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
13
14
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
14
15
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -155,6 +156,7 @@ export class FoundryPlatformStepExecutor {
155
156
  backup;
156
157
  hc;
157
158
  heartbeats;
159
+ accessProvider;
158
160
  constructor(cloudflare, declaredOrgStore, options = {}) {
159
161
  this.cloudflare = cloudflare;
160
162
  this.declaredOrgStore = declaredOrgStore;
@@ -172,6 +174,7 @@ export class FoundryPlatformStepExecutor {
172
174
  this.backup = options.backup;
173
175
  this.hc = options.hc;
174
176
  this.heartbeats = options.heartbeats;
177
+ this.accessProvider = options.accessProvider;
175
178
  }
176
179
  async blockedSteps(platform, recordedIds = {}) {
177
180
  const blocked = {};
@@ -323,6 +326,26 @@ export class FoundryPlatformStepExecutor {
323
326
  if (!apiKey)
324
327
  blocked.heartbeatsApiKeyMissing = true;
325
328
  }
329
+ // #604: only once edge and sign-in are applied (the planner reports
330
+ // that case itself) and no provider is recorded yet.
331
+ if (platform.edge.enabled &&
332
+ platform.auth.enabled &&
333
+ !recordedIds[ACCESS_PROVIDER_RECORD_KEY] &&
334
+ recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) {
335
+ if (!this.accessProvider?.zitadel.createOidcAppWithSecret) {
336
+ blocked.accessProvider =
337
+ 'the Access sign-in provider is blocked, not planned: the host built the platform executor without its Access provider dependencies (a ZITADEL client that can make a client with a secret)';
338
+ }
339
+ else {
340
+ try {
341
+ await (await platformAccess(this.accessProvider, recordedIds, 'platform:access')).readiness();
342
+ }
343
+ catch {
344
+ blocked.accessProvider =
345
+ 'the Access sign-in provider is blocked, not planned: edge could not confirm that Cloudflare Access is ready (its Cloudflare token needs every Access right)';
346
+ }
347
+ }
348
+ }
326
349
  // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
327
350
  if (this.archon?.runsInsideApp) {
328
351
  blocked.archonRunsInsideApp = true;
@@ -490,6 +513,12 @@ export class FoundryPlatformStepExecutor {
490
513
  }
491
514
  return provisionPlatformHc(context, this.hc);
492
515
  }
516
+ if (action.system === 'cloudflare' && action.kind === 'access-provider') {
517
+ if (!this.accessProvider) {
518
+ throw new Error('cloudflare:access-provider needs FoundryPlatformStepExecutor to be constructed with its Access provider dependencies (#604)');
519
+ }
520
+ return provisionPlatformAccessProvider(context, this.accessProvider);
521
+ }
493
522
  if (action.system === 'heartbeats' && action.kind === 'reconcile') {
494
523
  if (!this.heartbeats) {
495
524
  throw new Error('heartbeats:reconcile needs FoundryPlatformStepExecutor to be constructed with its heartbeats dependencies (#540)');
@@ -5668,6 +5697,85 @@ async function provisionPlatformEdge(context, deps) {
5668
5697
  await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5669
5698
  return { resourceId: appId };
5670
5699
  }
5700
+ /** The ZITADEL project and client the platform's Access provider signs in through (#604). */
5701
+ const PLATFORM_ACCESS_ZITADEL_PROJECT = 'cloudflare-access';
5702
+ const PLATFORM_ACCESS_ZITADEL_APP = 'cloudflare-access';
5703
+ /** The name edge finds the Cloudflare Access provider by. */
5704
+ const PLATFORM_ACCESS_PROVIDER_NAME = 'foundry';
5705
+ /**
5706
+ * #604: makes the platform's ZITADEL a sign-in provider in Cloudflare Access.
5707
+ * Reads the Access team address from edge, makes a ZITADEL client whose
5708
+ * redirect is that address's callback, and hands its id and secret to edge's
5709
+ * `ensureProvider`, which is the delivery. The client's secret exists once
5710
+ * (CLAUDE.md rules 1 and 6): its app id is recorded and persisted before the
5711
+ * secret leaves, the guard {@link ACCESS_PROVIDER_RECORD_KEY} only after edge
5712
+ * returned the provider id, and a recorded undelivered id stops a second
5713
+ * mint. The secret reaches only `ensureProvider`: no error, log or record
5714
+ * holds it. A client app by this name with no recorded id is one whose create
5715
+ * response was lost before anything saw its secret, so it is deleted and made
5716
+ * again.
5717
+ */
5718
+ async function provisionPlatformAccessProvider(context, deps) {
5719
+ const { platform } = context;
5720
+ const recorded = context.recordedIds();
5721
+ const sleep = deps.runtime?.sleep ?? defaultSleep;
5722
+ const attempts = deps.runtime?.deliverAttempts ?? 3;
5723
+ const label = 'platform:access-provider';
5724
+ if (recorded[ACCESS_PROVIDER_RECORD_KEY])
5725
+ return {};
5726
+ const lost = recorded[PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY];
5727
+ if (lost) {
5728
+ throw new Error(`${label}: the ZITADEL client ${lost} was made and its secret may or may not have reached edge; delete it and remove the recorded keys ${PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY} and ${ACCESS_PROVIDER_RECORD_KEY}, then apply again to make both anew`);
5729
+ }
5730
+ const access = await platformAccess(deps, recorded, label);
5731
+ let authDomain;
5732
+ try {
5733
+ ({ authDomain } = await access.readiness());
5734
+ }
5735
+ catch {
5736
+ throw new Error(`${label}: edge could not confirm that Cloudflare Access is ready`);
5737
+ }
5738
+ const issuer = `https://${platform.auth.hostname}`;
5739
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
5740
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
5741
+ const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ACCESS_ZITADEL_PROJECT);
5742
+ const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ACCESS_ZITADEL_APP && a.state !== 'APP_STATE_REMOVED');
5743
+ if (orphan) {
5744
+ try {
5745
+ await zitadel.deleteApp(org.id, project.id, orphan.id);
5746
+ }
5747
+ catch (error) {
5748
+ if (!isNotFoundError(error))
5749
+ throw error;
5750
+ }
5751
+ }
5752
+ const client = await zitadel.createOidcAppWithSecret(org.id, project.id, {
5753
+ name: PLATFORM_ACCESS_ZITADEL_APP,
5754
+ redirectUris: [`https://${authDomain}/cdn-cgi/access/callback`],
5755
+ postLogoutRedirectUris: [`https://${authDomain}/`],
5756
+ });
5757
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, client.appId);
5758
+ await context.persist?.();
5759
+ let providerId = '';
5760
+ await deliverWithRetry(async (_ref, secret) => {
5761
+ providerId = (await access.ensureProvider({
5762
+ name: PLATFORM_ACCESS_PROVIDER_NAME,
5763
+ clientId: client.clientId,
5764
+ clientSecret: secret,
5765
+ authUrl: `${issuer}/oauth/v2/authorize`,
5766
+ tokenUrl: `${issuer}/oauth/v2/token`,
5767
+ certsUrl: `${issuer}/oauth/v2/keys`,
5768
+ scopes: ['openid', 'email', 'profile', 'urn:zitadel:iam:user:resourceowner'],
5769
+ claims: [GATE_CLAIM_NAME],
5770
+ })).id;
5771
+ }, ACCESS_PROVIDER_RECORD_KEY, client.clientSecret, attempts, sleep, `the ZITADEL client ${client.appId}'s secret`, label);
5772
+ // Last: the guard says the secret reached edge.
5773
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, platform.auth.hostname);
5774
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, authDomain);
5775
+ context.recordScopeId(ACCESS_PROVIDER_RECORD_KEY, providerId);
5776
+ await context.persist?.();
5777
+ return {};
5778
+ }
5671
5779
  /** `healthchecks/healthchecks` on Docker Hub -- #540's own "third-party, not this estate's own repo" image. */
5672
5780
  const HC_IMAGE = 'healthchecks/healthchecks';
5673
5781
  const PLATFORM_HC_PROJECT = 'platform';
@@ -6022,7 +6130,7 @@ label = 'platform:files') {
6022
6130
  }
6023
6131
  catch {
6024
6132
  if (attempt >= attempts) {
6025
- throw new Error(`${label}: ${what} could not be delivered after ${attempts} tries; the next plan reports it as lost and mints no new one`);
6133
+ throw new Error(`${label}: ${what} could not be delivered after ${attempts} tries; the next plan reports it as blocked and mints no new one`);
6026
6134
  }
6027
6135
  await sleep(1000 * attempt);
6028
6136
  }
@@ -79,6 +79,30 @@ export class DeclaredOrgError extends Error {
79
79
  this.name = 'DeclaredOrgError';
80
80
  }
81
81
  }
82
+ /**
83
+ * Reads one stored entry. `hostnames` defaults as migration 0030 set it, and
84
+ * `gates` to none. An entry stored before gates existed with a non-empty
85
+ * `accessEmails` was gated: read it as one gate on its first non-'*' hostname
86
+ * (as stored), or on '*' when that is its only hostname, so the read fails
87
+ * closed and never drops a declared gate. With no hostname it is not served
88
+ * and reads no gates. The emails are dropped (people are chosen
89
+ * elsewhere now), and the next write stores the new shape without them. An
90
+ * entry that already has `gates` keeps them. A converted company over
91
+ * `gateLimit` is not an error here; validation reports it on the next validate.
92
+ */
93
+ function storedEntryToEntry(stored, usesDomain) {
94
+ const { accessEmails, ...rest } = stored;
95
+ const hostnames = stored.hostnames ?? (stored.key === APP_ENTRY_KEY && usesDomain ? ['@'] : []);
96
+ let gates = stored.gates;
97
+ if (gates === undefined) {
98
+ const first = hostnames.find((h) => h !== '*') ?? hostnames[0];
99
+ gates =
100
+ accessEmails !== undefined && accessEmails.length > 0 && first !== undefined
101
+ ? [{ key: stored.key, hostnames: [first] }]
102
+ : [];
103
+ }
104
+ return { ...rest, hostnames, gates };
105
+ }
82
106
  function rowToOrg(row) {
83
107
  return {
84
108
  id: row.id,
@@ -117,11 +141,8 @@ function rowToOrg(row) {
117
141
  usesDomain: row.hostingUsesDomain,
118
142
  tunnel: row.hostingTunnel,
119
143
  issuerApps: row.hostingIssuerApps,
120
- // Migration 0030's default for an entry stored before `hostnames`.
121
- entries: row.hostingEntries.map((entry) => ({
122
- ...entry,
123
- hostnames: entry.hostnames ?? (entry.key === APP_ENTRY_KEY && row.hostingUsesDomain ? ['@'] : []),
124
- })),
144
+ entries: row.hostingEntries.map((entry) => storedEntryToEntry(entry, row.hostingUsesDomain)),
145
+ gateLimit: row.hostingGateLimit,
125
146
  },
126
147
  sso: { enabled: row.ssoEnabled },
127
148
  guests: { enabled: row.guestsEnabled },
@@ -163,6 +184,7 @@ function orgToRow(org) {
163
184
  hostingTunnel: org.hosting.tunnel,
164
185
  hostingIssuerApps: org.hosting.issuerApps,
165
186
  hostingEntries: org.hosting.entries,
187
+ hostingGateLimit: org.hosting.gateLimit,
166
188
  ssoEnabled: org.sso.enabled,
167
189
  guestsEnabled: org.guests.enabled,
168
190
  soloEnabled: org.solo.enabled,
@@ -533,6 +533,23 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
533
533
  }, {}, {
534
534
  $type: HostingEntry[];
535
535
  }>;
536
+ hostingGateLimit: import("drizzle-orm/pg-core").PgColumn<{
537
+ name: "hosting_gate_limit";
538
+ tableName: "foundry_declared_orgs";
539
+ dataType: "number";
540
+ columnType: "PgInteger";
541
+ data: number;
542
+ driverParam: string | number;
543
+ notNull: true;
544
+ hasDefault: true;
545
+ isPrimaryKey: false;
546
+ isAutoincrement: false;
547
+ hasRuntimeDefault: false;
548
+ enumValues: undefined;
549
+ baseColumn: never;
550
+ identity: undefined;
551
+ generated: undefined;
552
+ }, {}, {}>;
536
553
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
537
554
  name: "sso_enabled";
538
555
  tableName: "foundry_declared_orgs";
@@ -59,6 +59,8 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
59
59
  // hosting_access_emails and hosting_app_env columns, which stay for an older
60
60
  // reader and are no longer written.
61
61
  hostingEntries: jsonb('hosting_entries').notNull().default([]).$type(),
62
+ // HostingConfig.gateLimit (org.ts, #603); migrations/0048. Default 3 is DEFAULT_GATE_LIMIT.
63
+ hostingGateLimit: integer('hosting_gate_limit').notNull().default(3),
62
64
  ssoEnabled: boolean('sso_enabled').notNull().default(false),
63
65
  guestsEnabled: boolean('guests_enabled').notNull().default(false),
64
66
  soloEnabled: boolean('solo_enabled').notNull().default(false),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@wtfalch/audit": "0.6.0",
45
45
  "@wtfalch/auth": "0.16.0",
46
- "@wtfalch/edge": "0.9.0",
46
+ "@wtfalch/edge": "0.11.0",
47
47
  "@wtfalch/email": "^0.17.0",
48
48
  "@wtfalch/providers": "0.7.5",
49
49
  "ssh2": "1.17.0",