@wtfalch/foundry 0.13.0 → 0.14.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/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/package.json +1 -1
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
|