@wtfalch/foundry 0.36.1 → 0.38.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.
@@ -7,6 +7,7 @@ import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
7
7
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
8
8
  import { legacyAppHostname } from './hostnames.js';
9
9
  import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
10
+ import { MAIL_DATABASE_SCOPE_KEY, mailStoreIsLegacy, mailStoreSwitchChange, mailStoreSwitchDescription, } from './mail-store.js';
10
11
  import { planOikosIngress } from './oikos-ingress.js';
11
12
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
12
13
  import { signInDomainBlockedText } from './sign-in-org.js';
@@ -1155,6 +1156,28 @@ export const PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY = 'uptime_applied_secrets
1155
1156
  export const PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY = 'uptime_applied_vars';
1156
1157
  /** `'1'` once the step removed the Worker because the platform no longer enables it; cleared when it deploys again. */
1157
1158
  export const PLATFORM_UPTIME_REMOVED_SCOPE_KEY = 'uptime_removed';
1159
+ /**
1160
+ * The chat bot's room (`!id:server`), recorded as soon as chat-service names it.
1161
+ * Recorded before the bot's token is held, so a crash between the two leaves the room known.
1162
+ */
1163
+ export const PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY = 'platform_uptime_chat_room_id';
1164
+ /**
1165
+ * A random id, recorded and persisted before the chat route is called (it mints the bot's token) and
1166
+ * cleared once that token is held. Set while the token is not held, it means a token was minted that may
1167
+ * be lost, so the next call asks the route to rotate it.
1168
+ */
1169
+ export const PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY = 'platform_uptime_chat_token_undelivered';
1170
+ /**
1171
+ * The Worker's plain text variables: the chat homeserver and room foundry made, under whatever the
1172
+ * host configured (a host value that is set wins). Unset values are left out.
1173
+ */
1174
+ export function uptimeEffectiveVars(hostVars, chat) {
1175
+ const host = Object.fromEntries(Object.entries(hostVars ?? {}).filter(([, v]) => Boolean(v)));
1176
+ return {
1177
+ ...(chat ? { CHAT_HOMESERVER_URL: chat.homeserverUrl, CHAT_ROOM_ID: chat.roomId } : {}),
1178
+ ...host,
1179
+ };
1180
+ }
1158
1181
  /** The digest of the Worker's plain text variables that are set; empty when none is. */
1159
1182
  export function uptimeVarsDigest(vars = {}) {
1160
1183
  const set = Object.entries(vars)
@@ -1332,7 +1355,7 @@ export function planPlatform(platform, live, options = {}) {
1332
1355
  plannedSteps.push(heartbeats.step);
1333
1356
  // foundry#542: after Archon is applied (never merely planned): the Worker
1334
1357
  // probes Archon, and Archon's env takes the token this step mints.
1335
- const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld);
1358
+ const uptime = planUptime(platform, recordedIds, blocked.uptime, blocked.uptimeSecretsHeld ?? [], Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_ARCHON_KEY)]), blocked.uptimeHeld, blocked.uptimeChat);
1336
1359
  if (uptime.step)
1337
1360
  plannedSteps.push(uptime.step);
1338
1361
  if (archon.step && blocked.archonRunsInsideApp)
