@wtfalch/foundry 0.36.1 → 0.37.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.
@@ -15,6 +15,7 @@ import { type MailCopyPort } from './mail-copy.js';
15
15
  import { type OwnServicesPort } from './own-services.js';
16
16
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
17
17
  import type { EdgePortName } from './platform-org.js';
18
+ import type { RequestHeaderPort } from './platform-providers.js';
18
19
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
19
20
  /**
20
21
  * The Coolify calls this executor makes, shaped to match
@@ -1595,6 +1596,13 @@ export interface FoundryStepExecutorPorts {
1595
1596
  * `edge.tunnels` (see {@link EdgePort.tunnels}). Kept for a host or a test that supplies its own.
1596
1597
  */
1597
1598
  tunnels?: TunnelPort;
1599
+ /**
1600
+ * #745: the Cloudflare request header rule call, edge's `platform.requestHeaders`
1601
+ * (`@wtfalch/edge` 0.14.0). A company's sign-in hostname gets its header strip rule
1602
+ * through it before its DNS record; without it the step logs one fixed line and
1603
+ * writes no rule. Mail and SFU hostnames never use it.
1604
+ */
1605
+ requestHeaders?: RequestHeaderPort;
1598
1606
  /**
1599
1607
  * F4.access: overrides the Access calls, which otherwise go through
1600
1608
  * `edge.access` (see {@link EdgePort.access}). Kept for a host or a test that supplies its own.
@@ -5749,7 +5749,12 @@ export class FoundryStepExecutor {
5749
5749
  apex: hn.zone,
5750
5750
  gitRef,
5751
5751
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
5752
- network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:auth`),
5752
+ network: orgNetwork({
5753
+ dns: this.dnsPort(),
5754
+ tunnels: this.tunnelPort(),
5755
+ requestHeaders: this.ports.requestHeaders,
5756
+ zones: this.zonePort(),
5757
+ }, scope, `${org.slug}:auth`),
5753
5758
  smtpSender: `noreply@${hn.mailDomain}`,
5754
5759
  defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
5755
5760
  ownerCompany: null,
@@ -6,7 +6,7 @@ import type { DnsPort } from './edge-dns.js';
6
6
  import type { ZonePort } from './edge-zones.js';
7
7
  import type { CallsSfuPort, CloudflarePort, CoolifyPort, FilesOrganisationPort, FoundryStepExecutorPorts, JobsSchedulerPort, MailPort, ZitadelPort } from './executor.js';
8
8
  import type { PlatformStepExecutionContext, StepExecutionContext } from './plan-apply.js';
9
- import type { FoundryPlatformStepExecutorOptions, PlatformAuthDeps, PlatformDnsFactory, PlatformFilesDeps, PlatformMailDeps, PlatformTunnelPort, PlatformTunnelsFactory } from './platform-providers.js';
9
+ import type { FoundryPlatformStepExecutorOptions, PlatformAuthDeps, PlatformDnsFactory, PlatformFilesDeps, PlatformMailDeps, PlatformRequestHeadersFactory, PlatformTunnelPort, PlatformTunnelsFactory, RequestHeaderPort } from './platform-providers.js';
10
10
  /** Applied-state key under which an own org's provisioned-services marker is recorded. */
11
11
  export declare const OWN_SERVICES_APPLIED_SCOPE_KEY = "own_services_applied";
12
12
  /**
@@ -82,10 +82,13 @@ export interface ServiceNetwork {
82
82
  dns(): Promise<DnsPort>;
83
83
  tunnels(): Promise<PlatformTunnelPort>;
84
84
  zoneId(apex: string): Promise<string | undefined>;
85
+ /** Undefined when the host supplies no way to write Cloudflare request header rules. */
86
+ requestHeaders(): Promise<RequestHeaderPort | undefined>;
85
87
  }
