@wtfalch/foundry 0.13.0 → 0.15.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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plan/agora.d.ts +44 -0
- package/dist/plan/agora.js +63 -0
- package/dist/plan/diff.js +47 -12
- package/dist/plan/executor.d.ts +15 -0
- package/dist/plan/executor.js +101 -4
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/platform-diff.d.ts +8 -0
- package/dist/plan/platform-diff.js +8 -0
- package/dist/plan/platform-providers.d.ts +38 -12
- package/dist/plan/platform-providers.js +99 -16
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export { createPostgresAuditLog } from './store/audit.js';
|
|
|
15
15
|
export type { FoundryStoreDb } from './store/postgres.js';
|
|
16
16
|
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
|
17
17
|
export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, PlanDetail, PlatformPlanDetail, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
|
|
18
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, 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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
18
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, 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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
19
19
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
|
20
20
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
21
21
|
export type { PlatformOrgClientFactories } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
|
5
5
|
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
6
6
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
7
7
|
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
8
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, 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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
8
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, 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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
9
9
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
10
10
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
11
11
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -106,6 +106,50 @@ export declare const CALLS_SERVICE_NAME_SCOPE_KEY = "coolify_calls_service_inter
|
|
|
106
106
|
export declare function callsServiceInternalName(org: DeclaredOrg): string;
|
|
107
107
|
/** Whether the recorded marker says the calls service carries the name the org's current slug gives it. */
|
|
108
108
|
export declare function callsServiceHasName(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Recorded once the calls service app carries the seven ZITADEL token env
|
|
111
|
+
* vars (`CALLS_ISSUER` ... `CALLS_INTROSPECTION_CLIENT_SECRET`,
|
|
112
|
+
* `calls/docs/member-auth.md`). Its value is {@link callsTokenEnvMarker}, so a
|
|
113
|
+
* changed issuer, audience or organisation plans the step again. The same
|
|
114
|
+
* marker-and-one-restart mechanism as {@link CALLS_SERVICE_NAME_SCOPE_KEY}.
|
|
115
|
+
*/
|
|
116
|
+
export declare const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = "coolify_calls_service_token_env";
|
|
117
|
+
/**
|
|
118
|
+
* The introspection API app minted for the calls service (once), and the
|
|
119
|
+
* undelivered key recorded before its secret leaves the executor
|
|
120
|
+
* (`foundry/CLAUDE.md`, platform rule 1). No `:` in either, as that rule says.
|
|
121
|
+
*/
|
|
122
|
+
export declare const CALLS_INTROSPECTION_SCOPE_KEY = "zitadel_calls_introspection_app";
|
|
123
|
+
export declare const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = "zitadel_calls_introspection_undelivered";
|
|
124
|
+
/** What the plan hands the calls service step: the two host values the token env needs. */
|
|
125
|
+
export interface CallsTokenHost {
|
|
126
|
+
issuer: string;
|
|
127
|
+
messagingProjectId: string;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The token env's inputs, or null while any is missing: the host's issuer and
|
|
131
|
+
* messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
|
|
132
|
+
* id. Null means write none of the seven, never a partial set: the calls
|
|
133
|
+
* service refuses to boot on one.
|
|
134
|
+
*/
|
|
135
|
+
export declare function callsTokenHostFrom(host: AgoraHostValues): CallsTokenHost | null;
|
|
136
|
+
/** The value {@link CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY} holds once the env is in place; null when it cannot be written yet. */
|
|
137
|
+
export declare function callsTokenEnvMarker(host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): string | null;
|
|
138
|
+
/**
|
|
139
|
+
* The calls service's introspection API app, in the messaging project. One per
|
|
140
|
+
* org: the project is shared by every org, so the name carries the slug (a DNS
|
|
141
|
+
* label, at most 63 characters) and only an exact match is ever deleted.
|
|
142
|
+
*/
|
|
143
|
+
export declare function callsIntrospectionAppName(org: DeclaredOrg): string;
|
|
144
|
+
/**
|
|
145
|
+
* The block shown, and thrown, when the messaging project or the ZITADEL org
|
|
146
|
+
* differs from what the token env was written for: the recorded client
|
|
147
|
+
* belongs to the old project, and foundry does not replace or delete it. Null
|
|
148
|
+
* when nothing is recorded, or only the issuer differs (the client is kept).
|
|
149
|
+
*/
|
|
150
|
+
export declare function callsTokenEnvMovedMessage(org: DeclaredOrg, host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): string | null;
|
|
151
|
+
/** True when there is nothing to write yet, or the recorded marker says it was written. */
|
|
152
|
+
export declare function callsServiceHasTokenEnv(host: CallsTokenHost | null, recorded: Readonly<Record<string, string>>): boolean;
|
|
109
153
|
/**
|
|
110
154
|
* The env vars an org's Agora app should carry, and every input still
|
|
111
155
|
* missing to build them. `AGORA_CONFIG` is present only when `missing` is
|
package/dist/plan/agora.js
CHANGED
|
@@ -109,6 +109,69 @@ export function callsServiceInternalName(org) {
|
|
|
109
109
|
export function callsServiceHasName(org, recorded) {
|
|
110
110
|
return recorded[CALLS_SERVICE_NAME_SCOPE_KEY] === callsServiceInternalName(org);
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Recorded once the calls service app carries the seven ZITADEL token env
|
|
114
|
+
* vars (`CALLS_ISSUER` ... `CALLS_INTROSPECTION_CLIENT_SECRET`,
|
|
115
|
+
* `calls/docs/member-auth.md`). Its value is {@link callsTokenEnvMarker}, so a
|
|
116
|
+
* changed issuer, audience or organisation plans the step again. The same
|
|
117
|
+
* marker-and-one-restart mechanism as {@link CALLS_SERVICE_NAME_SCOPE_KEY}.
|
|
118
|
+
*/
|
|
119
|
+
export const CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY = 'coolify_calls_service_token_env';
|
|
120
|
+
/**
|
|
121
|
+
* The introspection API app minted for the calls service (once), and the
|
|
122
|
+
* undelivered key recorded before its secret leaves the executor
|
|
123
|
+
* (`foundry/CLAUDE.md`, platform rule 1). No `:` in either, as that rule says.
|
|
124
|
+
*/
|
|
125
|
+
export const CALLS_INTROSPECTION_SCOPE_KEY = 'zitadel_calls_introspection_app';
|
|
126
|
+
export const CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY = 'zitadel_calls_introspection_undelivered';
|
|
127
|
+
/**
|
|
128
|
+
* The token env's inputs, or null while any is missing: the host's issuer and
|
|
129
|
+
* messaging project id (`AgoraHostValues`) and the org's recorded ZITADEL org
|
|
130
|
+
* id. Null means write none of the seven, never a partial set: the calls
|
|
131
|
+
* service refuses to boot on one.
|
|
132
|
+
*/
|
|
133
|
+
export function callsTokenHostFrom(host) {
|
|
134
|
+
if (!host.issuer || !host.messagingProjectId)
|
|
135
|
+
return null;
|
|
136
|
+
return { issuer: host.issuer, messagingProjectId: host.messagingProjectId };
|
|
137
|
+
}
|
|
138
|
+
/** The value {@link CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY} holds once the env is in place; null when it cannot be written yet. */
|
|
139
|
+
export function callsTokenEnvMarker(host, recorded) {
|
|
140
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
141
|
+
if (!host || !zitadelOrgId)
|
|
142
|
+
return null;
|
|
143
|
+
return `${host.issuer} ${host.messagingProjectId} ${zitadelOrgId}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The calls service's introspection API app, in the messaging project. One per
|
|
147
|
+
* org: the project is shared by every org, so the name carries the slug (a DNS
|
|
148
|
+
* label, at most 63 characters) and only an exact match is ever deleted.
|
|
149
|
+
*/
|
|
150
|
+
export function callsIntrospectionAppName(org) {
|
|
151
|
+
return `calls-introspection-${org.slug}`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The block shown, and thrown, when the messaging project or the ZITADEL org
|
|
155
|
+
* differs from what the token env was written for: the recorded client
|
|
156
|
+
* belongs to the old project, and foundry does not replace or delete it. Null
|
|
157
|
+
* when nothing is recorded, or only the issuer differs (the client is kept).
|
|
158
|
+
*/
|
|
159
|
+
export function callsTokenEnvMovedMessage(org, host, recorded) {
|
|
160
|
+
const wanted = callsTokenEnvMarker(host, recorded);
|
|
161
|
+
const written = recorded[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY];
|
|
162
|
+
if (!wanted || !written || !recorded[CALLS_INTROSPECTION_SCOPE_KEY])
|
|
163
|
+
return null;
|
|
164
|
+
const [, oldProject, oldOrg] = written.split(' ');
|
|
165
|
+
const [, newProject, newOrg] = wanted.split(' ');
|
|
166
|
+
if (oldProject === newProject && oldOrg === newOrg)
|
|
167
|
+
return null;
|
|
168
|
+
return `${org.slug}'s calls service is blocked, not changed: the messaging project or ZITADEL org differs from the one its token env was written for (project ${oldProject} -> ${newProject}, org ${oldOrg} -> ${newOrg}); retire the ZITADEL app ${callsIntrospectionAppName(org)} (${recorded[CALLS_INTROSPECTION_SCOPE_KEY]}) in the old project by hand, then remove the recorded keys ${CALLS_INTROSPECTION_SCOPE_KEY} and ${CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY}`;
|
|
169
|
+
}
|
|
170
|
+
/** True when there is nothing to write yet, or the recorded marker says it was written. */
|
|
171
|
+
export function callsServiceHasTokenEnv(host, recorded) {
|
|
172
|
+
const marker = callsTokenEnvMarker(host, recorded);
|
|
173
|
+
return marker === null || recorded[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] === marker;
|
|
174
|
+
}
|
|
112
175
|
/**
|
|
113
176
|
* The env vars an org's Agora app should carry, and every input still
|
|
114
177
|
* missing to build them. `AGORA_CONFIG` is present only when `missing` is
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
|
-
import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, desiredAgoraEnv, } from './agora.js';
|
|
3
|
+
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, } from './agora.js';
|
|
4
4
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
5
5
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
6
6
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
@@ -117,7 +117,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
117
117
|
// (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
|
|
118
118
|
// and MAS client credentials `coolifyCalls` just recorded, the same
|
|
119
119
|
// ordering `planOwner` relies on for `zitadel:issuer`.
|
|
120
|
-
const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady, recordedIds, calls.blocked !== null);
|
|
120
|
+
const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady, recordedIds, calls.blocked !== null, 'error' in agoraHost ? null : callsTokenHostFrom(agoraHost));
|
|
121
121
|
if (callsService.step)
|
|
122
122
|
plannedSteps.push(callsService.step);
|
|
123
123
|
// #251: same mechanism as the platform's own version-change lines -- see
|
|
@@ -275,6 +275,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
275
275
|
}
|
|
276
276
|
if (calls.blocked)
|
|
277
277
|
unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
|
|
278
|
+
if (callsService.blocked) {
|
|
279
|
+
unmanaged.push({ description: callsService.blocked, key: 'coolify:calls-service' });
|
|
280
|
+
}
|
|
278
281
|
if (filesOrganisation.blocked) {
|
|
279
282
|
unmanaged.push({ description: filesOrganisation.blocked, key: 'files:organisation' });
|
|
280
283
|
}
|
|
@@ -831,25 +834,55 @@ function planCalls(org, byKey, claimed, callsReady) {
|
|
|
831
834
|
* stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
|
|
832
835
|
* already reports the platform's shared SFU as missing once.
|
|
833
836
|
*/
|
|
834
|
-
function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlocked) {
|
|
837
|
+
function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlocked, tokenHost) {
|
|
835
838
|
const key = resourceKey('coolify', 'calls-service', org.id);
|
|
836
839
|
if (org.calls.enabled && !byKey.get(key) && !callsReady) {
|
|
837
840
|
return { step: null, blocked: null };
|
|
838
841
|
}
|
|
842
|
+
// The introspection secret is shown once. One minted but never written to
|
|
843
|
+
// the app is lost, so this reports it and mints no second (platform rule 1).
|
|
844
|
+
const undelivered = recordedIds[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
|
|
845
|
+
if (org.calls.enabled &&
|
|
846
|
+
undelivered &&
|
|
847
|
+
!recordedIds[CALLS_INTROSPECTION_SCOPE_KEY] &&
|
|
848
|
+
byKey.has(key)) {
|
|
849
|
+
return {
|
|
850
|
+
step: null,
|
|
851
|
+
blocked: `${org.slug}'s calls service is blocked, not planned: its introspection client secret (ZITADEL app ${undelivered}) was minted but never written to the app and cannot be shown again; remove the recorded key ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY} (and delete the ZITADEL client) to mint a new one`,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
const moved = org.calls.enabled && byKey.has(key)
|
|
855
|
+
? callsTokenEnvMovedMessage(org, tokenHost, recordedIds)
|
|
856
|
+
: null;
|
|
857
|
+
if (moved)
|
|
858
|
+
return { step: null, blocked: moved };
|
|
839
859
|
const toggle = planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`);
|
|
860
|
+
// The host values ride in the step, as Agora's do, so the env written is
|
|
861
|
+
// the env the plan showed. Only while enabled: a park step carries none.
|
|
862
|
+
const withHost = (step) => tokenHost && org.calls.enabled
|
|
863
|
+
? {
|
|
864
|
+
...step,
|
|
865
|
+
action: {
|
|
866
|
+
...step.action,
|
|
867
|
+
desired: { ...step.action.desired, host: tokenHost },
|
|
868
|
+
},
|
|
869
|
+
}
|
|
870
|
+
: step;
|
|
840
871
|
if (toggle)
|
|
841
|
-
return { step: toggle, blocked: null };
|
|
842
|
-
// A live, enabled app made before the internal name
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
//
|
|
872
|
+
return { step: withHost(toggle), blocked: null };
|
|
873
|
+
// A live, enabled app made before the internal name or the token env
|
|
874
|
+
// existed has no step from the toggle. Run the step again until the
|
|
875
|
+
// executor has recorded both markers, but only while it can succeed: the
|
|
876
|
+
// SFU ready and the Matrix stack not blocked.
|
|
877
|
+
const missingName = !callsServiceHasName(org, recordedIds);
|
|
878
|
+
const missingTokenEnv = !callsServiceHasTokenEnv(tokenHost, recordedIds);
|
|
846
879
|
if (org.calls.enabled &&
|
|
847
880
|
callsReady &&
|
|
848
881
|
!callsBlocked &&
|
|
849
882
|
byKey.has(key) &&
|
|
850
|
-
|
|
883
|
+
(missingName || missingTokenEnv)) {
|
|
851
884
|
return {
|
|
852
|
-
step: {
|
|
885
|
+
step: withHost({
|
|
853
886
|
action: {
|
|
854
887
|
system: 'coolify',
|
|
855
888
|
kind: 'calls-service',
|
|
@@ -857,11 +890,13 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
|
|
|
857
890
|
desired: { enabled: true },
|
|
858
891
|
},
|
|
859
892
|
step: {
|
|
860
|
-
description:
|
|
893
|
+
description: missingName
|
|
894
|
+
? `set the calls service's internal name for ${org.slug}`
|
|
895
|
+
: `set the calls service's ZITADEL token env for ${org.slug}`,
|
|
861
896
|
destroys: false,
|
|
862
897
|
migrates: false,
|
|
863
898
|
},
|
|
864
|
-
},
|
|
899
|
+
}),
|
|
865
900
|
blocked: null,
|
|
866
901
|
};
|
|
867
902
|
}
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -1767,6 +1767,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1767
1767
|
* 1); its own database is never touched from that branch.
|
|
1768
1768
|
*/
|
|
1769
1769
|
private coolifyCallsService;
|
|
1770
|
+
/**
|
|
1771
|
+
* The seven ZITADEL token env vars the calls service needs to accept
|
|
1772
|
+
* Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
|
|
1773
|
+
* inputs present, so the set is whole or absent.
|
|
1774
|
+
*
|
|
1775
|
+
* The introspection client is an API app in the messaging project (the
|
|
1776
|
+
* audience the token carries), minted once the way `platform-providers.ts`
|
|
1777
|
+
* mints the files one: its id is recorded under an undelivered key and
|
|
1778
|
+
* persisted before the secret leaves this method, the secret goes to
|
|
1779
|
+
* `deliverSecret` with retries and to the app's env, never to a log, error
|
|
1780
|
+
* or return value, and the guard is recorded by the caller after the env
|
|
1781
|
+
* write. A re-run reads the client id and secret back from the app's own
|
|
1782
|
+
* env and mints nothing.
|
|
1783
|
+
*/
|
|
1784
|
+
private callsTokenEnvs;
|
|
1770
1785
|
/**
|
|
1771
1786
|
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
1772
1787
|
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
package/dist/plan/executor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
3
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
4
|
import { APP_ENTRY_KEY, appEntry } from '../org.js';
|
|
5
|
-
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, callsServiceHasName, callsServiceInternalName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
5
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
6
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
7
7
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
8
8
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
@@ -2452,21 +2452,118 @@ export class FoundryStepExecutor {
|
|
|
2452
2452
|
{ key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
|
|
2453
2453
|
{ key: 'CALLS_ORIGIN', value: `https://${agoraHostnameFor(org)}` },
|
|
2454
2454
|
{ key: 'CALLS_HOST_ADAPTER', value: 'production' },
|
|
2455
|
+
// Deliberately kept as it was, not widened: the calls service refuses to
|
|
2456
|
+
// boot with no `CALLS_TRUSTED_IP_HEADER` (`config.mjs` has no default),
|
|
2457
|
+
// so unsetting it would stop the service. It keys the refused-bearer
|
|
2458
|
+
// limiter, which is only as honest as the proxy in front overwriting
|
|
2459
|
+
// this header (https://github.com/wtfalch/calls/issues/82).
|
|
2455
2460
|
{ key: 'CALLS_TRUSTED_IP_HEADER', value: 'cf-connecting-ip' },
|
|
2456
2461
|
{ key: 'PORT', value: CALLS_SERVICE_PORT },
|
|
2457
2462
|
];
|
|
2458
2463
|
if (bindingSecret)
|
|
2459
2464
|
envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
|
|
2465
|
+
// The seven ZITADEL token vars, all or none (the service refuses to boot
|
|
2466
|
+
// on a partial set). Deliberately absent: `CALLS_CHAT_URL` and
|
|
2467
|
+
// `CALLS_CHAT_TOKEN`. Foundry does not deploy chat
|
|
2468
|
+
// (https://github.com/wtfalch/chat/issues/40), so with both unset the
|
|
2469
|
+
// calls service admits only `event:` rooms.
|
|
2470
|
+
const tokenHost = action.desired.host;
|
|
2471
|
+
const tokenMarker = callsTokenEnvMarker(tokenHost ?? null, context.recordedIds());
|
|
2472
|
+
const token = tokenHost && tokenMarker
|
|
2473
|
+
? await this.callsTokenEnvs(org, app.appId, tokenHost, context)
|
|
2474
|
+
: null;
|
|
2475
|
+
if (token)
|
|
2476
|
+
envs.push(...token.envs);
|
|
2460
2477
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
2478
|
+
// Recorded after the write, so a failure before it leaves the app
|
|
2479
|
+
// unguarded and the planner reports the lost secret instead of minting again.
|
|
2480
|
+
if (token)
|
|
2481
|
+
context.recordScopeId(CALLS_INTROSPECTION_SCOPE_KEY, token.appId);
|
|
2461
2482
|
// A container reads its name and env only at start, so a running app is
|
|
2462
|
-
// restarted once, when this run is the one that gave it its name
|
|
2463
|
-
// rule `coolifyApp` follows when an existing app's
|
|
2464
|
-
|
|
2483
|
+
// restarted once, when this run is the one that gave it its name or its
|
|
2484
|
+
// token env (the same rule `coolifyApp` follows when an existing app's
|
|
2485
|
+
// env changed).
|
|
2486
|
+
const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
|
|
2487
|
+
if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
|
|
2465
2488
|
await this.ports.coolify.restart(app.appId);
|
|
2466
2489
|
}
|
|
2467
2490
|
context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
|
|
2491
|
+
if (token && tokenMarker)
|
|
2492
|
+
context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
|
|
2468
2493
|
return { resourceId: app.appId };
|
|
2469
2494
|
}
|
|
2495
|
+
/**
|
|
2496
|
+
* The seven ZITADEL token env vars the calls service needs to accept
|
|
2497
|
+
* Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
|
|
2498
|
+
* inputs present, so the set is whole or absent.
|
|
2499
|
+
*
|
|
2500
|
+
* The introspection client is an API app in the messaging project (the
|
|
2501
|
+
* audience the token carries), minted once the way `platform-providers.ts`
|
|
2502
|
+
* mints the files one: its id is recorded under an undelivered key and
|
|
2503
|
+
* persisted before the secret leaves this method, the secret goes to
|
|
2504
|
+
* `deliverSecret` with retries and to the app's env, never to a log, error
|
|
2505
|
+
* or return value, and the guard is recorded by the caller after the env
|
|
2506
|
+
* write. A re-run reads the client id and secret back from the app's own
|
|
2507
|
+
* env and mints nothing.
|
|
2508
|
+
*/
|
|
2509
|
+
async callsTokenEnvs(org, appId, host, context) {
|
|
2510
|
+
const recorded = context.recordedIds();
|
|
2511
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
2512
|
+
const scope = (clientId, clientSecret) => [
|
|
2513
|
+
{ key: 'CALLS_ISSUER', value: host.issuer },
|
|
2514
|
+
{ key: 'CALLS_AUDIENCE', value: host.messagingProjectId },
|
|
2515
|
+
{ key: 'CALLS_ORGANISATION_ID', value: zitadelOrgId },
|
|
2516
|
+
// Chat's own scope (`apps/chat-service`): the audience, and `estate`.
|
|
2517
|
+
{ key: 'CALLS_APPLICATION_ID', value: host.messagingProjectId },
|
|
2518
|
+
{ key: 'CALLS_PLATFORM_ID', value: 'estate' },
|
|
2519
|
+
{ key: 'CALLS_INTROSPECTION_CLIENT_ID', value: clientId },
|
|
2520
|
+
{ key: 'CALLS_INTROSPECTION_CLIENT_SECRET', value: clientSecret },
|
|
2521
|
+
];
|
|
2522
|
+
const done = recorded[CALLS_INTROSPECTION_SCOPE_KEY];
|
|
2523
|
+
const undelivered = recorded[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
|
|
2524
|
+
if (undelivered && !done) {
|
|
2525
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the introspection client secret (ZITADEL app ${undelivered}) was minted but never written; remove the recorded key ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
2526
|
+
}
|
|
2527
|
+
// A changed project or org is never replaced here (see the message); an
|
|
2528
|
+
// issuer-only change keeps the client and rewrites the env below.
|
|
2529
|
+
const moved = callsTokenEnvMovedMessage(org, host, recorded);
|
|
2530
|
+
if (moved)
|
|
2531
|
+
throw new Error(`coolify:calls-service: ${moved}`);
|
|
2532
|
+
if (done) {
|
|
2533
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
2534
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
2535
|
+
const clientId = read('CALLS_INTROSPECTION_CLIENT_ID');
|
|
2536
|
+
const clientSecret = read('CALLS_INTROSPECTION_CLIENT_SECRET');
|
|
2537
|
+
if (!clientId || !clientSecret) {
|
|
2538
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the app; remove the recorded keys ${CALLS_INTROSPECTION_SCOPE_KEY} and ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
|
|
2539
|
+
}
|
|
2540
|
+
return { envs: scope(clientId, clientSecret), appId: done };
|
|
2541
|
+
}
|
|
2542
|
+
// An app by this name with no recorded secret is a crash between create
|
|
2543
|
+
// and record: nothing was delivered, so it is deleted and made again.
|
|
2544
|
+
const stale = (await this.ports.zitadel.listApps(zitadelOrgId, host.messagingProjectId)).apps.filter((app) => app.name === callsIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
|
|
2545
|
+
for (const app of stale) {
|
|
2546
|
+
try {
|
|
2547
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, host.messagingProjectId, app.id);
|
|
2548
|
+
}
|
|
2549
|
+
catch (error) {
|
|
2550
|
+
if (!isNotFoundError(error))
|
|
2551
|
+
throw error;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
const client = await this.ports.zitadel.createApiApp(zitadelOrgId, host.messagingProjectId, {
|
|
2555
|
+
name: callsIntrospectionAppName(org),
|
|
2556
|
+
});
|
|
2557
|
+
context.recordScopeId(CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
2558
|
+
await context.persistScopeIds();
|
|
2559
|
+
if (!(await this.deliverWithRetry(`zitadel:calls:${org.slug}:introspection`, client.clientSecret))) {
|
|
2560
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
|
|
2561
|
+
}
|
|
2562
|
+
return {
|
|
2563
|
+
envs: scope(client.clientId, client.clientSecret),
|
|
2564
|
+
appId: client.appId,
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2470
2567
|
/**
|
|
2471
2568
|
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
2472
2569
|
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { readLiveResources, readLivePlatformResources } from './live.js';
|
|
|
3
3
|
export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
|
|
4
4
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
5
5
|
export type { PlatformPlanDetail } from './platform-diff.js';
|
|
6
|
-
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
|
|
6
|
+
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
|
|
7
7
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
|
|
8
8
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
9
9
|
export type { PlatformOrgClientFactories } from './platform-org-ports.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { readLiveResources, readLivePlatformResources } from './live.js';
|
|
2
2
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
3
|
-
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
|
|
3
|
+
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
|
|
4
4
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
5
5
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
6
6
|
export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
|
|
@@ -299,6 +299,14 @@ export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git
|
|
|
299
299
|
* own internal address, never its admin credential.
|
|
300
300
|
*/
|
|
301
301
|
export declare const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = "mail_internal_url";
|
|
302
|
+
/**
|
|
303
|
+
* #346: the mail ZITADEL project's id in the platform's own sign-in, the
|
|
304
|
+
* {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY} pattern for mail --
|
|
305
|
+
* `provisionPlatformMail` finds or creates it and uses the id as the OIDC
|
|
306
|
+
* audience Stalwart's directory checks an access token's `aud` claim
|
|
307
|
+
* against, so no host has to register one by hand.
|
|
308
|
+
*/
|
|
309
|
+
export declare const PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY = "zitadel_platform_mail_project";
|
|
302
310
|
/**
|
|
303
311
|
* #177: the one host the platform's mail server takes SMTP/MX on, and so the
|
|
304
312
|
* `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
|
|
@@ -330,6 +330,14 @@ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
|
|
|
330
330
|
* own internal address, never its admin credential.
|
|
331
331
|
*/
|
|
332
332
|
export const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = 'mail_internal_url';
|
|
333
|
+
/**
|
|
334
|
+
* #346: the mail ZITADEL project's id in the platform's own sign-in, the
|
|
335
|
+
* {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY} pattern for mail --
|
|
336
|
+
* `provisionPlatformMail` finds or creates it and uses the id as the OIDC
|
|
337
|
+
* audience Stalwart's directory checks an access token's `aud` claim
|
|
338
|
+
* against, so no host has to register one by hand.
|
|
339
|
+
*/
|
|
340
|
+
export const PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_mail_project';
|
|
333
341
|
/**
|
|
334
342
|
* #177: the one host the platform's mail server takes SMTP/MX on, and so the
|
|
335
343
|
* `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
|
|
@@ -248,10 +248,10 @@ export declare const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = "foundry:platform:adm
|
|
|
248
248
|
* instance's own URL and the recovery-admin credential this step generated
|
|
249
249
|
* ({@link PlatformMailDeps.stalwartForInstance}), the same way
|
|
250
250
|
* `zitadelForInstance` is built from a freshly issued instance's issuer URL.
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* `provisionPlatformMail`'s own doc
|
|
254
|
-
* `StalwartClient.createApiKey`.
|
|
251
|
+
* {@link defaultStalwartForInstance} is the real implementation, wrapping
|
|
252
|
+
* `@wtfalch/email/postmaster`'s `openInstance`, `applyObjects` with
|
|
253
|
+
* `platformMailSettingsObjects` (see `provisionPlatformMail`'s own doc
|
|
254
|
+
* comment for why not `mailObjects`) and `StalwartClient.createApiKey`.
|
|
255
255
|
*/
|
|
256
256
|
export interface PlatformMailAdminPort {
|
|
257
257
|
/**
|
|
@@ -261,6 +261,8 @@ export interface PlatformMailAdminPort {
|
|
|
261
261
|
*/
|
|
262
262
|
applySettings(input: {
|
|
263
263
|
mailHost: string;
|
|
264
|
+
/** The platform's own apex domain, `platformMailSettingsObjects`'s `usernameDomain`. */
|
|
265
|
+
apex: string;
|
|
264
266
|
oidc: {
|
|
265
267
|
issuerUrl: string;
|
|
266
268
|
audience: string;
|
|
@@ -269,6 +271,26 @@ export interface PlatformMailAdminPort {
|
|
|
269
271
|
/** `StalwartClient.createApiKey`'s own signature. */
|
|
270
272
|
createApiKey(description: string, permissions: readonly string[] | 'inherit'): Promise<string>;
|
|
271
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* #346: the real {@link PlatformMailAdminPort}, built from the instance's URL
|
|
276
|
+
* and the recovery-admin credential `provisionPlatformMail` just read back.
|
|
277
|
+
* `stalwartForInstance` defaults to this, so a host need not build its own --
|
|
278
|
+
* the narrower settings write `provisionPlatformMail`'s own doc comment
|
|
279
|
+
* explains ("Why not `mailObjects`/`applyObjects`" as-is) instead goes
|
|
280
|
+
* through `platformMailSettingsObjects`, which `applyObjects` resolves and
|
|
281
|
+
* writes the same way `mailObjects` would.
|
|
282
|
+
*
|
|
283
|
+
* Exported only so a unit test can call it directly against a mocked
|
|
284
|
+
* `@wtfalch/email/postmaster` and check that `url`/`user`/`password` reach
|
|
285
|
+
* `openInstance` and `apex` reaches `applySettings` -- every other test
|
|
286
|
+
* reaches this function only indirectly, through an injected
|
|
287
|
+
* `stalwartForInstance` fake.
|
|
288
|
+
*/
|
|
289
|
+
export declare function defaultStalwartForInstance(input: {
|
|
290
|
+
url: string;
|
|
291
|
+
user: string;
|
|
292
|
+
password: string;
|
|
293
|
+
}): PlatformMailAdminPort;
|
|
272
294
|
/**
|
|
273
295
|
* #169: everything {@link provisionPlatformMail} needs beyond
|
|
274
296
|
* {@link PlatformStepExecutionContext}, mirroring {@link PlatformAuthDeps}'s
|
|
@@ -278,18 +300,22 @@ export interface PlatformMailDeps {
|
|
|
278
300
|
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy'>;
|
|
279
301
|
cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'>;
|
|
280
302
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
303
|
+
* Must target the platform's own ZITADEL instance, or `zitadelForInstance`
|
|
304
|
+
* must be set. `provisionPlatformMail` finds or creates a `mail` project
|
|
305
|
+
* in it (the {@link PlatformFilesDeps}/{@link PlatformArchonDeps} pattern)
|
|
306
|
+
* and uses the project's id as the OIDC audience Stalwart's directory
|
|
307
|
+
* checks an access token's `aud` claim against -- #346, no host has to
|
|
308
|
+
* register a resource API for this by hand any more.
|
|
286
309
|
*/
|
|
287
|
-
|
|
310
|
+
zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject'>;
|
|
311
|
+
/** {@link PlatformArchonDeps.zitadelForInstance}, for Stalwart's OIDC directory. */
|
|
312
|
+
zitadelForInstance?: (issuerUrl: string) => PlatformMailDeps['zitadel'];
|
|
288
313
|
/**
|
|
289
314
|
* {@link PlatformMailAdminPort}, built from the instance this step just
|
|
290
|
-
* deployed and the recovery credential it just read back.
|
|
315
|
+
* deployed and the recovery credential it just read back. Defaults to
|
|
316
|
+
* {@link defaultStalwartForInstance}.
|
|
291
317
|
*/
|
|
292
|
-
stalwartForInstance(input: {
|
|
318
|
+
stalwartForInstance?(input: {
|
|
293
319
|
url: string;
|
|
294
320
|
user: string;
|
|
295
321
|
password: string;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
|
|
2
2
|
import { isIPv4 } from 'node:net';
|
|
3
|
+
import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
|
|
3
4
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
4
5
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
5
6
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
6
7
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
7
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
8
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
8
9
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
9
10
|
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
10
11
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
@@ -1389,6 +1390,32 @@ const PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY = 'mail_deployed_git_ref';
|
|
|
1389
1390
|
* {@link PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} records in that case.
|
|
1390
1391
|
*/
|
|
1391
1392
|
export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
|
|
1393
|
+
/**
|
|
1394
|
+
* #346: the real {@link PlatformMailAdminPort}, built from the instance's URL
|
|
1395
|
+
* and the recovery-admin credential `provisionPlatformMail` just read back.
|
|
1396
|
+
* `stalwartForInstance` defaults to this, so a host need not build its own --
|
|
1397
|
+
* the narrower settings write `provisionPlatformMail`'s own doc comment
|
|
1398
|
+
* explains ("Why not `mailObjects`/`applyObjects`" as-is) instead goes
|
|
1399
|
+
* through `platformMailSettingsObjects`, which `applyObjects` resolves and
|
|
1400
|
+
* writes the same way `mailObjects` would.
|
|
1401
|
+
*
|
|
1402
|
+
* Exported only so a unit test can call it directly against a mocked
|
|
1403
|
+
* `@wtfalch/email/postmaster` and check that `url`/`user`/`password` reach
|
|
1404
|
+
* `openInstance` and `apex` reaches `applySettings` -- every other test
|
|
1405
|
+
* reaches this function only indirectly, through an injected
|
|
1406
|
+
* `stalwartForInstance` fake.
|
|
1407
|
+
*/
|
|
1408
|
+
export function defaultStalwartForInstance(input) {
|
|
1409
|
+
const instance = openInstance({ url: input.url, secret: input.password, user: input.user });
|
|
1410
|
+
return {
|
|
1411
|
+
async applySettings({ mailHost, apex, oidc }) {
|
|
1412
|
+
await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
|
|
1413
|
+
},
|
|
1414
|
+
createApiKey: (description, permissions) => instance.client.createApiKey(description, permissions),
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
/** The ZITADEL project and resource api Stalwart's own OIDC directory checks access tokens' `aud` claim against (#346). */
|
|
1418
|
+
const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
|
|
1392
1419
|
/**
|
|
1393
1420
|
* #169: makes `coolify:app` keyed {@link PLATFORM_MAIL_KEY} true -- Stalwart
|
|
1394
1421
|
* on Coolify with its own persistent storage, reached at
|
|
@@ -1423,8 +1450,9 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
|
|
|
1423
1450
|
* 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
|
|
1424
1451
|
* ACME either. Calling it as-is would mint an outbound relay this platform
|
|
1425
1452
|
* does not want and a credential requirement this issue does not cover, so
|
|
1426
|
-
* {@link PlatformMailAdminPort.applySettings} is a narrower
|
|
1427
|
-
*
|
|
1453
|
+
* {@link PlatformMailAdminPort.applySettings} is a narrower settings write
|
|
1454
|
+
* instead, through `platformMailSettingsObjects`: the default hostname and
|
|
1455
|
+
* the OIDC directory only.
|
|
1428
1456
|
*
|
|
1429
1457
|
* Known gaps, left for review (like #168's own "the PR stays open for
|
|
1430
1458
|
* William's review"):
|
|
@@ -1432,11 +1460,6 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
|
|
|
1432
1460
|
* Stalwart's own self-signed certificate until a follow-up wires a real
|
|
1433
1461
|
* one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
|
|
1434
1462
|
* relay it also carries).
|
|
1435
|
-
* - **`oidcAudience` is supplied, not resolved.** #168 mints foundry's own
|
|
1436
|
-
* ZITADEL admin machine user but registers no resource API for Stalwart's
|
|
1437
|
-
* access-token `aud` check; whoever supplies
|
|
1438
|
-
* {@link PlatformMailDeps.oidcAudience} must have registered one by hand
|
|
1439
|
-
* until that is built.
|
|
1440
1463
|
* - **The Lettermint team token the org app step reads still comes from
|
|
1441
1464
|
* wherever the host already reads it, not held keys.** Needs
|
|
1442
1465
|
* `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
|
|
@@ -1446,6 +1469,17 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
|
|
|
1446
1469
|
* instance -- `diff.ts`'s `planMail` already blocks the step until the
|
|
1447
1470
|
* platform's own mail is applied, so the fallback only ever served a
|
|
1448
1471
|
* plan recorded before that, and now fails loudly instead.)
|
|
1472
|
+
* - **A ZITADEL client's token may not carry the mail project's id in
|
|
1473
|
+
* `aud` by default.** #346's own "Human steps": confirm this on the first
|
|
1474
|
+
* live run, and if it does not, the client must request the mail
|
|
1475
|
+
* project's audience scope.
|
|
1476
|
+
* - **The admin API key is minted with `'inherit'` permissions**, not a
|
|
1477
|
+
* narrower set scoped to only what `applySettings` needs.
|
|
1478
|
+
* - **The undelivered-key guard records before `createApiKey` mints**, not
|
|
1479
|
+
* after -- so a crash between the two records an id (`admin.createApiKey`
|
|
1480
|
+
* has not even run) that no key was ever minted for. Harmless today
|
|
1481
|
+
* (the guard only ever blocks a retry and names an id to revoke by), but
|
|
1482
|
+
* worth tightening if `createApiKey` ever starts returning its own id.
|
|
1449
1483
|
*/
|
|
1450
1484
|
async function provisionPlatformMail(context, deps) {
|
|
1451
1485
|
const { platform } = context;
|
|
@@ -1610,6 +1644,57 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1610
1644
|
proxied: false,
|
|
1611
1645
|
});
|
|
1612
1646
|
}
|
|
1647
|
+
// ---- the mail ZITADEL project, in the platform's sign-in -------------------
|
|
1648
|
+
// #346: found or created on every run, like the files/Archon steps' own
|
|
1649
|
+
// projects -- its id is the OIDC audience Stalwart's directory checks an
|
|
1650
|
+
// access token's `aud` claim against, so no host has to register a
|
|
1651
|
+
// resource API for this by hand.
|
|
1652
|
+
const issuer = `https://${platform.auth.hostname}`;
|
|
1653
|
+
const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
|
|
1654
|
+
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
1655
|
+
const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
|
|
1656
|
+
const recordedProjectId = recorded[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY];
|
|
1657
|
+
const adminKeyRecorded = Boolean(recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]);
|
|
1658
|
+
if (adminKeyRecorded && recordedProjectId !== project.id) {
|
|
1659
|
+
// #346 round 3: the OIDC-directory write below (next block) runs only
|
|
1660
|
+
// once, while the admin key is unrecorded -- so once it is recorded, a
|
|
1661
|
+
// changed project id (the project was deleted and recreated, ZITADEL was
|
|
1662
|
+
// re-bootstrapped, or `recordedProjectId` is simply missing, from a
|
|
1663
|
+
// platform applied before this change) has no other path back to a
|
|
1664
|
+
// correct audience. Re-apply the settings write with the new id, over
|
|
1665
|
+
// the same recovery-admin credential the first run reads back, and mint
|
|
1666
|
+
// no new key -- that stays gated on the admin key being unrecorded,
|
|
1667
|
+
// below. The record is written only after `applySettings` succeeds, so a
|
|
1668
|
+
// throw here leaves the old (still-required) id in place for the next
|
|
1669
|
+
// run to retry. A project deleted out of band is noticed only here, on
|
|
1670
|
+
// the next plan/apply -- the same way the files/Archon steps' own
|
|
1671
|
+
// projects are.
|
|
1672
|
+
await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
|
|
1673
|
+
const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
|
|
1674
|
+
if (!recoveryEnv) {
|
|
1675
|
+
throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience');
|
|
1676
|
+
}
|
|
1677
|
+
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
1678
|
+
try {
|
|
1679
|
+
const admin = (deps.stalwartForInstance ?? defaultStalwartForInstance)({
|
|
1680
|
+
url: `https://${hostname}`,
|
|
1681
|
+
user: STALWART_RECOVERY_USER,
|
|
1682
|
+
password,
|
|
1683
|
+
});
|
|
1684
|
+
await admin.applySettings({
|
|
1685
|
+
mailHost,
|
|
1686
|
+
apex: platform.apex,
|
|
1687
|
+
oidc: { issuerUrl: issuer, audience: project.id },
|
|
1688
|
+
});
|
|
1689
|
+
await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
1690
|
+
}
|
|
1691
|
+
catch (error) {
|
|
1692
|
+
throw redactedMailError(error, [password]);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
else if (!recordedProjectId) {
|
|
1696
|
+
await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
1697
|
+
}
|
|
1613
1698
|
// ---- OIDC directory and foundry's own admin API key, once --------------------
|
|
1614
1699
|
if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
|
|
1615
1700
|
const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
|
|
@@ -1623,14 +1708,15 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1623
1708
|
}
|
|
1624
1709
|
const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
|
|
1625
1710
|
try {
|
|
1626
|
-
const admin = deps.stalwartForInstance({
|
|
1711
|
+
const admin = (deps.stalwartForInstance ?? defaultStalwartForInstance)({
|
|
1627
1712
|
url: `https://${hostname}`,
|
|
1628
1713
|
user: STALWART_RECOVERY_USER,
|
|
1629
1714
|
password,
|
|
1630
1715
|
});
|
|
1631
1716
|
await admin.applySettings({
|
|
1632
1717
|
mailHost,
|
|
1633
|
-
|
|
1718
|
+
apex: platform.apex,
|
|
1719
|
+
oidc: { issuerUrl: issuer, audience: project.id },
|
|
1634
1720
|
});
|
|
1635
1721
|
// Recorded and persisted before the secret leaves this function: if
|
|
1636
1722
|
// delivery fails for good, the next plan reports the lost key rather
|
|
@@ -1696,9 +1782,9 @@ async function resolveMailServer(coolify, recordedUuid) {
|
|
|
1696
1782
|
}
|
|
1697
1783
|
/**
|
|
1698
1784
|
* The mail step's own reasons it cannot run yet, as fixed text: missing
|
|
1699
|
-
* dependencies
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1785
|
+
* dependencies, a floating image tag, a server with no usable address.
|
|
1786
|
+
* Skipped once the step is applied at the declared values, so a live app is
|
|
1787
|
+
* not blocked by a later Coolify change to the server's address.
|
|
1702
1788
|
*/
|
|
1703
1789
|
async function mailBlockedReason(platform, mail, recordedIds) {
|
|
1704
1790
|
if (!mail) {
|
|
@@ -1710,9 +1796,6 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
1710
1796
|
recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
|
|
1711
1797
|
return null;
|
|
1712
1798
|
}
|
|
1713
|
-
if (!mail.oidcAudience || mail.oidcAudience.trim() === '') {
|
|
1714
|
-
return "the platform's mail server is blocked, not planned: the host supplies no oidcAudience, the ZITADEL resource id Stalwart's OIDC directory checks access tokens against";
|
|
1715
|
-
}
|
|
1716
1799
|
const tagBlocked = mailImageTagBlocked(platform.mail.gitRef);
|
|
1717
1800
|
if (tagBlocked)
|
|
1718
1801
|
return tagBlocked;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@wtfalch/audit": "0.6.0",
|
|
44
44
|
"@wtfalch/auth": "0.16.0",
|
|
45
|
-
"@wtfalch/email": "0.
|
|
45
|
+
"@wtfalch/email": "^0.17.0",
|
|
46
46
|
"@wtfalch/providers": "0.6.0",
|
|
47
47
|
"ssh2": "1.17.0",
|
|
48
48
|
"zod": "4.6.5"
|