@wtfalch/foundry 0.28.0 → 0.29.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.
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg } from '../org.js';
1
+ import { type DeclaredOrg } from '../org.js';
2
2
  import type { BrokerEntryInput, BrokerEntryWant } from './broker-entries.js';
3
3
  /**
4
4
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
@@ -36,8 +36,28 @@ export declare const BOULE_DEPLOYED_SCOPE_KEY = "coolify_boule_deployed";
36
36
  * issuer.
37
37
  */
38
38
  export declare function bouleHostnameFor(org: DeclaredOrg): string;
39
+ /** The env name Boule reads its Lettermint token from (boule#266). */
40
+ export declare const BOULE_MAIL_TOKEN_ENV = "LETTERMINT_API_TOKEN";
41
+ /**
42
+ * Whether Boule should mail invites: the org's mail is hosted and
43
+ * `coolify:mail` has recorded its domain. Mail off, received, or no domain
44
+ * yet: no mail env at all, and Boule shows the invite link on screen.
45
+ */
46
+ export declare function bouleMailWanted(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
47
+ /**
48
+ * Boule's `MAIL_FROM`: the org's no-reply address on its recorded mail
49
+ * domain. The local part falls back to `DEFAULT_NO_REPLY_LOCAL_PART`, the
50
+ * same default the hosted app's `MAIL_FROM` uses and the store column holds.
51
+ */
52
+ export declare function bouleMailFrom(org: DeclaredOrg, mailDomain: string): string;
53
+ /**
54
+ * Whether the live Boule's mail token needs writing: it holds none, or the
55
+ * org's hosted app holds a different one (the app's token wins). Compares
56
+ * values in memory only; the result names nothing secret.
57
+ */
58
+ export declare function bouleMailTokenDrift(liveToken: string | undefined, appToken: string | undefined): boolean;
39
59
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
40
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL"];
60
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN"];
41
61
  /**
42
62
  * The env vars an org's Boule app should carry, and every input still missing
43
63
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -55,11 +75,15 @@ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER",
55
75
  * absent: Boule has no platform wired in every test and every org-only
56
76
  * caller, same as `issuer` before #177.
57
77
  *
78
+ * `mailWanted` (see {@link bouleMailWanted}) adds `MAIL_FROM`. The Lettermint
79
+ * token is never here: it is a secret the executor writes and the plan
80
+ * compares in memory ({@link bouleMailTokenDrift}).
81
+ *
58
82
  * Not here, and written by the executor instead: the database URLs
59
83
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
60
84
  * (generated once, never rotated).
61
85
  */
62
- export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null): {
86
+ export declare function desiredBouleEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, issuer: string | null, archonUrl: string | null, mailWanted?: boolean): {
63
87
  env: Record<string, string>;
64
88
  missing: string[];
65
89
  };
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_NO_REPLY_LOCAL_PART } from '../org.js';
1
2
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
2
3
  /**
3
4
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
@@ -37,6 +38,34 @@ export const BOULE_DEPLOYED_SCOPE_KEY = 'coolify_boule_deployed';
37
38
  export function bouleHostnameFor(org) {
38
39
  return org.domain ? `boule.${org.domain}` : `${org.slug}-boule.oikos.host`;
39
40
  }
41
+ /** The env name Boule reads its Lettermint token from (boule#266). */
42
+ export const BOULE_MAIL_TOKEN_ENV = 'LETTERMINT_API_TOKEN';
43
+ /** The scope id `coolify:mail` records the org's resolved mail domain under. */
44
+ const MAIL_DOMAIN_RECORDED_KEY = 'mail_domain';
45
+ /**
46
+ * Whether Boule should mail invites: the org's mail is hosted and
47
+ * `coolify:mail` has recorded its domain. Mail off, received, or no domain
48
+ * yet: no mail env at all, and Boule shows the invite link on screen.
49
+ */
50
+ export function bouleMailWanted(org, recorded) {
51
+ return org.mail.mode === 'hosted' && Boolean(recorded[MAIL_DOMAIN_RECORDED_KEY]);
52
+ }
53
+ /**
54
+ * Boule's `MAIL_FROM`: the org's no-reply address on its recorded mail
55
+ * domain. The local part falls back to `DEFAULT_NO_REPLY_LOCAL_PART`, the
56
+ * same default the hosted app's `MAIL_FROM` uses and the store column holds.
57
+ */
58
+ export function bouleMailFrom(org, mailDomain) {
59
+ return `${org.mail.noReplyLocalPart || DEFAULT_NO_REPLY_LOCAL_PART}@${mailDomain}`;
60
+ }
61
+ /**
62
+ * Whether the live Boule's mail token needs writing: it holds none, or the
63
+ * org's hosted app holds a different one (the app's token wins). Compares
64
+ * values in memory only; the result names nothing secret.
65
+ */
66
+ export function bouleMailTokenDrift(liveToken, appToken) {
67
+ return !liveToken || (Boolean(appToken) && appToken !== liveToken);
68
+ }
40
69
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
41
70
  export const BOULE_MANAGED_ENV_KEYS = [
42
71
  'APP_URL',
@@ -46,6 +75,8 @@ export const BOULE_MANAGED_ENV_KEYS = [
46
75
  'BOULE_ORG_NAME',
47
76
  'AUTHZ_BOOTSTRAP_OPERATOR',
48
77
  'ARCHON_URL',
78
+ 'MAIL_FROM',
79
+ BOULE_MAIL_TOKEN_ENV,
49
80
  ];
50
81
  /**
51
82
  * The env vars an org's Boule app should carry, and every input still missing
@@ -64,11 +95,15 @@ export const BOULE_MANAGED_ENV_KEYS = [
64
95
  * absent: Boule has no platform wired in every test and every org-only
65
96
  * caller, same as `issuer` before #177.
66
97
  *
98
+ * `mailWanted` (see {@link bouleMailWanted}) adds `MAIL_FROM`. The Lettermint
99
+ * token is never here: it is a secret the executor writes and the plan
100
+ * compares in memory ({@link bouleMailTokenDrift}).
101
+ *
67
102
  * Not here, and written by the executor instead: the database URLs
68
103
  * (`DATABASE_URL_OWNER`, `DATABASE_URL`, minted once) and `AUTH_COOKIE_SECRET`
69
104
  * (generated once, never rotated).
70
105
  */
71
- export function desiredBouleEnv(org, recorded, issuer, archonUrl) {
106
+ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = false) {
72
107
  const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
73
108
  const clientId = recorded[BOULE_CLIENT_ID_SCOPE_KEY];
74
109
  const missing = [];
@@ -96,6 +131,9 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl) {
96
131
  : undefined;
97
132
  if (ownerUserId)
98
133
  env.AUTHZ_BOOTSTRAP_OPERATOR = ownerUserId;
134
+ const mailDomain = recorded[MAIL_DOMAIN_RECORDED_KEY];
135
+ if (mailWanted && mailDomain)
136
+ env.MAIL_FROM = bouleMailFrom(org, mailDomain);
99
137
  return { env, missing };
100
138
  }
101
139
  /** The `AUTH_APPS` name of an org's Boule broker entry, and the scope key suffix it records under. */
package/dist/plan/diff.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
- import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
4
+ import { BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
6
  import { chatInternalNames } from './chat-matrix.js';
7
7
  import { planEntryAiKeys } from './entry-ai-key.js';
@@ -398,7 +398,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
398
398
  const bouleGuarded = guardApp(bouleClient);
399
399
  if (bouleGuarded)
400
400
  plannedSteps.push(bouleGuarded);
401
- const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
401
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness);
402
402
  if (boule.step)
403
403
  plannedSteps.push(boule.step);
404
404
  // After the apps and Boule -- an edge key needs its app recorded or planned,
@@ -468,6 +468,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
468
468
  unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
469
469
  if (boule.blocked)
470
470
  unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
471
+ if (boule.mailBlocked) {
472
+ unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
473
+ }
471
474
  // Same for an ai key minted but never delivered: planning a new mint would
472
475
  // orphan a second live key.
473
476
  if (aiKey.blocked)
@@ -2027,7 +2030,7 @@ function planBouleClient(org, byKey, claimed) {
2027
2030
  * address (the one Agora signs in at); without it the Boule is blocked, not
2028
2031
  * planned, so every other step still applies.
2029
2032
  */
2030
- function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl) {
2033
+ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness) {
2031
2034
  const key = resourceKey('coolify', 'boule', org.id);
2032
2035
  const live = byKey.get(key);
2033
2036
  if (live)
@@ -2042,26 +2045,59 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2042
2045
  if (tooLong.length > 0) {
2043
2046
  return {
2044
2047
  step: null,
2048
+ mailBlocked: null,
2045
2049
  blocked: `${org.slug}'s Boule is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
2046
2050
  };
2047
2051
  }
2048
2052
  if (otherSlugs.includes(`${org.slug}-boule`)) {
2049
2053
  return {
2050
2054
  step: null,
2055
+ mailBlocked: null,
2051
2056
  blocked: `${org.slug}'s Boule is blocked, not planned: another declared org has the slug '${org.slug}-boule', which is this Boule's hostname`,
2052
2057
  };
2053
2058
  }
2054
2059
  if (!issuer) {
2055
2060
  return {
2056
2061
  step: null,
2062
+ mailBlocked: null,
2057
2063
  blocked: `${org.slug}'s Boule is blocked, not planned: the host supplies no issuer`,
2058
2064
  };
2059
2065
  }
2066
+ // Mail env, only for a hosted-mail org whose domain is recorded. Blocked,
2067
+ // not failing: while the platform cannot yet supply the token, Boule is
2068
+ // planned without mail and keeps showing the invite link.
2069
+ const mailWanted = bouleMailWanted(org, recordedIds);
2070
+ const mailDomain = recordedIds.mail_domain?.toLowerCase();
2071
+ const isApexOrg = Boolean(mailDomain && readiness.platformApex && mailDomain === readiness.platformApex.toLowerCase());
2072
+ let mailBlockedReason = null;
2073
+ if (mailWanted && isApexOrg) {
2074
+ if (!readiness.platformLettermintProjectId) {
2075
+ mailBlockedReason = 'platform.mail.lettermintProjectId is not declared yet';
2076
+ }
2077
+ else if (readiness.mailKeyHeld !== true) {
2078
+ mailBlockedReason = `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`;
2079
+ }
2080
+ }
2081
+ else if (mailWanted) {
2082
+ mailBlockedReason = edgeNeeded(readiness, ['zones', 'dns']);
2083
+ }
2084
+ const mailBlocked = mailBlockedReason
2085
+ ? `${org.slug}'s Boule mail is blocked, not planned: ${mailBlockedReason}`
2086
+ : null;
2087
+ const mailOn = mailWanted && !mailBlocked;
2060
2088
  const action = {
2061
2089
  system: 'coolify',
2062
2090
  kind: 'boule',
2063
2091
  key: org.id,
2064
- desired: { issuer, archonUrl },
2092
+ // The platform values go in like the app step's: the executor writes
2093
+ // what the plan showed and refuses a plan that carries no `platformApex`.
2094
+ desired: {
2095
+ issuer,
2096
+ archonUrl,
2097
+ platformApex: readiness.platformApex,
2098
+ platformLettermintProjectId: readiness.platformLettermintProjectId,
2099
+ mailBlocked: mailBlocked !== null,
2100
+ },
2065
2101
  };
2066
2102
  if (!live) {
2067
2103
  return {
@@ -2070,13 +2106,19 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2070
2106
  step: { description: `provision ${org.slug}'s Boule`, destroys: false, migrates: false },
2071
2107
  },
2072
2108
  blocked: null,
2109
+ mailBlocked,
2073
2110
  };
2074
2111
  }
2075
2112
  const liveEnv = (live.config.env ?? {});
2076
- const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl);
2113
+ const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn);
2077
2114
  const drift = bouleEnvDrift(env, liveEnv);