86
88
  export declare function platformNetwork(deps: {
87
89
  dns: PlatformDnsFactory;
88
90
  tunnels: PlatformTunnelsFactory;
91
+ requestHeaders?: PlatformRequestHeadersFactory;
89
92
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
90
93
  }, scope: StepScope, step: string): ServiceNetwork;
91
94
  /**
@@ -96,6 +99,7 @@ export declare function platformNetwork(deps: {
96
99
  export declare function orgNetwork(ports: {
97
100
  dns?: DnsPort;
98
101
  tunnels?: PlatformTunnelPort;
102
+ requestHeaders?: RequestHeaderPort;
99
103
  zones?: Pick<ZonePort, 'findZoneByApex'>;
100
104
  }, scope: StepScope, step: string): ServiceNetwork;
101
105
  /** Where the project comes from: the platform's 'platform' project, or the org's own. */
@@ -1,5 +1,5 @@
1
1
  import { NO_DOMAIN_ZONE } from './own-hostnames.js';
2
- import { apexZoneKey, exactZone, platformDns, platformTunnels } from './platform-providers.js';
2
+ import { apexZoneKey, exactZone, platformDns, platformRequestHeaders, platformTunnels, } from './platform-providers.js';
3
3
  /** The key the org's zone step records its Cloudflare zone id under. */
4
4
  const ORG_ZONE_SCOPE_KEY = 'cloudflare_zone';
5
5
  /** Applied-state key under which an own org's provisioned-services marker is recorded. */
@@ -123,6 +123,7 @@ export function platformNetwork(deps, scope, step) {
123
123
  return {
124
124
  dns: () => platformDns(deps, scope.recordedIds(), step),
125
125
  tunnels: () => platformTunnels(deps, scope.recordedIds(), step),
126
+ requestHeaders: () => platformRequestHeaders(deps, scope.recordedIds()),
126
127
  zoneId: async (apex) => scope.recordedIds()[apexZoneKey(apex)] ?? (await exactZone(deps.cloudflare, apex))?.id,
127
128
  };
128
129
  }
@@ -143,6 +144,7 @@ export function orgNetwork(ports, scope, step) {
143
144
  throw new Error(`${step}: the host supplies no tunnel port for this org`);
144
145
  return ports.tunnels;
145
146
  },
