@wtfalch/foundry 0.36.0 → 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.
@@ -34,10 +34,16 @@ export declare const DIGESTS: PinnedVersions;
34
34
  */
35
35
  export declare function pinnedRef(key: keyof PinnedVersions): string;
36
36
  /**
37
- * A declared tag that equals the pinned one gets its digest; any other
38
- * declared tag (an override) is passed through unchanged.
37
+ * The two image fields Coolify accepts. Its tag rule has no `@` or `:`, so a
38
+ * digest goes in the name as `<image>@sha256` and the tag holds the bare
39
+ * 64-hex digest (Coolify pulls `<name>:<tag>`). A declared tag that equals the
40
+ * pinned one gets its digest; any other declared tag (an override) is passed
41
+ * through unchanged.
39
42
  */
40
- export declare function pinnedTagFor(key: keyof PinnedVersions, tag: string): string;
43
+ export declare function coolifyImageFor(key: keyof PinnedVersions, image: string, tag: string): {
44
+ docker_registry_image_name: string;
45
+ docker_registry_image_tag: string;
46
+ };
41
47
  /**
42
48
  * Why an image tag cannot be used, or `null`. #248: generalised out of what
43
49
  * was `platform-providers.ts`'s `mailImageTagBlocked`, the one guard
@@ -41,11 +41,20 @@ export function pinnedRef(key) {
41
41
  return `${VERSIONS[key]}@${DIGESTS[key]}`;
42
42
  }
43
43
  /**
44
- * A declared tag that equals the pinned one gets its digest; any other
45
- * declared tag (an override) is passed through unchanged.
44
+ * The two image fields Coolify accepts. Its tag rule has no `@` or `:`, so a
45
+ * digest goes in the name as `<image>@sha256` and the tag holds the bare
46
+ * 64-hex digest (Coolify pulls `<name>:<tag>`). A declared tag that equals the
47
+ * pinned one gets its digest; any other declared tag (an override) is passed
48
+ * through unchanged.
46
49
  */