@@ -1353,6 +1376,9 @@ export function planPlatform(platform, live, options = {}) {
1353
1376
  // read for, is premature, not informational.
1354
1377
  const versionChanges = [];
1355
1378
  for (const change of [
1379
+ mail.blocked || !mail.step?.action.desired.switchMailStore
1380
+ ? null
1381
+ : mailStoreSwitchChange('the platform'),
1356
1382
  mail.blocked
1357
1383
  ? null
1358
1384
  : versionChangeLine('mail', recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY], platform.mail.gitRef, 'image, redeploy'),
@@ -1593,11 +1619,13 @@ export const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS = {
1593
1619
  archon: 'coolify_platform_archon_database',
1594
1620
  ai: 'coolify_platform_ai_database',
1595
1621
  integrations: 'coolify_integrations_database',
1622
+ mail: MAIL_DATABASE_SCOPE_KEY,
1596
1623
  };
1597
1624
  export const PLATFORM_BACKUP_SERVICE_KEYS = [
1598
1625
  'archon',
1599
1626
  'ai',
1600
1627
  'integrations',
1628
+ 'mail',
1601
1629
  ];
1602
1630
  /**
1603
1631
  * #257: a nightly backup for each platform database foundry itself creates,
@@ -2047,7 +2075,9 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
2047
2075
  if (!platform.mail.enabled)
2048
2076
  return { step: null, blocked: null };
2049
2077
  const key = resourceKey('coolify', 'app', PLATFORM_MAIL_KEY);
2050
- if (recordedIds[key] &&
2078
+ const storeSwitch = mailStoreIsLegacy(recordedIds);
2079
+ if (!storeSwitch &&
2080
+ recordedIds[key] &&
2051
2081
  recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
2052
2082
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
2053
2083
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
@@ -2083,6 +2113,28 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
2083
2113
  blocked: "the platform's mail server is blocked, not planned: its OIDC directory uses the platform's own sign-in, which is neither applied nor planned",
2084
2114
  };
2085
2115
  }
2116
+ if (storeSwitch) {
2117
+ return {
2118
+ step: {
2119
+ action: {
2120
+ system: 'coolify',
2121
+ kind: 'app',
2122
+ key: PLATFORM_MAIL_KEY,
2123
+ desired: {
2124
+ hostname: platform.mail.hostname,
2125
+ gitRef: platform.mail.gitRef,
2126
+ switchMailStore: true,
2127
+ },
2128
+ },
2129
+ step: {
2130
+ description: mailStoreSwitchDescription('the platform'),
2131
+ destroys: true,
2132
+ migrates: false,
2133
+ },
2134
+ },
2135
+ blocked: null,
2136
+ };
2137
+ }
2086
2138
  return {
2087
2139
  step: {
2088
2140
  action: {
@@ -2684,7 +2736,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2684
2736
  token: true,
2685
2737
  access: true,
2686
2738
  vars: '',
2687
- }) {
2739
+ }, chat) {
2688
2740
  const { uptime } = platform;
2689
2741
  const deployed = Boolean(recordedIds[resourceKey('cloudflare', 'worker', PLATFORM_UPTIME_KEY)]) &&
2690
2742
  !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
@@ -2733,7 +2785,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2733
2785
  if (executorBlocked)
2734
2786
  return { step: null, blocked: [executorBlocked], notes: [] };
2735
2787
  const notes = [];
2736
- if (!heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2788
+ if (!chat && !heldOptional.includes('CHAT_ACCESS_TOKEN')) {
2737
2789
  notes.push({
2738
2790
  key: 'uptime:CHAT_ACCESS_TOKEN',
2739
2791
  service: 'uptime',
@@ -2759,9 +2811,12 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2759
2811
  recordedIds[PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY] === uptime.sha256 &&
2760
2812
  recordedIds[PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
2761
2813
  (recordedIds[PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY] ?? '') === uptimeHeld.vars &&
2762
- recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional);
2814
+ recordedIds[PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY] === uptimeSecretNames(heldOptional) &&
2815
+ !chat?.pending;
2816
+ // Reported beside the step, not instead of it: the Worker deploys without chat alerts meanwhile.
2817
+ const blockedLines = chat?.blocked ? [chat.blocked] : [];
2763
2818
  if (applied)
2764
- return { step: null, blocked: [], notes };
2819
+ return { step: null, blocked: blockedLines, notes };
2765
2820
  return {
2766
2821
  step: {
2767
2822
  action: action({ hostname, gitRef: uptime.tag, sha256: uptime.sha256 }),
@@ -2771,7 +2826,7 @@ function planUptime(platform, recordedIds, executorBlocked, heldOptional, archon
2771
2826
  migrates: false,
2772
2827
  },
2773
2828
  },
2774
- blocked: [],
2829
+ blocked: blockedLines,
2775
2830
  notes,
2776
2831
  };
2777
2832
  }
@@ -304,10 +304,23 @@ export interface OwnerFiles {
304
304
  organisationId: string;
305
305
  managementKeyRef: string;
306
306
  }
307
+ /**
308
+ * The owner company's chat once `coolify:chat` has applied: where the uptime
309
+ * Worker's bot is made and which people join its room.
310
+ */
311
+ export interface OwnerChat {
312
+ /** The chat-service app (Coolify uuid), whose env holds `CHAT_INTERNAL_TOKEN`. */
313
+ appId: string;
314
+ /** The company's Agora host: the public origin of the bot route and the Matrix homeserver. */
315
+ agoraHost: string;
316
+ /** ZITADEL subjects of the owner company's owners whose invite is recorded. */
317
+ subjects: readonly string[];
318
+ }
307
319
  /** What the platform plan reads about the owner company. */
308
320
  export interface PlatformOwnerInput {
309
321
  signIn?: OwnerSignIn | null;
310
322
  files?: OwnerFiles | null;
323
+ chat?: OwnerChat | null;
311
324
  }
312
325
  /**
313
326
  * The owner's sign-in from its declared org and the ids its own steps recorded.
@@ -326,3 +339,5 @@ export declare function ownerSignInFrom(owner: DeclaredOrg | null, recorded: Rea
326
339
  export declare function platformOwnerUserIdFrom(owner: DeclaredOrg | null, recorded: Readonly<Record<string, string>>): string | null;
327
340
  /** The owner's files host once its own files step has applied; `null` before. */
328
341
  export declare function ownerFilesFrom(owner: DeclaredOrg | null, recorded: Readonly<Record<string, string>>): OwnerFiles | null;
342
+ /** The owner's chat once its `coolify:chat` step has applied; `null` before, and when chat is off. */
343
+ export declare function ownerChatFrom(owner: DeclaredOrg | null, recorded: Readonly<Record<string, string>>): OwnerChat | null;
@@ -1,5 +1,5 @@
1
1
  import { ownsServices } from '../org.js';
2
- import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
2
+ import { ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor } from './agora.js';
3
3
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
4
4
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
5
5
  import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-host.js';
@@ -352,3 +352,19 @@ export function ownerFilesFrom(owner, recorded) {
352
352
  managementKeyRef: orgTarget(owner).refs.filesManagement,
353
353
  };
354
354
  }
355
+ /** The recorded id of the chat-service app (`coolify_chat_service`, set by the `coolify:chat` step). */
356
+ const CHAT_SERVICE_APP_SCOPE_KEY = 'coolify_chat_service';
357
+ /** The owner's chat once its `coolify:chat` step has applied; `null` before, and when chat is off. */
358
+ export function ownerChatFrom(owner, recorded) {
359
+ const appId = recorded[CHAT_SERVICE_APP_SCOPE_KEY];
360
+ if (!owner || !owner.chat.enabled || !appId || !recorded[`coolify:chat:${owner.id}`])
361
+ return null;
362
+ return {
363
+ appId,
364
+ agoraHost: agoraHostnameFor(owner),
365
+ subjects: owner.owners.flatMap((o) => {
366
+ const id = recorded[`zitadel:owner:${owner.id}:${o.email.toLowerCase()}`];
367
+ return id ? [id] : [];
368
+ }),
369
+ };
370
+ }
@@ -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;
@@ -322,6 +354,10 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
322
354
  access: boolean;
323
355
  vars: string;
324
356
  };