2115
+ // The token is compared, never planned with its value.
2116
+ if (mailOn &&
2117
+ bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
2118
+ drift.push(BOULE_MAIL_TOKEN_ENV);
2119
+ }
2078
2120
  if (drift.length === 0)
2079
- return { step: null, blocked: null };
2121
+ return { step: null, blocked: null, mailBlocked };
2080
2122
  return {
2081
2123
  step: {
2082
2124
  action,
@@ -2087,6 +2129,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2087
2129
  },
2088
2130
  },
2089
2131
  blocked: null,
2132
+ mailBlocked,
2090
2133
  };
2091
2134
  }
2092
2135
  /**
@@ -1957,6 +1957,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1957
1957
  * domain restricted to them before any old project is deleted, so the
1958
1958
  * restriction list never becomes empty.
1959
1959
  *
1960
+ * #654: the non-platform-apex part now lives in {@link ensureOrgAppMailToken},
1961
+ * shared with Boule, which holds the same token; the rule below is its rule
1962
+ * with the consumers read from the hosted app and Boule.
1963
+ *
1960
1964
  * Mint rule ("Credential model"): the project is healthy when the app env
1961
1965
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
1962
1966
  * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
@@ -2003,6 +2007,31 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2003
2007
  * this field existed (review round 2, finding 3).
2004
2008
  */