47
- export function pinnedTagFor(key, tag) {
48
- return tag === VERSIONS[key] ? pinnedRef(key) : tag;
50
+ export function coolifyImageFor(key, image, tag) {
51
+ if (tag !== VERSIONS[key]) {
52
+ return { docker_registry_image_name: image, docker_registry_image_tag: tag };
53
+ }
54
+ return {
55
+ docker_registry_image_name: `${image}@sha256`,
56
+ docker_registry_image_tag: DIGESTS[key].replace(/^sha256:/, ''),
57
+ };
49
58
  }
50
59
  /**
51
60
  * Tag spellings that resolve to whatever the registry currently has, not a
@@ -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.
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomBytes, randomUUID } from 'node:crypto';
3
3
  import { URL } from 'node:url';
4
- import { pinnedTagFor } from '../bootstrap/versions.js';
4
+ import { coolifyImageFor } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
7
7
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, ownsServices, sharedSecretRef, } from '../org.js';
@@ -3870,8 +3870,7 @@ export class FoundryStepExecutor {
3870
3870
  server_uuid: server.uuid,
3871
3871
  environment_name: environment.name,
3872
3872
  environment_uuid: environment.uuid,
3873
- docker_registry_image_name: SYNAPSE_IMAGE,
3874
- docker_registry_image_tag: pinnedTagFor('synapse', synapseTag),
3873
+ ...coolifyImageFor('synapse', SYNAPSE_IMAGE, synapseTag),
3875
3874
  ports_exposes: '8008',
3876
3875
  name: names.synapse,
3877
3876
  description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${synapseTag})`,
@@ -5750,7 +5749,12 @@ export class FoundryStepExecutor {
5750
5749
  apex: hn.zone,
5751
5750
  gitRef,
5752
5751
  project: { ensure: () => this.ensureCoolifyProject(org, context) },
5753
- 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`),
5754
5758
  smtpSender: `noreply@${hn.mailDomain}`,
5755
5759
  defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
5756
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];
@@ -1,4 +1,4 @@
1
- import { pinnedTagFor } from '../bootstrap/versions.js';
1
+ import { coolifyImageFor } from '../bootstrap/versions.js';
2
2
  import { mintLivekitCredentials } from '../calls-sfu-keys.js';
3
3
  import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
4
4
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
@@ -105,8 +105,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
105
105
  server_uuid: server.uuid,
106
106
  environment_name: environment.name,
107
107
  environment_uuid: environment.uuid,
108
- docker_registry_image_name: OWN_SFU_IMAGE,
109
- docker_registry_image_tag: pinnedTagFor('livekit', input.version),
108
+ ...coolifyImageFor('livekit', OWN_SFU_IMAGE, input.version),
110
109
  ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
111
110
  ports_mappings: sfuPortsMappings(input.slot),
112
111
  name: target.names.sfuApp,
@@ -122,7 +121,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
122
121
  // Set on every run: a declared version or slot reaches an existing app here.
123
122
  await coolify.updateApplication(appId, {
124
123
  custom_internal_name: target.internal.sfu,
125
- docker_registry_image_tag: pinnedTagFor('livekit', input.version),
124
+ ...coolifyImageFor('livekit', OWN_SFU_IMAGE, input.version),
126
125
  ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
127
126
  ports_mappings: sfuPortsMappings(input.slot),
128
127
  ...healthCheckSettings('/', LIVEKIT_SIGNALLING_PORT),
@@ -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;
@@ -1,7 +1,7 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
3
  import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
4
- import { VERSIONS, floatingTagBlocked, pinnedRef, pinnedTagFor } from '../bootstrap/versions.js';
4
+ import { VERSIONS, coolifyImageFor, floatingTagBlocked, pinnedRef } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ownerMailboxes } from '../org.js';
7
7
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
@@ -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');
@@ -2539,8 +2568,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2539
2568
  server_uuid: server.uuid,
2540
2569
  environment_name: environment.name,
2541
2570
  environment_uuid: environment.uuid,
2542
- docker_registry_image_name: STALWART_IMAGE,
2543
- docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2571
+ ...coolifyImageFor('stalwart', STALWART_IMAGE, imageTag),
2544
2572
  ports_exposes: STALWART_PORTS_EXPOSES,
2545
2573
  ...(hostPorts ? { ports_mappings: hostPorts } : {}),
2546
2574
  name: target.names.mailApp,
@@ -2560,7 +2588,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2560
2588
  // `'ready'` probe below. Stalwart's own readiness route and HTTP port.
2561
2589
  await deps.coolify.updateApplication(appId, {
2562
2590
  custom_internal_name: target.internal.mail,
2563
- docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2591
+ ...coolifyImageFor('stalwart', STALWART_IMAGE, imageTag),
2564
2592
  ...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
2565
2593
  // Only a platform that uses the shared relay, or one that did, changes a published port.
2566
2594
  ...(portsChanged ? { ports_mappings: hostPorts } : {}),
@@ -7263,8 +7291,7 @@ async function provisionPlatformHc(context, deps) {
7263
7291
  server_uuid: server.uuid,
7264
7292
  environment_name: environment.name,
7265
7293
  environment_uuid: environment.uuid,
7266
- docker_registry_image_name: HC_IMAGE,
7267
- docker_registry_image_tag: pinnedTagFor('hc', gitRef),
7294
+ ...coolifyImageFor('hc', HC_IMAGE, gitRef),
7268
7295
  ports_exposes: HC_PORT,
7269
7296
  name: PLATFORM_HC_APP_NAME,
7270
7297
  description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
@@ -7277,7 +7304,7 @@ async function provisionPlatformHc(context, deps) {
7277
7304
  // own rule. The image tag too, so a declared release change reaches the app.
7278
7305
  await deps.coolify.updateApplication(appId, {
7279
7306
  custom_internal_name: HC_INTERNAL_NAME,
7280
- docker_registry_image_tag: pinnedTagFor('hc', gitRef),
7307
+ ...coolifyImageFor('hc', HC_IMAGE, gitRef),
7281
7308
  ...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
7282
7309
  });
7283
7310
  // ---- env, parsed off the database's own owner credentials -----------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.36.0",
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",