357
+ uptimeChat?: {
358
+ pending: boolean;
359
+ blocked?: string;
360
+ };
325
361
  archonRunsInsideApp?: boolean;
326
362
  archonDeployRunning?: string;
327
363
  archonIdTokenUserinfo?: boolean;
@@ -574,6 +610,16 @@ export interface PlatformMailAdminPort {
574
610
  issuerUrl: string;
575
611
  audience: string;
576
612
  };
613
+ /**
614
+ * Where Stalwart keeps its data: a Postgres reached at `host:port`. The password is not here:
615
+ * Stalwart reads it from the env var {@link MAIL_DATABASE_PASSWORD_ENV}.
616
+ */
617
+ dataStore: {
618
+ host: string;
619
+ port: number;
620
+ database: string;
621
+ user: string;
622
+ };
577
623
  }): Promise<'restart' | undefined>;
578
624
  /**
579
625
  * `StalwartClient.createApiKey`'s own signature. The key belongs to a real
@@ -610,7 +656,7 @@ export declare function defaultStalwartForInstance(input: {
610
656
  * own shape.
611
657
  */
612
658
  export interface PlatformMailDeps {
613
- coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'restart' | 'getDeployment'>;
659
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'listDatabases' | 'deleteApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'restart' | 'getDeployment'>;
614
660
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
615
661
  dns: PlatformDnsFactory;
616
662
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -652,6 +698,8 @@ export interface PlatformMailRuntime {
652
698
  readyIntervalMs?: number;
653
699
  /** Tries at handing the admin key to the host (default 3). */
654
700
  deliverAttempts?: number;
701
+ /** Called with the mail database's password, so the host can keep it out of every message it writes. */
702
+ onSecret?: (value: string) => void;
655
703
  }
656
704
  /**
657
705
  * What {@link provisionMailServer} reads besides the target's names: the
@@ -669,6 +717,12 @@ export interface MailServerInput {
669
717
  mailHost: string;
670
718
  /** The Stalwart image tag. */
671
719
  imageTag: string;
720
+ /**
721
+ * The plan's confirmed switch of a volume-backed server to Postgres: delete the Stalwart app and its
722
+ * volume, clear the ids that describe it, then provision as usual. Without it a server still on its
723
+ * volume is refused before any write.
724
+ */
725
+ switchStore?: boolean;
672
726
  /** `host:container` list; empty publishes no host port. */
673
727
  hostPorts: string;
674
728
  /** The sign-in the server's directory trusts; its `mail` project's id is the audience. */