2005
2009
  private ensureAppLettermintMail;
2010
+ /**
2011
+ * The one mint rule for the org's `<slug>-app` Lettermint token, shared by
2012
+ * the hosted app (`ensureAppLettermintMail`) and Boule
2013
+ * (`ensureBouleMail`), which hold the same token. Lettermint shows a
2014
+ * token once, so the token lives only in those apps' envs: the rule reads
2015
+ * every consumer that is recorded (the hosted app first, Boule second).
2016
+ * The project is healthy when Lettermint lists exactly one `<slug>-app`
2017
+ * project and some consumer holds a token. A healthy rule gives `self` that
2018
+ * token (the app's wins) when it lacks it or holds another; a peer gets it
2019
+ * in its own step. An
2020
+ * unhealthy one mints a fresh project and writes its token to every
2021
+ * consumer, restarting those that run, before any other `<slug>-app`
2022
+ * project is deleted. Either role may run first: an app added after Boule
2023
+ * adopts Boule's token instead of deleting its project.
2024
+ *
2025
+ * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
2026
+ * returned, recorded, logged or interpolated into an error. Returns
2027
+ * whether `self`'s own token changed and whether it was restarted here.
2028
+ *
2029
+ * A peer recorded but gone from Coolify (404) is no consumer. A healthy
2030
+ * company's step writes only its own env; a re-mint writes every live
2031
+ * consumer, restarts the running ones, restricts the domain, then deletes
2032
+ * the old projects.
2033
+ */
2034
+ private ensureOrgAppMailToken;
2006
2035
  /**
2007
2036
  * #149: mints this org's own Lettermint relay credential and writes it to
2008
2037
  * Stalwart via `MailPort.applyOrgRelay`, the same mint rule
@@ -2443,6 +2472,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2443
2472
  * shape {@link coolifyAgora}'s own doc comment explains.
2444
2473
  */