147
+ requestHeaders: async () => ports.requestHeaders,
146
148
  zoneId: async (apex) => {
147
149
  // The recorded zone is the company's own; `oikos.host` is never it.
148
150
  const recorded = apex === NO_DOMAIN_ZONE ? undefined : scope.recordedIds()[ORG_ZONE_SCOPE_KEY];
@@ -59,6 +59,33 @@ export type PlatformTunnelPort = Pick<TunnelPort, 'findTunnelByName' | 'createTu
59
59
  export declare function platformTunnels(deps: {
60
60
  tunnels: PlatformTunnelsFactory;
61
61
  }, recorded: Readonly<Record<string, string>>, step: string): Promise<PlatformTunnelPort>;
62
+ /**
63
+ * Request headers a client can set that ZITADEL reads to pick or name an
64
+ * instance (auth#241, auth#252). Cloudflare removes them from every inbound
65
+ * request on a sign-in hostname, because the tunnel routes ZITADEL's paths
66
+ * straight to ZITADEL and no app code sees them first.
67
+ */
68
+ export declare const SIGNIN_STRIPPED_REQUEST_HEADERS: readonly ["X-Zitadel-Instance-Host", "X-Zitadel-Public-Host", "X-Zitadel-Forward-Host", "X-Forwarded-Host"];
69
+ /** One Cloudflare request-header Transform Rule (phase `http_request_late_transform`) that removes headers. */
70
+ export interface RequestHeaderStripRule {
71
+ /** Names the rule in the zone: the key that makes a repeat safe. */
72
+ description: string;
73
+ expression: string;
74
+ headers: readonly string[];
75
+ }
76
+ /** The Cloudflare request header rule calls a sign-in step makes. */
77
+ export interface RequestHeaderPort {
78
+ /** Makes the rule, or returns the one already made for that zone and description. */
79
+ ensureRequestHeaderStrip(zoneId: string, rule: RequestHeaderStripRule): Promise<{
80
+ id: string;
81
+ }>;
82
+ }
83
+ /** Like {@link PlatformTunnelsFactory}: builds the {@link RequestHeaderPort} at edge's recorded address. */
84
+ export type PlatformRequestHeadersFactory = (edgeUrl: string) => RequestHeaderPort | Promise<RequestHeaderPort>;
85
+ /** The request header port for a platform step; undefined when edge is not recorded or the host supplied no factory. */
86
+ export declare function platformRequestHeaders(deps: {
87
+ requestHeaders?: PlatformRequestHeadersFactory;
88
+ }, recorded: Readonly<Record<string, string>>): Promise<RequestHeaderPort | undefined>;
62
89
  /**
63
90
  * F4.access: builds the {@link AccessPort} a platform step finds, creates and
64
91
  * deletes its Access app through, given the same address as
@@ -110,6 +137,11 @@ export interface PlatformAuthDeps {
110
137
  dns: PlatformDnsFactory;
111
138
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
112
139
  tunnels: PlatformTunnelsFactory;
140
+ /**
141
+ * Request header rules through edge. Edge has no such call yet (@wtfalch/edge 0.13.0 offers
142
+ * rate limit rules only), so a host that leaves this out gets no rule and a logged line per run.
143
+ */
144
+ requestHeaders?: PlatformRequestHeadersFactory;
113
145
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
114
146
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
115
147
  zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp;
@@ -53,6 +53,23 @@ export async function platformTunnels(deps, recorded, step) {
53
53
  }
54
54
  return deps.tunnels(edgeUrl);
55
55
  }
56
+ /**
57
+ * Request headers a client can set that ZITADEL reads to pick or name an
58
+ * instance (auth#241, auth#252). Cloudflare removes them from every inbound
59
+ * request on a sign-in hostname, because the tunnel routes ZITADEL's paths
60
+ * straight to ZITADEL and no app code sees them first.
61
+ */
62
+ export const SIGNIN_STRIPPED_REQUEST_HEADERS = [
63
+ 'X-Zitadel-Instance-Host',
64
+ 'X-Zitadel-Public-Host',
65
+ 'X-Zitadel-Forward-Host',
66
+ 'X-Forwarded-Host',
67
+ ];
68
+ /** The request header port for a platform step; undefined when edge is not recorded or the host supplied no factory. */
69
+ export async function platformRequestHeaders(deps, recorded) {
70
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
71
+ return edgeUrl && deps.requestHeaders ? deps.requestHeaders(edgeUrl) : undefined;
72
+ }
56
73
  /** The Access port for a platform step; same address and same reason as {@link platformDns}. */
57
74
  async function platformAccess(deps, recorded, step) {
58
75
  const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
@@ -1701,6 +1718,18 @@ export async function provisionAuthStack(scope, deps, target, input) {
1701
1718
  if (!zoneId) {
1702
1719
  throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
1703
1720
  }
1721
+ // Before the DNS record, so the hostname is never published without the rule.
1722
+ const requestHeaders = await network.requestHeaders();
1723
+ if (requestHeaders) {
1724
+ await requestHeaders.ensureRequestHeaderStrip(zoneId, {
1725
+ description: `strip spoofable sign-in headers: ${hostname}`,
1726
+ expression: `(http.host eq "${hostname}")`,
1727
+ headers: SIGNIN_STRIPPED_REQUEST_HEADERS,
1728
+ });
1729
+ }
1730
+ else {
1731
+ console.error(`${label}: no request header port, so the sign-in hostname keeps X-Zitadel-* and X-Forwarded-Host from clients`);
1732
+ }
1704
1733
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1705
1734
  const dns = await network.dns();
1706
1735
  const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.36.1",
3
+ "version": "0.37.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",