@wtfalch/foundry 0.22.5 → 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.
@@ -11,7 +11,7 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
11
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
12
12
  import { waitForNewDeployment, } from './deploy-health.js';
13
13
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
14
- import { createEdgeAccess, } from './edge-access.js';
14
+ import { createEdgeAccess, isNotFound as isEdgeNotFound, } from './edge-access.js';
15
15
  import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
16
16
  import { createEdgeRateLimits, } from './edge-ratelimits.js';
17
17
  import { createEdgeTunnels } from './edge-tunnels.js';
@@ -19,6 +19,7 @@ import { createEdgeZones } from './edge-zones.js';
19
19
  import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
20
20
  import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
21
21
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
22
+ import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate } from './gates.js';
22
23
  import { healthCheckSettings } from './health-check-settings.js';
23
24
  import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.js';
24
25
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
@@ -184,18 +185,14 @@ function mailOrgFor(org, usesDomain, mode) {
184
185
  * value.
185
186
  */
186
187
  const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
187
- /** The one Access policy foundry puts on its own Access app (#84). */
188
+ /** The one Access policy foundry puts on a platform Access app (#84). */
188
189
  export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
189
190
  /**
190
191
  * The Access policy name for each `hosting.access` rule kind (#246),
191
192
  * `cloudflareAccess`'s way of telling its own policy apart from a foreign
192
- * one on the same app -- the same role {@link ACCESS_POLICY_NAME} plays for
193
- * the `app` entry's own email gate, which keeps that exact name so a
194
- * re-run against an org applied before #246 still recognises its own
195
- * policy.
193
+ * one on the same app.
196
194
  */
197
195
  const ACCESS_RULE_POLICY_NAMES = {
198
- gate: ACCESS_POLICY_NAME,
199
196
  bypass: 'foundry: bypass',
200
197
  'service-token': 'foundry: service token',
201
198
  };
@@ -721,13 +718,10 @@ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
721
718
  * redirect or 403.
722
719
  */
723
720
  function publicHealthCheck(org, recorded, hostname, path, options = {}) {
724
- const app = appEntry(org.hosting);
725
- const behindAccess = (org.hosting.access ?? []).some((rule) => rule.hostname === hostname) ||
726
- (app !== undefined &&
727
- app.accessEmails.length > 0 &&
728
- entryHostnames(org, app).includes(hostname));
729
- if (behindAccess)
721
+ if ((org.hosting.access ?? []).some((rule) => rule.hostname === hostname) ||
722
+ isBehindGate(org, hostname)) {
730
723
  return { kind: 'deployment-only' };
724
+ }
731
725
  const routed = (host) => !org.domain ||
732
726
  !(host === org.domain || host.endsWith(`.${org.domain}`)) ||
733
727
  (recorded[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY] ?? '').split(',').includes(host);
@@ -798,6 +792,8 @@ export class FoundryStepExecutor {
798
792
  return this.cloudflareZone(action, org, context);
799
793
  case 'cloudflare:access':
800
794
  return this.cloudflareAccess(action, org);
795
+ case 'cloudflare:gate':
796
+ return this.cloudflareGate(action, org, context);
801
797
  case 'cloudflare:ratelimit':
802
798
  return this.cloudflareRateLimit(org);
803
799
  case 'cloudflare:worker':
@@ -843,40 +839,27 @@ export class FoundryStepExecutor {
843
839
  }
844
840
  }
845
841
  /**
846
- * `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
847
- * behind a Cloudflare Access app. `action.key` tells the `app` entry's own
848
- * gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
849
- * any other hostname -- `appStepKey` builds both, keyed by the entry's key
850
- * or by the rule's hostname respectively, and the two never collide
851
- * because an entry key never holds a `.` while a rule's hostname always
852
- * does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
853
- * Access app name `foundry:<orgId>` it has always used, so a re-run
854
- * against an org applied before #246 still finds the app it already
855
- * created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
856
- * since an org may declare more than one. Either way foundry reuses only
857
- * an app with its own name (so a re-run after a crash does not duplicate
858
- * it), refuses to adopt a hostname another Access app already holds
859
- * (destroy would later delete it), and refuses when its own app carries
860
- * any policy but foundry's, since that policy could let others in.
842
+ * `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
843
+ * hostname behind a Cloudflare Access app named
844
+ * `foundry:<orgId>:<hostname>`, since an org may declare more than one.
845
+ * Foundry reuses only an app with its own name (so a re-run after a crash
846
+ * does not duplicate it), refuses to adopt a hostname another Access app
847
+ * already holds (destroy would later delete it), and refuses when its own
848
+ * app carries any policy but foundry's, since that policy could let others
849
+ * in.
861
850
  */
862
851
  async cloudflareAccess(action, org) {
863
- // Only the `app` entry has a public name (`planAccess`); per-entry
864
- // hostnames are issue 309.
865
- const entry = appEntry(org.hosting);
866
- if (!entry)
867
- throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
868
- if (action.key === appStepKey(org, entry.key)) {
852
+ const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
853
+ const owned = action.desired.ownedBypass;
854
+ if (!rule && owned && appStepKey(org, owned) === action.key) {
855
+ // A name foundry owns under a '*' gate: the same bypass a declared rule makes.
869
856
  return this.cloudflareAccessApp(org, {
870
- appName: `foundry:${org.id}`,
871
- domain: appHostname(org),
872
- policyName: ACCESS_POLICY_NAME,
873
- policy: {
874
- decision: 'allow',
875
- include: entry.accessEmails.map((email) => ({ email: { email } })),
876
- },
857
+ appName: `foundry:${org.id}:${owned}`,
858
+ domain: owned,
859
+ policyName: ACCESS_RULE_POLICY_NAMES.bypass,
860
+ policy: await this.accessPolicyFor(org, { kind: 'bypass', hostname: owned }),
877
861
  });
878
862
  }
879
- const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
880
863
  if (!rule) {
881
864
  throw new Error(`cloudflare:access for ${org.slug}: no hosting.access rule matches step key '${action.key}'`);
882
865
  }
@@ -887,14 +870,74 @@ export class FoundryStepExecutor {
887
870
  policy: await this.accessPolicyFor(org, rule),
888
871
  });
889
872
  }
873
+ /**
874
+ * `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
875
+ * gate through edge. A create needs the platform's recorded Access provider,
876
+ * the company's sign-in organisation and an owner with an email, and fails
877
+ * plainly without edge being called when one is missing. The create seeds
878
+ * the list of people with the owners' emails; nothing here sets people
879
+ * again. The gate id is recorded from the return value, the hostnames it
880
+ * has last been made with as a scope id.
881
+ */
882
+ async cloudflareGate(action, org, context) {
883
+ const gateKey = action.key.slice(org.id.length + 1);
884
+ const at = `cloudflare:gate for ${org.slug} gate '${gateKey}'`;
885
+ const desired = action.desired;
886
+ const recorded = context.recordedIds();
887
+ const gateId = recorded[gateIdRecordKey(org.id, gateKey)];
888
+ const hostnamesKey = gateHostnamesScopeKey(gateKey);
889
+ if (desired.mode === 'delete') {
890
+ if (gateId)
891
+ await this.accessPort().deleteGate(gateId);
892
+ if (context.forgetId) {
893
+ context.forgetId(gateIdRecordKey(org.id, gateKey));
894
+ context.forgetId(hostnamesKey);
895
+ }
896
+ return {};
897
+ }
898
+ const hostnames = desired.hostnames ?? [];
899
+ if (gateId) {
900
+ try {
901
+ await this.accessPort().replaceGateHostnames(gateId, hostnames);
902
+ }
903
+ catch (error) {
904
+ if (!isEdgeNotFound(error))
905
+ throw error;
906
+ // The gate is gone at edge: forget it, so the next plan creates it again.
907
+ context.forgetId?.(gateIdRecordKey(org.id, gateKey));
908
+ context.forgetId?.(hostnamesKey);
909
+ return {};
910
+ }
911
+ context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
912
+ return {};
913
+ }
914
+ if (!desired.providerId) {
915
+ throw new Error(`${at}: the platform's sign-in provider step has not been applied yet, so there is no Access provider to require`);
916
+ }
917
+ const claimValue = recorded[ZITADEL_ORG_SCOPE_KEY];
918
+ if (!claimValue) {
919
+ throw new Error(`${at}: ${org.slug} has no sign-in organisation recorded yet`);
920
+ }
921
+ const emails = org.owners.map((owner) => owner.email);
922
+ if (emails.length === 0) {
923
+ throw new Error(`${at}: ${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`);
924
+ }
925
+ const created = await this.accessPort().createGate({
926
+ organisationId: org.id,
927
+ key: gateKey,
928
+ entry: desired.entry,
929
+ hostnames,
930
+ providerId: desired.providerId,
931
+ claimName: GATE_CLAIM_NAME,
932
+ claimValue,
933
+ emails,
934
+ });
935
+ context.recordScopeId(hostnamesKey, JSON.stringify(hostnames));
936
+ return { resourceId: created.id };
937
+ }
890
938
  /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
891
939
  async accessPolicyFor(org, rule) {
892
940
  switch (rule.kind) {
893
- case 'gate':
894
- return {
895
- decision: 'allow',
896
- include: rule.emails.map((email) => ({ email: { email } })),
897
- };
898
941
  case 'bypass':
899
942
  // A bypass policy still needs an `include` rule to match against;
900
943
  // `everyone` is Cloudflare's own shape for "match every visitor".
@@ -5996,6 +6039,9 @@ export class FoundryStepExecutor {
5996
6039
  case 'cloudflare:access':
5997
6040
  await this.accessPort().deleteAccessApp(target.id);
5998
6041
  return;
6042
+ case 'cloudflare:gate':
6043
+ await this.accessPort().deleteGate(target.id);
6044
+ return;
5999
6045
  case 'ai:key': {
6000
6046
  // F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a
6001
6047
  // port with no `revokeKey` leaves it live until it expires.
@@ -0,0 +1,42 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ /**
4
+ * A company's declared gates (`HostingEntry.gates`) as Cloudflare Access gates
5
+ * made through edge. Foundry sets who passes once, on create, from the
6
+ * company's owners; it never writes the list of people again, so a company's
7
+ * admin app owns it from then on.
8
+ */
9
+ /**
10
+ * The platform's recorded id of the Access provider that signs people in
11
+ * through the platform's ZITADEL. The platform's sign-in provider step writes
12
+ * it; a gate is never made without it. A flat scope key, no `:`.
13
+ */
14
+ export declare const ACCESS_PROVIDER_RECORD_KEY = "access_provider_id";
15
+ /** The claim every gate requires: the company's ZITADEL organisation id, on every token ZITADEL issues. */
16
+ export declare const GATE_CLAIM_NAME = "urn:zitadel:iam:user:resourceowner:id";
17
+ /** The gate id edge returned, recorded by `runSteps` from the step's `resourceId`. */
18
+ export declare const gateIdRecordKey: (orgId: string, gateKey: string) => string;
19
+ /** The resolved, sorted hostnames the gate was last made or changed with, as JSON. */
20
+ export declare const gateHostnamesScopeKey: (gateKey: string) => string;
21
+ /** `'@'` is the domain, `'*'` is `*.<domain>`, a label is `<label>.<domain>`. Sorted. */
22
+ export declare function resolveGateHostnames(domain: string, names: readonly string[]): string[];
23
+ /** True when a declared gate resolves to `*.<domain>`. */
24
+ export declare function hasWildcardGate(org: DeclaredOrg): boolean;
25
+ /**
26
+ * True when a declared gate covers `hostname`, exactly or through a gated
27
+ * wildcard, unless a `bypass` rule names it exactly: that rule wins. A name
28
+ * foundry owns is never behind a gate: the plan bypasses it under a wildcard.
29
+ */
30
+ export declare function isBehindGate(org: DeclaredOrg, hostname: string): boolean;
31
+ /**
32
+ * One step per declared gate whose recorded id or hostnames differ from what
33
+ * is declared (a create, or a hostname change), and one destroying step per
34
+ * recorded gate no longer declared. `providerId` is the platform's recorded
35
+ * provider. A create needs it, the company's sign-in organisation and an
36
+ * owner with an email: when one is missing no create is planned and
37
+ * `blocked` says why (CLAUDE.md rule 2).
38
+ */
39
+ export declare function planGates(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, providerId: string | null): {
40
+ steps: DraftStep[];
41
+ blocked: string | null;
42
+ };
@@ -0,0 +1,126 @@
1
+ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
2
+ import { lockedHostnames } from './locked-hostnames.js';
3
+ import { appStepKey } from './platform-diff.js';
4
+ /**
5
+ * A company's declared gates (`HostingEntry.gates`) as Cloudflare Access gates
6
+ * made through edge. Foundry sets who passes once, on create, from the
7
+ * company's owners; it never writes the list of people again, so a company's
8
+ * admin app owns it from then on.
9
+ */
10
+ /**
11
+ * The platform's recorded id of the Access provider that signs people in
12
+ * through the platform's ZITADEL. The platform's sign-in provider step writes
13
+ * it; a gate is never made without it. A flat scope key, no `:`.
14
+ */
15
+ export const ACCESS_PROVIDER_RECORD_KEY = 'access_provider_id';
16
+ /** The claim every gate requires: the company's ZITADEL organisation id, on every token ZITADEL issues. */
17
+ export const GATE_CLAIM_NAME = 'urn:zitadel:iam:user:resourceowner:id';
18
+ /** The gate id edge returned, recorded by `runSteps` from the step's `resourceId`. */
19
+ export const gateIdRecordKey = (orgId, gateKey) => `cloudflare:gate:${orgId}:${gateKey}`;
20
+ /** The resolved, sorted hostnames the gate was last made or changed with, as JSON. */
21
+ export const gateHostnamesScopeKey = (gateKey) => `gate_hostnames_${gateKey}`;
22
+ /** `'@'` is the domain, `'*'` is `*.<domain>`, a label is `<label>.<domain>`. Sorted. */
23
+ export function resolveGateHostnames(domain, names) {
24
+ return names
25
+ .map((name) => (name === '@' ? domain : name === '*' ? `*.${domain}` : `${name}.${domain}`))
26
+ .sort();
27
+ }
28
+ /** Every declared gate's key with its entry's key and resolved hostnames; none without hosting or a domain. */
29
+ function declaredGates(org) {
30
+ const gates = new Map();
31
+ if (!org.hosting.enabled || !org.domain)
32
+ return gates;
33
+ for (const entry of org.hosting.entries) {
34
+ for (const gate of entry.gates) {
35
+ gates.set(gate.key, {
36
+ entry: entry.key,
37
+ hostnames: resolveGateHostnames(org.domain, gate.hostnames),
38
+ });
39
+ }
40
+ }
41
+ return gates;
42
+ }
43
+ /** True when a declared gate resolves to `*.<domain>`. */
44
+ export function hasWildcardGate(org) {
45
+ return [...declaredGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.')));
46
+ }
47
+ /**
48
+ * True when a declared gate covers `hostname`, exactly or through a gated
49
+ * wildcard, unless a `bypass` rule names it exactly: that rule wins. A name
50
+ * foundry owns is never behind a gate: the plan bypasses it under a wildcard.
51
+ */
52
+ export function isBehindGate(org, hostname) {
53
+ if (lockedHostnames(org, []).has(hostname))
54
+ return false;
55
+ if ((org.hosting.access ?? []).some((r) => r.kind === 'bypass' && r.hostname === hostname)) {
56
+ return false;
57
+ }
58
+ return [...declaredGates(org).values()].some(({ hostnames }) => hostnames.some((name) => name.startsWith('*.') ? hostname.endsWith(name.slice(1)) : name === hostname));
59
+ }
60
+ /**
61
+ * One step per declared gate whose recorded id or hostnames differ from what
62
+ * is declared (a create, or a hostname change), and one destroying step per
63
+ * recorded gate no longer declared. `providerId` is the platform's recorded
64
+ * provider. A create needs it, the company's sign-in organisation and an
65
+ * owner with an email: when one is missing no create is planned and
66
+ * `blocked` says why (CLAUDE.md rule 2).
67
+ */
68
+ export function planGates(org, recordedIds, providerId) {
69
+ const steps = [];
70
+ let blocked = null;
71
+ const missing = !providerId
72
+ ? "the platform's sign-in provider step has not been applied yet, so there is no Access provider to require"
73
+ : !recordedIds[ZITADEL_ORG_SCOPE_KEY]
74
+ ? `${org.slug} has no sign-in organisation recorded yet`
75
+ : org.owners.length === 0
76
+ ? `${org.slug} declares no owner with an email, and a gate nobody can pass is a mistake`
77
+ : null;
78
+ const declared = declaredGates(org);
79
+ const prefix = gateIdRecordKey(org.id, '');
80
+ for (const [name, id] of Object.entries(recordedIds)) {
81
+ if (!id || !name.startsWith(prefix))
82
+ continue;
83
+ const gateKey = name.slice(prefix.length);
84
+ if (declared.has(gateKey))
85
+ continue;
86
+ steps.push({
87
+ action: {
88
+ system: 'cloudflare',
89
+ kind: 'gate',
90
+ key: appStepKey(org, gateKey),
91
+ desired: { mode: 'delete' },
92
+ },
93
+ step: {
94
+ description: `delete ${org.slug}'s '${gateKey}' Cloudflare Access gate: it is no longer declared`,
95
+ destroys: true,
96
+ migrates: false,
97
+ },
98
+ });
99
+ }
100
+ for (const [gateKey, { entry, hostnames }] of declared) {
101
+ const recorded = recordedIds[gateIdRecordKey(org.id, gateKey)];
102
+ if (recorded && recordedIds[gateHostnamesScopeKey(gateKey)] === JSON.stringify(hostnames)) {
103
+ continue;
104
+ }
105
+ if (!recorded && missing) {
106
+ blocked = missing;
107
+ continue;
108
+ }
109
+ steps.push({
110
+ action: {
111
+ system: 'cloudflare',
112
+ kind: 'gate',
113
+ key: appStepKey(org, gateKey),
114
+ desired: { mode: 'apply', entry, hostnames, providerId },
115
+ },
116
+ step: {
117
+ description: recorded
118
+ ? `change the hostnames of ${org.slug}'s '${gateKey}' Cloudflare Access gate to ${hostnames.join(', ')}`
119
+ : `put ${hostnames.join(', ')} (${org.slug}'s '${gateKey}' gate) behind Cloudflare Access`,
120
+ destroys: false,
121
+ migrates: false,
122
+ },
123
+ });
124
+ }
125
+ return { steps, blocked };
126
+ }
@@ -1,7 +1,8 @@
1
- import { APP_ENTRY_KEY, appEntry } from '../org.js';
1
+ import { appEntry } from '../org.js';
2
2
  import { agoraHostnameFor } from './agora.js';
3
3
  import { bouleHostnameFor } from './boule.js';
4
4
  import { callsHostnameFor, cmsHostnameFor } from './executor.js';
5
+ import { isBehindGate } from './gates.js';
5
6
  import { entryHostnames } from './hostnames.js';
6
7
  import { loginHostnameFor } from './login-host.js';
7
8
  function okExpect() {
@@ -182,9 +183,7 @@ function hostingEntryTarget(org, entry) {
182
183
  expect: okExpect(),
183
184
  depth: entry.buildPack === 'static' ? 'page' : entry.database ? 'deep' : 'shallow',
184
185
  restartSafe: true,
185
- // Only the `app` entry may be gated behind Cloudflare Access at all
186
- // (`org.ts`'s own `HostingEntry.accessEmails` doc comment).
187
- behindAccess: entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0,
186
+ behindAccess: isBehindGate(org, hostname),
188
187
  };
189
188
  }
190
189
  /**
@@ -216,6 +216,8 @@ export interface PlatformStepExecutor {
216
216
  ai?: string;
217
217
  archon?: string;
218
218
  mail?: string;
219
+ /** #604: see `PlanPlatformOptions.blocked.accessProvider` (`platform-diff.ts`). */
220
+ accessProvider?: string;
219
221
  /** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
220
222
  backupExecutorMissing?: boolean;
221
223
  /** #540: see `PlanPlatformOptions.blocked.hc` (`platform-diff.ts`). */
@@ -98,6 +98,7 @@ const HOSTNAME_STEPS = new Set([
98
98
  'coolify:database',
99
99
  'cloudflare:tunnel',
100
100
  'cloudflare:access',
101
+ 'cloudflare:gate',
101
102
  'jobs:registration',
102
103
  // #318 (F8): the route is itself hostname-shaped, validated against the
103
104
  // org's own domain by `validateEntryWorker`.
@@ -1149,6 +1150,7 @@ function destroyTargets(serviceIds, orgId) {
1149
1150
  'zitadel:login-host:',
1150
1151
  'cloudflare:tunnel:',
1151
1152
  'cloudflare:access:',
1153
+ 'cloudflare:gate:',
1152
1154
  // #318: an entry's Worker and its route.
1153
1155
  'cloudflare:worker:',
1154
1156
  'coolify:database:',
@@ -1311,7 +1313,8 @@ const STEP_FIELDS = {
1311
1313
  'coolify:database': 'hosting.database',
1312
1314
  'cloudflare:tunnel': 'hosting.tunnel',
1313
1315
  'cloudflare:zone': 'hosting.usesDomain',
1314
- 'cloudflare:access': 'hosting.accessEmails',
1316
+ 'cloudflare:access': 'hosting.access',
1317
+ 'cloudflare:gate': 'hosting.entries.gates',
1315
1318
  'cloudflare:ratelimit': 'hosting.usesDomain',
1316
1319
  'cloudflare:worker': 'hosting.entries.worker',
1317
1320
  };
@@ -1330,6 +1333,10 @@ function stepField(action) {
1330
1333
  if (action.system === 'zitadel' && action.kind === 'app') {
1331
1334
  return `hosting.issuerApps.${action.key}`;
1332
1335
  }
1336
+ // A bypass on a name foundry owns serves the entry gates, not `hosting.access`.
1337
+ if (action.system === 'cloudflare' && action.kind === 'access' && action.desired.ownedBypass) {
1338
+ return 'hosting.entries.gates';
1339
+ }
1333
1340
  return STEP_FIELDS[`${action.system}:${action.kind}`] ?? `${action.system}.${action.kind}`;
1334
1341
  }
1335
1342
  /**
@@ -694,6 +694,18 @@ export declare const PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF = "cloudflare:edge:apiTo
694
694
  * reports it and never mints again. Neither key holds the secret itself.
695
695
  */
696
696
  export declare const PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = "edge_platform_key_undelivered";
697
+ /**
698
+ * #604: the ZITADEL client Cloudflare Access signs people in through. Its
699
+ * secret exists once, at creation, and goes to edge's `ensureProvider`. The
700
+ * client's app id is recorded here and persisted before the secret leaves;
701
+ * the guard, `ACCESS_PROVIDER_RECORD_KEY` (`gates.ts`), is recorded only
702
+ * after edge returned the provider's id. An undelivered id with no guard
703
+ * means the secret is lost: the planner reports it and never mints again.
704
+ */
705
+ export declare const PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY = "access_provider_client_undelivered";
706
+ /** The sign-in hostname and the Access team address the provider was applied with. */
707
+ export declare const PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY = "access_provider_applied_hostname";
708
+ export declare const PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY = "access_provider_applied_auth_domain";
697
709
  export declare const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = "edge_platform_key_set";
698
710
  /**
699
711
  * #478: the hostname and `gitRef` the last successful names run applied.
@@ -850,6 +862,8 @@ export interface PlanPlatformOptions {
850
862
  mail?: string;
851
863
  /** #478: why the edge host is blocked, or absent when it is not. */
852
864
  edge?: string;
865
+ /** #604: why the Access sign-in provider cannot be made yet (no dependencies, or edge cannot confirm Access is ready), or absent. */
866
+ accessProvider?: string;
853
867
  /**
854
868
  * #257: true once a service's database exists but the host built the
855
869
  * platform executor with no backup dependencies -- every such service's
@@ -1,6 +1,7 @@
1
1
  import { VERSIONS } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY } from '../org.js';
3
3
  import { VERSION_CHANGE_KIND, } from '../plan.js';
4
+ import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
4
5
  import { legacyAppHostname } from './hostnames.js';
5
6
  import { withStepSystem } from './step-system.js';
6
7
  /**
@@ -771,6 +772,18 @@ export const PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF = 'cloudflare:edge:apiToken';
771
772
  * reports it and never mints again. Neither key holds the secret itself.
772
773
  */
773
774
  export const PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = 'edge_platform_key_undelivered';
775
+ /**
776
+ * #604: the ZITADEL client Cloudflare Access signs people in through. Its
777
+ * secret exists once, at creation, and goes to edge's `ensureProvider`. The
778
+ * client's app id is recorded here and persisted before the secret leaves;
779
+ * the guard, `ACCESS_PROVIDER_RECORD_KEY` (`gates.ts`), is recorded only
780
+ * after edge returned the provider's id. An undelivered id with no guard
781
+ * means the secret is lost: the planner reports it and never mints again.
782
+ */
783
+ export const PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY = 'access_provider_client_undelivered';
784
+ /** The sign-in hostname and the Access team address the provider was applied with. */
785
+ export const PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY = 'access_provider_applied_hostname';
786
+ export const PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY = 'access_provider_applied_auth_domain';
774
787
  export const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = 'edge_platform_key_set';
775
788
  /**
776
789
  * #478: the hostname and `gitRef` the last successful names run applied.
@@ -999,6 +1012,11 @@ export function planPlatform(platform, live, options = {}) {
999
1012
  const ai = planAi(platform, recordedIds, blocked.ai);
1000
1013
  if (ai.step)
1001
1014
  plannedSteps.push(ai.step);
1015
+ // #604: after edge and sign-in, whose applied ids it reads; before Archon,
1016
+ // whose redeploy can end the run.
1017
+ const accessProvider = planAccessProvider(platform, recordedIds, blocked.accessProvider);
1018
+ if (accessProvider.step)
1019
+ plannedSteps.push(accessProvider.step);
1002
1020
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
1003
1021
  // `planAuth` builds, and its env reads what every other service's step
1004
1022
  // records or delivers, so in one apply it runs after all of them.
@@ -1081,6 +1099,9 @@ export function planPlatform(platform, live, options = {}) {
1081
1099
  unmanaged.push({ description: ai.blocked, key: 'ai' });
1082
1100
  if (edge.blocked)
1083
1101
  unmanaged.push({ description: edge.blocked, key: 'edge' });
1102
+ if (accessProvider.blocked) {
1103
+ unmanaged.push({ description: accessProvider.blocked, key: 'access-provider' });
1104
+ }
1084
1105
  if (hc.blocked)
1085
1106
  unmanaged.push({ description: hc.blocked, key: 'hc' });
1086
1107
  if (archon.blocked)
@@ -2027,6 +2048,63 @@ function planEdge(platform, recordedIds, executorBlocked) {
2027
2048
  blocked: null,
2028
2049
  };
2029
2050
  }
2051
+ /**
2052
+ * #604: the platform's ZITADEL as a sign-in provider in Cloudflare Access: a
2053
+ * ZITADEL client for Cloudflare, then edge's `ensureProvider`. One
2054
+ * `cloudflare:access-provider` step. It needs edge and the platform's sign-in
2055
+ * applied (it reads the Access team address through the one and makes the
2056
+ * client in the other). Done once `ACCESS_PROVIDER_RECORD_KEY` is recorded.
2057
+ *
2058
+ * Blocked, not planned: edge or sign-in not applied, a client minted but never
2059
+ * delivered (its secret cannot be shown again), or `executorBlocked` (no
2060
+ * dependencies, or edge cannot confirm Access is ready).
2061
+ */
2062
+ function planAccessProvider(platform, recordedIds, executorBlocked) {
2063
+ if (!platform.edge.enabled || !platform.auth.enabled)
2064
+ return { step: null, blocked: null };
2065
+ if (recordedIds[ACCESS_PROVIDER_RECORD_KEY]) {
2066
+ // The secret is not stored, so a changed address cannot be re-sent: a person remakes the client.
2067
+ const appliedHostname = recordedIds[PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY];
2068
+ if (appliedHostname && appliedHostname !== platform.auth.hostname) {
2069
+ return {
2070
+ step: null,
2071
+ blocked: `the Access sign-in provider is blocked, not planned: it was applied with the sign-in hostname ${appliedHostname} and ${platform.auth.hostname} is declared now; its secret is not stored, so delete the ZITADEL client cloudflare-access, remove the recorded keys ${ACCESS_PROVIDER_RECORD_KEY}, ${PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY} and ${PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY}, then apply again`,
2072
+ };
2073
+ }
2074
+ return { step: null, blocked: null };
2075
+ }
2076
+ const undelivered = recordedIds[PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY];
2077
+ if (undelivered) {
2078
+ return {
2079
+ step: null,
2080
+ blocked: `the Access sign-in provider is blocked, not planned: its ZITADEL client (${undelivered}) was made and its secret may or may not have reached edge; it cannot be shown again, so delete that client in ZITADEL and remove the recorded keys ${PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY} and ${ACCESS_PROVIDER_RECORD_KEY}, then apply again to make both anew`,
2081
+ };
2082
+ }
2083
+ const applied = Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) &&
2084
+ Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]) &&
2085
+ // The sibling ZITADEL steps need the delivered admin key (or the instance it came from).
2086
+ (Boolean(recordedIds[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) ||
2087
+ Boolean(recordedIds[PLATFORM_AUTH_INSTANCE_SCOPE_KEY]));
2088
+ if (!applied) {
2089
+ return {
2090
+ step: null,
2091
+ blocked: 'the Access sign-in provider is blocked, not planned: edge and the platform sign-in are not both applied yet',
2092
+ };
2093
+ }
2094
+ if (executorBlocked)
2095
+ return { step: null, blocked: executorBlocked };
2096
+ return {
2097
+ step: {
2098
+ action: { system: 'cloudflare', kind: 'access-provider', key: 'platform', desired: {} },
2099
+ step: {
2100
+ description: "make the platform's ZITADEL the sign-in provider in Cloudflare Access, for company gates",
2101
+ destroys: false,
2102
+ migrates: false,
2103
+ },
2104
+ },
2105
+ blocked: null,
2106
+ };
2107
+ }
2030
2108
  /**
2031
2109
  * #540: a self-hosted heartbeat server (Healthchecks) for the platform, for
2032
2110
  * now -- a Coolify Postgres plus one Docker-image app
@@ -87,6 +87,8 @@ export interface OrgPlatformReadiness {
87
87
  callsReady: boolean;
88
88
  /** The platform's own sign-in (#168), which every org's own ZITADEL org lives inside. */
89
89
  authReady: boolean;
90
+ /** The Access provider id the platform recorded (`ACCESS_PROVIDER_RECORD_KEY`), which every gate requires. `null` or absent until the platform has recorded it. */
91
+ accessProviderId?: string | null;
90
92
  /** The jobs scheduler (#173) is enabled, applied, has recorded its internal address, and its admin token is held. */
91
93
  jobsReady: boolean;
92
94
  /**
@@ -1,3 +1,4 @@
1
+ import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
1
2
  import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
2
3
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
3
4
  /**
@@ -129,6 +130,9 @@ edgePortPresent = false) {
129
130
  aiReady: aiApplied && missingKeys.ai === null,
130
131
  aiUrl: aiApplied ? (platformRecordedIds[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] ?? null) : null,
131
132
  callsReady: platform.sfu.enabled,
133
+ ...(platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY]
134
+ ? { accessProviderId: platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY] }
135
+ : {}),
132
136
  authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
133
137
  jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
134
138
  ...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),