2445
2474
  private coolifyBoule;
2475
+ /**
2476
+ * Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
2477
+ * the platform's own apex, else the org's `<slug>-app` token through the
2478
+ * shared mint rule ({@link ensureOrgAppMailToken}), which mints that project
2479
+ * when no hosted app made it yet. `MAIL_FROM` is written with the rest of
2480
+ * Boule's env. Returns whether Boule's token changed, so the caller
2481
+ * restarts a running Boule, unless `restarted`: the shared rule then already
2482
+ * restarted it (`running`), before it deleted the old project.
2483
+ */
2484
+ private ensureBouleMail;
2446
2485
  /**
2447
2486
  * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
2448
2487
  * the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
@@ -3,7 +3,7 @@ import { URL } from 'node:url';
3
3
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
5
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
6
+ import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
8
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
9
  import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -2493,6 +2493,10 @@ export class FoundryStepExecutor {
2493
2493
  * domain restricted to them before any old project is deleted, so the
2494
2494
  * restriction list never becomes empty.
2495
2495
  *
2496
+ * #654: the non-platform-apex part now lives in {@link ensureOrgAppMailToken},
2497
+ * shared with Boule, which holds the same token; the rule below is its rule
2498
+ * with the consumers read from the hosted app and Boule.
2499
+ *
2496
2500
  * Mint rule ("Credential model"): the project is healthy when the app env
2497
2501
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
2498
2502
  * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
@@ -2545,7 +2549,6 @@ export class FoundryStepExecutor {
2545
2549
  if (!hasPlatformApexField) {
2546
2550
  throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
2547
2551
  }
2548
- const appName = `${org.slug}-app`;
2549
2552
  // #491: an org whose mail domain is the platform's own apex (Company
2550
2553
  // Included on companyincluded.com, today) shares the platform's existing
2551
2554
  // Lettermint project rather than minting a `<slug>-app` one of its own --
@@ -2588,44 +2591,129 @@ export class FoundryStepExecutor {
2588
2591
  await this.ports.coolify.restart(appId);
2589
2592
  return;
2590
2593
  }
2591
- const [projects, envs] = await Promise.all([
2594
+ await this.ensureOrgAppMailToken({
2595
+ org,
2596
+ context,
2597
+ mailDomain,
2598
+ self: { role: 'app', id: appId, restart: running },
2599
+ mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
2600
+ platformApex,
2601
+ platformLettermintProjectId,
2602
+ });
2603
+ }
2604
+ /**
2605
+ * The one mint rule for the org's `<slug>-app` Lettermint token, shared by
2606
+ * the hosted app (`ensureAppLettermintMail`) and Boule
2607
+ * (`ensureBouleMail`), which hold the same token. Lettermint shows a
2608
+ * token once, so the token lives only in those apps' envs: the rule reads
2609
+ * every consumer that is recorded (the hosted app first, Boule second).
2610
+ * The project is healthy when Lettermint lists exactly one `<slug>-app`
2611
+ * project and some consumer holds a token. A healthy rule gives `self` that
2612
+ * token (the app's wins) when it lacks it or holds another; a peer gets it
2613
+ * in its own step. An
2614
+ * unhealthy one mints a fresh project and writes its token to every
2615
+ * consumer, restarting those that run, before any other `<slug>-app`
2616
+ * project is deleted. Either role may run first: an app added after Boule
2617
+ * adopts Boule's token instead of deleting its project.
2618
+ *
2619
+ * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
2620
+ * returned, recorded, logged or interpolated into an error. Returns
2621
+ * whether `self`'s own token changed and whether it was restarted here.
2622
+ *
2623
+ * A peer recorded but gone from Coolify (404) is no consumer. A healthy
2624
+ * company's step writes only its own env; a re-mint writes every live
2625
+ * consumer, restarts the running ones, restricts the domain, then deletes
2626
+ * the old projects.
2627
+ */
2628
+ async ensureOrgAppMailToken(args) {
2629
+ const { org, context, mailDomain, self, mailFrom } = args;
2630
+ const appName = `${org.slug}-app`;
2631
+ const recorded = context.recordedIds();
2632
+ const appId = self.role === 'app' ? self.id : recorded[`coolify:app:${appStepKey(org)}`];
2633
+ const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
2634
+ const peerListEnvs = (id) => !id || id === self.id
2635
+ ? Promise.resolve(null)
2636
+ : // A recorded peer that Coolify no longer has is not a consumer: not
2637
+ // read, written or restarted (same 404 rule as the retire step).
2638
+ this.ports.coolify
2639
+ .listEnvs(id)
2640
+ .catch((error) => {
2641
+ if (!isNotFoundError(error))
2642
+ throw error;
2643
+ return null;
2644
+ });
2645
+ const [projects, selfEnvs, appEnvs, bouleEnvs] = await Promise.all([
2592
2646
  this.ports.lettermint.listProjects(),
2593
- this.ports.coolify.listEnvs(appId),
2647
+ this.ports.coolify.listEnvs(self.id),
2648
+ self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
2649
+ self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
2594
2650
  ]);
2595
- const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
2651
+ const tokenOf = (envs) => envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value || undefined;
2652
+ // The hosted app first, Boule second: the app's token wins a disagreement.
2653
+ const consumers = [];
2654
+ const appSlot = self.role === 'app' ? selfEnvs : appEnvs;
2655
+ const bouleSlot = self.role === 'boule' ? selfEnvs : bouleEnvs;
2656
+ if (appSlot && appId)
2657
+ consumers.push({ id: appId, token: tokenOf(appSlot) });
2658
+ if (bouleSlot && bouleId)
2659
+ consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
2660
+ const existingToken = consumers.find((c) => c.token)?.token;
2596
2661
  const ours = projects.filter((p) => p.name === appName);
2597
2662
  const held = ours.length === 1 ? ours[0] : undefined;
2598
- const healthy = held !== undefined && Boolean(envValue('LETTERMINT_API_TOKEN'));
2663
+ const healthy = held !== undefined && existingToken !== undefined;
2599
2664
  let allProjects = projects;
2600
2665
  let keepProjectId;
2601
- const envUpdates = [];
2602
- if (healthy && held) {
2666
+ let token;
2667
+ if (healthy && held && existingToken) {
2603
2668
  keepProjectId = held.id;
2669
+ token = existingToken;
2604
2670
  }
2605
2671
  else {
2606
2672
  const created = await this.ports.lettermint.createProject(appName);
2607
2673
  keepProjectId = created.project.id;
2608
2674
  allProjects = [...projects, created.project];
2609
- envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
2675
+ token = created.token;
2676
+ }
2677
+ // A healthy company: this step writes only its own env; a peer that lacks
2678
+ // the token gets it in its own step. A re-mint kills the old token, so it
2679
+ // reaches every live consumer here.
2680
+ const targets = consumers.filter((c) => c.token !== token && (!healthy || c.id === self.id));
2681
+ const selfChanged = targets.some((c) => c.id === self.id);
2682
+ for (const consumer of consumers) {
2683
+ const updates = [];
2684
+ if (targets.includes(consumer))
2685
+ updates.push({ key: 'LETTERMINT_API_TOKEN', value: token });
2686
+ if (consumer.id === self.id && mailFrom)
2687
+ updates.push({ key: 'MAIL_FROM', value: mailFrom });
2688
+ if (updates.length > 0)
2689
+ await this.ports.coolify.bulkUpsertEnvs(consumer.id, updates);
2610
2690
  }
2611
- envUpdates.push({
2612
- key: 'MAIL_FROM',
2613
- value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
2614
- });
2615
- await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
2616
2691
  // A running container read its env at start and still holds the old
2617
- // token, so a fresh one only reaches it through a restart -- before the
2618
- // old project is deleted below. A just-created app is deployed first (#566).
2619
- if (!healthy && running)
2620
- await this.ports.coolify.restart(appId);
2621
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2692
+ // token, so a fresh one only reaches it through a restart -- after every
2693
+ // write and before the old project is deleted below, so a failure in
2694
+ // between leaves the old project for the next apply to re-mint. A just-created
2695
+ // app is deployed first (#566).
2696
+ let selfRestarted = false;
2697
+ for (const consumer of targets) {
2698
+ const isSelf = consumer.id === self.id;
2699
+ const running = isSelf
2700
+ ? self.restart
2701
+ : isRunningOrStarting((await this.ports.coolify.getApplication(consumer.id)).status);
2702
+ if (!running)
2703
+ continue;
2704
+ await this.ports.coolify.restart(consumer.id);
2705
+ if (isSelf)
2706
+ selfRestarted = true;
2707
+ }
2708
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, args.platformApex, args.platformLettermintProjectId);
2622
2709
  for (const project of allProjects) {
2623
2710
  if (project.name === appName &&
2624
2711
  project.id !== keepProjectId &&
2625
- project.id !== platformLettermintProjectId) {
2712
+ project.id !== args.platformLettermintProjectId) {
2626
2713
  await this.ports.lettermint.deleteProject(project.id);
2627
2714
  }
2628
2715
  }
2716
+ return { selfChanged, selfRestarted };
2629
2717
  }
