@wtfalch/foundry 0.23.0 → 0.25.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.
@@ -449,6 +449,19 @@ export interface WorkersRateLimitBinding {
449
449
  period: number;
450
450
  };
451
451
  }
452
+ /** A Workers KV namespace binding (edge 0.11.0): edge reuses the namespace titled `title` or creates it, and deletes one it created with the script. */
453
+ export interface WorkersKvBinding {
454
+ type: 'kv_namespace';
455
+ name: string;
456
+ title: string;
457
+ }
458
+ /** A plain text variable binding (edge 0.11.0). Never for a secret: those go through {@link PlatformWorkersPort.setSecrets}. */
459
+ export interface WorkersPlainTextBinding {
460
+ type: 'plain_text';
461
+ name: string;
462
+ text: string;
463
+ }
464
+ export type WorkersBinding = WorkersRateLimitBinding | WorkersKvBinding | WorkersPlainTextBinding;
452
465
  /**
453
466
  * The Workers calls `cloudflareWorker` makes (#318, F8), served by edge's
454
467
  * `platform.workers` through `createEdgeWorkers` (`edge-workers.ts`): deploys
@@ -484,7 +497,7 @@ export interface WorkersPort {
484
497
  artifactUrl: string;
485
498
  artifactSha256: string;
486
499
  compatibilityDate: string;
487
- bindings?: WorkersRateLimitBinding[];
500
+ bindings?: WorkersBinding[];
488
501
  }): Promise<void>;
489
502
  /** The script name currently bound to `pattern` in `zoneId`, or `undefined` for no route there. */
490
503
  findRoute(zoneId: string, pattern: string): Promise<{
@@ -747,6 +760,23 @@ export interface ZitadelPort {
747
760
  clientId: string;
748
761
  clientSecret: string;
749
762
  }>;
763
+ /**
764
+ * #604: a web client that signs in with a client secret (authorization
765
+ * code, `OIDC_AUTH_METHOD_TYPE_BASIC`), which Cloudflare Access needs.
766
+ * `createOidcApp` makes a public PKCE client with no secret, and
767
+ * `@wtfalch/providers` 0.7.5 has no call that makes this one, so the
768
+ * method is optional until it does; the platform's Access provider step
769
+ * is blocked without it. Like `createApiApp`, the secret is shown once.
770
+ */
771
+ createOidcAppWithSecret?(orgId: string, projectId: string, input: {
772
+ name: string;
773
+ redirectUris: string[];
774
+ postLogoutRedirectUris: string[];
775
+ }): Promise<{
776
+ appId: string;
777
+ clientId: string;
778
+ clientSecret: string;
779
+ }>;
750
780
  deleteApp(orgId: string, projectId: string, appId: string): Promise<void>;
751
781
  /**
752
782
  * `zitadelApp`'s kind-change path reads `apps` to find an app a prior,
@@ -1506,7 +1536,7 @@ export declare function callsHostnameFor(org: DeclaredOrg): string;
1506
1536
  * this file's hostname rules.
1507
1537
  */
1508
1538
  export declare function healthUrls(org: DeclaredOrg): string[];
1509
- /** The one Access policy foundry puts on its own Access app (#84). */
1539
+ /** The one Access policy foundry puts on a platform Access app (#84). */
1510
1540
  export declare const ACCESS_POLICY_NAME = "foundry: allowed emails";
1511
1541
  /**
1512
1542
  * Whether an application's own Coolify `status` (`` `running:healthy`,
@@ -1562,23 +1592,26 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1562
1592
  resourceId?: string;
1563
1593
  } | undefined>;
1564
1594
  /**
1565
- * `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
1566
- * behind a Cloudflare Access app. `action.key` tells the `app` entry's own
1567
- * gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
1568
- * any other hostname -- `appStepKey` builds both, keyed by the entry's key
1569
- * or by the rule's hostname respectively, and the two never collide
1570
- * because an entry key never holds a `.` while a rule's hostname always
1571
- * does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
1572
- * Access app name `foundry:<orgId>` it has always used, so a re-run
1573
- * against an org applied before #246 still finds the app it already
1574
- * created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
1575
- * since an org may declare more than one. Either way foundry reuses only
1576
- * an app with its own name (so a re-run after a crash does not duplicate
1577
- * it), refuses to adopt a hostname another Access app already holds
1578
- * (destroy would later delete it), and refuses when its own app carries
1579
- * any policy but foundry's, since that policy could let others in.
1595
+ * `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
1596
+ * hostname behind a Cloudflare Access app named
1597
+ * `foundry:<orgId>:<hostname>`, since an org may declare more than one.
1598
+ * Foundry reuses only an app with its own name (so a re-run after a crash
1599
+ * does not duplicate it), refuses to adopt a hostname another Access app
1600
+ * already holds (destroy would later delete it), and refuses when its own
1601
+ * app carries any policy but foundry's, since that policy could let others
1602
+ * in.
1580
1603
  */
1581
1604
  private cloudflareAccess;
1605
+ /**
1606
+ * `plan/diff.ts`'s `planGates`: makes, changes or deletes one declared
1607
+ * gate through edge. A create needs the platform's recorded Access provider,
1608
+ * the company's sign-in organisation and an owner with an email, and fails
1609
+ * plainly without edge being called when one is missing. The create seeds
1610
+ * the list of people with the owners' emails; nothing here sets people
1611
+ * again. The gate id is recorded from the return value, the hostnames it
1612
+ * has last been made with as a scope id.
1613
+ */
1614
+ private cloudflareGate;
1582
1615
  /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
1583
1616
  private accessPolicyFor;
1584
1617
  /**
@@ -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
  /**
@@ -46,7 +46,8 @@ export type { DnsPort, DnsRecordInput, DnsRecordSummary, EdgePlatformDns } from
46
46
  export { createEdgeAccess } from './edge-access.js';
47
47
  export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
48
48
  export { createEdgeWorkers } from './edge-workers.js';
49
- export type { EdgePlatformWorkers, EdgeWorkersOptions } from './edge-workers.js';
49
+ export type { EdgePlatformWorkers, EdgeWorkersOptions, PlatformWorkersPort, } from './edge-workers.js';
50
+ export type { PlatformUptimeAccessFactory, PlatformUptimeAccessPort, PlatformUptimeDeps, PlatformWorkersFactory, } from './uptime-worker.js';
50
51
  export { createEdgeRateLimits } from './edge-ratelimits.js';
51
52
  export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
52
53
  export { createEdgeZones } from './edge-zones.js';
@@ -216,12 +216,18 @@ 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`). */
222
224
  hc?: string;
223
225
  /** #540: see `PlanPlatformOptions.blocked.heartbeatsExecutorMissing` (`platform-diff.ts`). */
224
226
  heartbeatsExecutorMissing?: boolean;
227
+ /** foundry#542: see `PlanPlatformOptions.blocked.uptime` (`platform-diff.ts`). */
228
+ uptime?: string;
229
+ /** foundry#542: see `PlanPlatformOptions.blocked.uptimeSecretsHeld` (`platform-diff.ts`). */
230
+ uptimeSecretsHeld?: string[];
225
231
  /** See `PlanPlatformOptions.blocked.archonRunsInsideApp`. */
226
232
  archonRunsInsideApp?: boolean;
227
233
  /** See `PlanPlatformOptions.blocked.archonDeployRunning`. */
@@ -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
  /**