@wtfalch/foundry 0.27.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.
- package/dist/calls-sfu.d.ts +6 -0
- package/dist/calls-sfu.js +17 -8
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0050_platform_sfu_connection_revision.sql +7 -0
- package/dist/plan/boule.d.ts +27 -3
- package/dist/plan/boule.js +39 -1
- package/dist/plan/diff.js +49 -6
- package/dist/plan/executor.d.ts +46 -7
- package/dist/plan/executor.js +169 -26
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +2 -2
- package/dist/plan/live.d.ts +3 -3
- package/dist/plan/plan-apply.js +4 -0
- package/dist/plan/platform-diff.d.ts +10 -0
- package/dist/plan/platform-diff.js +29 -14
- package/dist/plan/platform-org.d.ts +3 -4
- package/dist/plan/platform-providers.d.ts +1 -1
- package/dist/plan/platform-providers.js +16 -9
- package/dist/plan/platform-sfu.d.ts +10 -0
- package/dist/plan/platform-sfu.js +39 -0
- package/dist/plan/providers-readers.d.ts +1 -1
- package/dist/plan/providers-readers.js +19 -4
- package/dist/platform.d.ts +11 -1
- package/dist/platform.js +6 -0
- package/dist/store/postgres.js +7 -1
- package/dist/store/schema.d.ts +17 -0
- package/dist/store/schema.js +2 -0
- package/package.json +1 -1
package/dist/calls-sfu.d.ts
CHANGED
|
@@ -203,6 +203,12 @@ export declare function cloudflaredEnvFile(token: string): string;
|
|
|
203
203
|
* writes the set it was given.
|
|
204
204
|
*/
|
|
205
205
|
export declare function livekitConfigYaml(config: CallsSfuConfig): string;
|
|
206
|
+
/** The command that brings both compose services up; part of what {@link callsSfuFilesVersion} hashes. */
|
|
207
|
+
export declare const CALLS_SFU_UP_COMMAND = "docker compose -f /opt/livekit/docker-compose.yml up -d";
|
|
208
|
+
/** Run first, so the SSH session this runs over is never refused. */
|
|
209
|
+
export declare const CALLS_SFU_SSH_RULE_COMMAND = "ufw allow 22/tcp";
|
|
210
|
+
/** Run after any unscoped signalling rule is removed, in this order. */
|
|
211
|
+
export declare const CALLS_SFU_FIREWALL_COMMANDS: readonly string[];
|
|
206
212
|
/**
|
|
207
213
|
* Writes all three files, brings LiveKit and `cloudflared` up, then narrows
|
|
208
214
|
* the VM firewall to exactly what #122's and #127's "Decided" sections name
|
package/dist/calls-sfu.js
CHANGED
|
@@ -239,6 +239,19 @@ rtc:
|
|
|
239
239
|
node_ip: ${address}
|
|
240
240
|
${keysYaml}`;
|
|
241
241
|
}
|
|
242
|
+
/** The command that brings both compose services up; part of what {@link callsSfuFilesVersion} hashes. */
|
|
243
|
+
export const CALLS_SFU_UP_COMMAND = `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`;
|
|
244
|
+
/** Run first, so the SSH session this runs over is never refused. */
|
|
245
|
+
export const CALLS_SFU_SSH_RULE_COMMAND = 'ufw allow 22/tcp';
|
|
246
|
+
/** Run after any unscoped signalling rule is removed, in this order. */
|
|
247
|
+
export const CALLS_SFU_FIREWALL_COMMANDS = [
|
|
248
|
+
`ufw allow from ${CALLS_SFU_PRIVATE_CIDR} to any port ${LIVEKIT_SIGNALLING_PORT} proto tcp`,
|
|
249
|
+
`ufw allow ${LIVEKIT_MEDIA_TCP_PORT}/tcp`,
|
|
250
|
+
`ufw allow ${LIVEKIT_MEDIA_UDP_RANGE}/udp`,
|
|
251
|
+
'ufw default deny incoming',
|
|
252
|
+
'ufw default allow outgoing',
|
|
253
|
+
'ufw --force enable',
|
|
254
|
+
];
|
|
242
255
|
async function run(host, command) {
|
|
243
256
|
const result = await host.exec(command);
|
|
244
257
|
if (result.exitCode !== 0) {
|
|
@@ -290,16 +303,12 @@ export async function applyCallsSfu(host, config) {
|
|
|
290
303
|
await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
|
|
291
304
|
await writeFileAtomically(host, LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
|
|
292
305
|
await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
|
|
293
|
-
await run(host,
|
|
294
|
-
await run(host,
|
|
306
|
+
await run(host, CALLS_SFU_UP_COMMAND);
|
|
307
|
+
await run(host, CALLS_SFU_SSH_RULE_COMMAND);
|
|
295
308
|
const status = await host.exec('ufw status');
|
|
296
309
|
if (status.exitCode === 0 && hasUnscopedSignallingRule(status.stdout)) {
|
|
297
310
|
await run(host, `ufw delete allow ${LIVEKIT_SIGNALLING_PORT}/tcp`);
|
|
298
311
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
await run(host, `ufw allow ${LIVEKIT_MEDIA_UDP_RANGE}/udp`);
|
|
302
|
-
await run(host, 'ufw default deny incoming');
|
|
303
|
-
await run(host, 'ufw default allow outgoing');
|
|
304
|
-
await run(host, 'ufw --force enable');
|
|
312
|
+
for (const command of CALLS_SFU_FIREWALL_COMMANDS)
|
|
313
|
+
await run(host, command);
|
|
305
314
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, HostingEntry, EntryGate, HostingEntryUses, EntryAuth, EntryFiles, EntryAi, EnvValue, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, OwnerMailbox, DeclaredOrg, } from './org.js';
|
|
2
2
|
export { APP_ENTRY_KEY, DEFAULT_GATE_LIMIT, FILES_ENTRY_PERMISSIONS, AI_ENTRY_PERMISSIONS, DEFAULT_MAIL_APPS, appEntry, ownerMailboxes, validateBuyerIdentity, validateHosting, validateMail, validateOwners, validateSite, } from './org.js';
|
|
3
|
-
export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
|
|
3
|
+
export type { DeclaredPlatform, PlatformService, PlatformServiceKey, PlatformSfuService, } from './platform.js';
|
|
4
4
|
export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
5
5
|
import { type PinnedVersions } from './bootstrap/versions.js';
|
|
6
6
|
export type { PinnedVersions };
|
|
@@ -22,7 +22,7 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
|
|
|
22
22
|
export { AdoptRefusedError } from './plan/index.js';
|
|
23
23
|
export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
24
24
|
export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
|
|
25
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
25
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
26
26
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
|
27
27
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
28
28
|
export type { PlatformOrgClientFactories } from './plan/index.js';
|
|
@@ -47,7 +47,7 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
|
|
|
47
47
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
|
|
48
48
|
export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './plan/index.js';
|
|
49
49
|
export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './plan/index.js';
|
|
50
|
-
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
|
|
50
|
+
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './plan/index.js';
|
|
51
51
|
export type { CallsSfuUrls } from './calls-sfu.js';
|
|
52
52
|
export type { CallsSfuSshSession } from './calls-sfu-ssh-adapter.js';
|
|
53
53
|
export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ export { createPostgresAuditLog } from './store/audit.js';
|
|
|
13
13
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
14
14
|
export { AdoptRefusedError } from './plan/index.js';
|
|
15
15
|
export { createEdgeAccess, createEdgeDns, edgeDnsRecordOwners, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
16
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
16
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_SFU_APPLIED_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
17
17
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
18
18
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
19
19
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
@@ -26,7 +26,7 @@ export { checkMailAppsAllowlist } from './plan/index.js';
|
|
|
26
26
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
|
|
27
27
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
|
|
28
28
|
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
|
|
29
|
-
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
|
|
29
|
+
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './plan/index.js';
|
|
30
30
|
export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
|
|
31
31
|
export { UnknownCallsSfuHostKeyError, toCallsSfuHostPort, applyCallsSfuOverSession, } from './calls-sfu-ssh-adapter.js';
|
|
32
32
|
export { mintLivekitCredentials } from './calls-sfu-keys.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- @wtfalch/foundry: the platform's `sfu.connectionRevision`, a value a host
|
|
2
|
+
-- changes when the SFU VM's SSH key, host key or tunnel token changes, so the
|
|
3
|
+
-- platform plan shows the `sfu` step again. Declared; null until set.
|
|
4
|
+
-- Mirrors src/store/schema.ts's `foundryDeclaredPlatform` table addition.
|
|
5
|
+
|
|
6
|
+
alter table foundry_declared_platform
|
|
7
|
+
add column if not exists sfu_connection_revision text;
|
package/dist/plan/boule.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
};
|
package/dist/plan/boule.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -519,7 +519,7 @@ export interface WorkersPort {
|
|
|
519
519
|
/**
|
|
520
520
|
* One DNS record a Lettermint domain's own response says must be published --
|
|
521
521
|
* `purpose` is Lettermint's own label (`return_path`, `dmarc`, `dkim_legacy`,
|
|
522
|
-
* `dkim_primary`, `dkim_secondary`), replacing
|
|
522
|
+
* `dkim_primary`, `dkim_secondary`), replacing the earlier provider's `record` field, from
|
|
523
523
|
* {@link LettermintPort.createDomain}/{@link LettermintPort.getDomain}, the
|
|
524
524
|
* same shape https://lettermint.co/docs/api-reference/team/0.0.1/lettermint-team-openapi.json's
|
|
525
525
|
* `DomainDnsRecordData` documents (`fqdn`/`content` renamed to this port's own
|
|
@@ -552,17 +552,17 @@ export interface LettermintProject {
|
|
|
552
552
|
* has no Lettermint client yet, unlike Coolify/Cloudflare/ZITADEL above, so
|
|
553
553
|
* this port names Lettermint's own REST shapes directly
|
|
554
554
|
* (`.plans/2026-09-28-lettermint-api.md`, its own primary-source citations).
|
|
555
|
-
* Replaces
|
|
555
|
+
* Replaces the earlier mail port (#138/#140), trimmed to what `coolifyApp`/`coolifyMail`
|
|
556
556
|
* below actually call.
|
|
557
557
|
*
|
|
558
|
-
* Unlike
|
|
558
|
+
* Unlike the earlier provider's per-domain sending key, a Lettermint domain is
|
|
559
559
|
* unrestricted by default -- any project in the team could send from it --
|
|
560
560
|
* so a project's own domain access is a separate call
|
|
561
561
|
* ({@link restrictDomainToProjects}), and the credential unit is a *project*,
|
|
562
562
|
* not a per-domain key: Lettermint's API mints, at most, one live token per
|
|
563
563
|
* project and has no call to list or revoke an individual token within one
|
|
564
564
|
* (`.plans/2026-09-28-lettermint-api.md` §4), so `<slug>-app` is a project
|
|
565
|
-
* name, never suffixed with a domain id the way
|
|
565
|
+
* name, never suffixed with a domain id the way the earlier provider's key name was.
|
|
566
566
|
*/
|
|
567
567
|
export interface LettermintPort {
|
|
568
568
|
/**
|
|
@@ -585,7 +585,7 @@ export interface LettermintPort {
|
|
|
585
585
|
/**
|
|
586
586
|
* Asks Lettermint to re-check DNS now. Never polled or awaited to
|
|
587
587
|
* completion -- the same "do not block the plan on DNS propagation" rule
|
|
588
|
-
*
|
|
588
|
+
* the earlier mail port followed: record the status, and let the next
|
|
589
589
|
* apply re-check it.
|
|
590
590
|
*/
|
|
591
591
|
verifyDomain(id: string): Promise<{
|
|
@@ -1957,9 +1957,13 @@ 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
|
-
* named `<slug>-app` -- unlike
|
|
1966
|
+
* named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
|
|
1963
1967
|
* carries no domain id, since a domain switch is handled entirely by
|
|
1964
1968
|
* {@link LettermintPort.restrictDomainToProjects} below and never needs a
|
|
1965
1969
|
* fresh project. Otherwise a fresh project is created and its token
|
|
@@ -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
|
|
@@ -2754,7 +2793,7 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2754
2793
|
* upsert `publishMailDnsRecords` above uses for Stalwart's own records --
|
|
2755
2794
|
* except an existing `_dmarc` record is never overwritten (a stricter
|
|
2756
2795
|
* policy an org already set, `p=quarantine`/`p=reject`, should not be
|
|
2757
|
-
* relaxed back to Lettermint's own default).
|
|
2796
|
+
* relaxed back to Lettermint's own default). The earlier provider's domain also
|
|
2758
2797
|
* carried an apex `SPF` TXT record that had to be merged into whatever SPF
|
|
2759
2798
|
* record already existed there (RFC 7208: two `v=spf1` TXT records on one
|
|
2760
2799
|
* name is a permerror); Lettermint's own DNS records
|