2630
2718
  /**
2631
2719
  * #149: mints this org's own Lettermint relay credential and writes it to
@@ -4237,7 +4325,14 @@ export class FoundryStepExecutor {
4237
4325
  // written is the env the plan showed.
4238
4326
  const issuer = action.desired.issuer ?? null;
4239
4327
  const archonUrl = action.desired.archonUrl ?? null;
4240
- const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl);
4328
+ // The plan decides blocked mail (`mailBlocked`); a plan with no
4329
+ // `platformApex` key predates this field and is refused before any mail
4330
+ // env is written.
4331
+ const mailWanted = bouleMailWanted(org, context.recordedIds()) && action.desired.mailBlocked !== true;
4332
+ if (mailWanted && !('platformApex' in action.desired)) {
4333
+ throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
4334
+ }
4335
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted);
4241
4336
  if (missing.length > 0) {
4242
4337
  throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
4243
4338
  }
@@ -4288,6 +4383,12 @@ export class FoundryStepExecutor {
4288
4383
  if (!dbPush.pushed) {
4289
4384
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
4290
4385
  }
4386
+ // The org's Lettermint token, the same one its hosted app holds. A no-op
4387
+ // for an org with mail off or no mail domain recorded: Boule then shows
4388
+ // the invite link.
4389
+ const mail = mailWanted
4390
+ ? await this.ensureBouleMail(app.appId, org, context, action, app.existed && Boolean(context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]))
4391
+ : { changed: false, restarted: false };
4291
4392
  // #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
4292
4393
  // never sets `instant_deploy`, so a brand new app (or one found by name
4293
4394
  // that a previous apply created but crashed before deploying) sat built
@@ -4300,11 +4401,53 @@ export class FoundryStepExecutor {
4300
4401
  context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
4301
4402
  }
4302
4403
  else if (app.existed &&
4303
- (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
4404
+ !mail.restarted &&
4405
+ (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
4406
+ // A restart inside the mail rule already came after every env write above.
4304
4407
  await this.ports.coolify.restart(app.appId);
4305
4408
  }
4306
4409
  return { resourceId: app.appId };
4307
4410
  }
4411
+ /**
4412
+ * Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
4413
+ * the platform's own apex, else the org's `<slug>-app` token through the
4414
+ * shared mint rule ({@link ensureOrgAppMailToken}), which mints that project
4415
+ * when no hosted app made it yet. `MAIL_FROM` is written with the rest of
4416
+ * Boule's env. Returns whether Boule's token changed, so the caller
4417
+ * restarts a running Boule, unless `restarted`: the shared rule then already
4418
+ * restarted it (`running`), before it deleted the old project.
4419
+ */
4420
+ async ensureBouleMail(appId, org, context, action, running) {
4421
+ const none = { changed: false, restarted: false };
4422
+ const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4423
+ if (!mailDomain)
4424
+ return none;
4425
+ const platformApex = action.desired.platformApex ?? null;
4426
+ const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
4427
+ if (platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase()) {
4428
+ const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
4429
+ if (!heldToken) {
4430
+ throw new Error(`coolify:boule for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
4431
+ }
4432
+ const current = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
4433
+ if (current === heldToken)
4434
+ return none;
4435
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
4436
+ { key: 'LETTERMINT_API_TOKEN', value: heldToken },
4437
+ ]);
4438
+ return { changed: true, restarted: false };
4439
+ }
4440
+ const { selfChanged, selfRestarted } = await this.ensureOrgAppMailToken({
4441
+ org,
4442
+ context,
4443
+ mailDomain,
4444
+ self: { role: 'boule', id: appId, restart: running },
4445
+ mailFrom: null,
4446
+ platformApex,
4447
+ platformLettermintProjectId,
4448
+ });
4449
+ return { changed: selfChanged, restarted: selfRestarted };
4450
+ }
4308
4451
  // ---- jobs (#173) ---------------------------------------------------------
4309
4452
  /**
4310
4453
  * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
@@ -1,7 +1,7 @@
1
1
  import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
3
  import { type ZitadelClient } from '@wtfalch/providers/zitadel';
4
- import type { OrgId } from '../org.js';
4
+ import { type OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import type { AiReader, CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
7
7
  /** The `CoolifyClient` methods the readers call, so a host's own implementation needs no cast. */
@@ -1,5 +1,6 @@
1
1
  import { ZitadelError } from '@wtfalch/providers/zitadel';
2
- import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
2
+ import { APP_ENTRY_KEY } from '../org.js';
3
+ import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
3
4
  /**
4
5
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
5
6
  * — the one place foundry records a provider's own id for an org today,
@@ -247,7 +248,7 @@ async function withAgoraEnvConfig(client, resource, recorded) {
247
248
  * `executor.ts` would write. Gated and failing exactly like
248
249
  * {@link withAgoraEnvConfig}.
249
250
  */
250
- async function withBouleEnvConfig(client, resource, recorded) {
251
+ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
251
252
  if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('boule:')) {
252
253
  return resource;
253
254
  }
@@ -257,7 +258,20 @@ async function withBouleEnvConfig(client, resource, recorded) {
257
258
  if (!e.is_preview && managed.includes(e.key))
258
259
  env[e.key] = e.value;
259
260
  }
260
- return { ...resource, config: { ...resource.config, env } };
261
+ // The hosted app's Lettermint token, as `config.appMailToken`: the one
262
+ // Boule must hold too. In memory only, compared by `planBoule` and never
263
+ // written to a step, description or log.
264
+ const orgId = recorded[resource.key]?.slice('boule:'.length);
265
+ const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
266
+ // A recorded app that is not in the live listing is gone: no app token, and
267
+ // no read of its envs. A listed app whose read fails fails like any read.
268
+ const appToken = appUuid && liveKeys.has(appUuid)
269
+ ? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
270
+ : undefined;
271
+ return {
272
+ ...resource,
273
+ config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
274
+ };
261
275
  }
262
276
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
263
277
  function toFoundryResources(system, resources) {
@@ -290,7 +304,8 @@ export class ProvidersCoolifyReader {
290
304
  const withEnvNames = await Promise.all(withHealthCheck.map((r) => withEnvNamesConfig(this.client, r, recorded)));
291
305
  const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
292
306
  const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
293
- const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded)));
307
+ const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
308
+ const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
294
309
  return applyRecordedIds(withBouleConfig, recorded);
295
310
  }
296
311
  /** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",