@wtfalch/foundry 0.18.0 → 0.19.1
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/bootstrap/declare.d.ts +11 -2
- package/dist/bootstrap/declare.js +17 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/plan/agora.d.ts +36 -0
- package/dist/plan/agora.js +38 -0
- package/dist/plan/boule.d.ts +8 -0
- package/dist/plan/boule.js +8 -0
- package/dist/plan/chat-matrix.d.ts +3 -0
- package/dist/plan/chat-matrix.js +2 -0
- package/dist/plan/diff.js +120 -36
- package/dist/plan/executor.d.ts +51 -5
- package/dist/plan/executor.js +209 -23
- package/dist/plan/login-host.d.ts +15 -11
- package/dist/plan/login-host.js +16 -16
- package/dist/plan/platform-org-ports.d.ts +1 -0
- package/dist/plan/platform-org-ports.js +3 -0
- package/dist/plan/platform-providers.d.ts +3 -2
- package/dist/plan/platform-providers.js +29 -11
- package/dist/plan/providers-readers.d.ts +34 -11
- package/dist/plan/providers-readers.js +72 -15
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -2,8 +2,8 @@ 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, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
|
|
5
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, 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, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
-
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
+
import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
7
7
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
8
8
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
9
9
|
import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
@@ -14,7 +14,7 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
|
|
|
14
14
|
import { EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
15
15
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
16
16
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
17
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
17
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
18
18
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
19
19
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
20
20
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
@@ -240,10 +240,14 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
|
|
|
240
240
|
* followed rather than reopened (#164 "Decide" 1). The agora repo's own
|
|
241
241
|
* Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
|
|
242
242
|
* from a build built at the repository root, the same one-repo shape
|
|
243
|
-
* `CMS_REPO`'s own two apps use
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
243
|
+
* `CMS_REPO`'s own two apps use. This comment used to claim that needed no
|
|
244
|
+
* `ports_exposes` of its own, on the theory that Coolify's github-app build
|
|
245
|
+
* reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
|
|
246
|
+
* apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
|
|
247
|
+
* runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
|
|
248
|
+
* Traefik is left routing to a port nothing listens on, and a company's
|
|
249
|
+
* Agora answers 502 behind the tunnel. Seen live on Company Included,
|
|
250
|
+
* 2026-10-05.
|
|
247
251
|
*/
|
|
248
252
|
const AGORA_REPO = 'wtfalch/agora';
|
|
249
253
|
const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
|
|
@@ -665,7 +669,7 @@ export class FoundryStepExecutor {
|
|
|
665
669
|
case 'cloudflare:tunnel':
|
|
666
670
|
return this.cloudflareTunnel(action, org, context);
|
|
667
671
|
case 'cloudflare:zone':
|
|
668
|
-
return this.cloudflareZone(action, org);
|
|
672
|
+
return this.cloudflareZone(action, org, context);
|
|
669
673
|
case 'cloudflare:access':
|
|
670
674
|
return this.cloudflareAccess(action, org);
|
|
671
675
|
case 'cloudflare:ratelimit':
|
|
@@ -1232,11 +1236,16 @@ export class FoundryStepExecutor {
|
|
|
1232
1236
|
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
1233
1237
|
}
|
|
1234
1238
|
const host = action.desired.host;
|
|
1235
|
-
|
|
1236
|
-
//
|
|
1237
|
-
|
|
1239
|
+
// The platform's own shared sign-in host: already trusted and routed by
|
|
1240
|
+
// the shared instance, so it gets no trusted domain or binding here.
|
|
1241
|
+
const shared = action.desired.shared === true;
|
|
1238
1242
|
const clientIds = loginHostClientIds(recorded);
|
|
1239
|
-
|
|
1243
|
+
if (!shared) {
|
|
1244
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
1245
|
+
// zitadel-login's own routing table, written before any app is repointed
|
|
1246
|
+
// so no app ever points at a host with no binding.
|
|
1247
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
1248
|
+
}
|
|
1240
1249
|
// Only apps whose client id the binding now holds: any other app in the
|
|
1241
1250
|
// project would lose sign-in. The rest move on a later run, once the
|
|
1242
1251
|
// recorded client ids differ from the binding and replan this step.
|
|
@@ -1337,7 +1346,10 @@ export class FoundryStepExecutor {
|
|
|
1337
1346
|
// the binding and replans the step next run; a nonempty pending list
|
|
1338
1347
|
// does too (`planLoginHost`), independent of the host comparison that
|
|
1339
1348
|
// normally alone decides it.
|
|
1340
|
-
|
|
1349
|
+
if (shared)
|
|
1350
|
+
context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
|
|
1351
|
+
else
|
|
1352
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
1341
1353
|
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
1342
1354
|
return { resourceId: host };
|
|
1343
1355
|
}
|
|
@@ -3067,6 +3079,74 @@ export class FoundryStepExecutor {
|
|
|
3067
3079
|
}
|
|
3068
3080
|
}
|
|
3069
3081
|
// ---- chat -----------------------------------------------------------------
|
|
3082
|
+
/**
|
|
3083
|
+
* The chat service's introspection client: an API app in the org's
|
|
3084
|
+
* messaging project (the audience Agora's token carries), so ZITADEL's
|
|
3085
|
+
* introspection accepts it. The chat service authenticates as it with
|
|
3086
|
+
* `client_secret_basic`. Minted once, the way {@link callsTokenEnvs} does:
|
|
3087
|
+
* the id is recorded under an undelivered key and persisted before the
|
|
3088
|
+
* secret leaves, the secret goes to `deliverSecret` and to the caller's env
|
|
3089
|
+
* write, never to a log, error or return beyond that. A re-run reads the id
|
|
3090
|
+
* and secret back from the service's own env and mints nothing. `changed`
|
|
3091
|
+
* is true only for a fresh mint.
|
|
3092
|
+
*/
|
|
3093
|
+
async ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context) {
|
|
3094
|
+
const recorded = context.recordedIds();
|
|
3095
|
+
const done = recorded[CHAT_INTROSPECTION_SCOPE_KEY];
|
|
3096
|
+
const undelivered = recorded[CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
|
|
3097
|
+
if (done) {
|
|
3098
|
+
const envs = await this.ports.coolify.listEnvs(serviceAppId);
|
|
3099
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
3100
|
+
const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
|
|
3101
|
+
const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
|
|
3102
|
+
if (!clientId || !clientSecret) {
|
|
3103
|
+
throw new Error(`coolify:chat for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the service; remove the recorded keys ${CHAT_INTROSPECTION_SCOPE_KEY} and ${CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
|
|
3104
|
+
}
|
|
3105
|
+
return { clientId, clientSecret, appId: done, changed: false };
|
|
3106
|
+
}
|
|
3107
|
+
if (undelivered) {
|
|
3108
|
+
// A minted client with no marker: a crash or failure after the mint.
|
|
3109
|
+
// Both values on the service mean the write landed and only the marker
|
|
3110
|
+
// was lost; otherwise nothing usable was delivered, and the app is
|
|
3111
|
+
// deleted below and made again.
|
|
3112
|
+
const envs = await this.ports.coolify.listEnvs(serviceAppId);
|
|
3113
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
3114
|
+
const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
|
|
3115
|
+
const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
|
|
3116
|
+
if (clientId && clientSecret) {
|
|
3117
|
+
return { clientId, clientSecret, appId: undelivered, changed: false };
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
// An app by this name with no recorded secret is a crash between create
|
|
3121
|
+
// and record: nothing was delivered, so it is deleted and made again.
|
|
3122
|
+
const stale = (await this.ports.zitadel.listApps(zitadelOrgId, projectId)).apps.filter((app) => app.name === chatIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
|
|
3123
|
+
const staleIds = new Set(stale.map((app) => app.id));
|
|
3124
|
+
if (undelivered)
|
|
3125
|
+
staleIds.add(undelivered);
|
|
3126
|
+
for (const id of staleIds) {
|
|
3127
|
+
try {
|
|
3128
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, id);
|
|
3129
|
+
}
|
|
3130
|
+
catch (error) {
|
|
3131
|
+
if (!isNotFoundError(error))
|
|
3132
|
+
throw error;
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
const client = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
|
|
3136
|
+
name: chatIntrospectionAppName(org),
|
|
3137
|
+
});
|
|
3138
|
+
context.recordScopeId(CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
3139
|
+
await context.persistScopeIds();
|
|
3140
|
+
if (!(await this.deliverWithRetry(`zitadel:chat:${org.slug}:introspection`, client.clientSecret))) {
|
|
3141
|
+
throw new Error(`coolify:chat for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
|
|
3142
|
+
}
|
|
3143
|
+
return {
|
|
3144
|
+
clientId: client.clientId,
|
|
3145
|
+
clientSecret: client.clientSecret,
|
|
3146
|
+
appId: client.appId,
|
|
3147
|
+
changed: true,
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3070
3150
|
/**
|
|
3071
3151
|
* Creates the `chat.admin` role on the org's messaging project and grants
|
|
3072
3152
|
* it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
|
|
@@ -3135,6 +3215,29 @@ export class FoundryStepExecutor {
|
|
|
3135
3215
|
context.recordScopeId(markerKey, '1');
|
|
3136
3216
|
return { resourceId: serviceAppId };
|
|
3137
3217
|
}
|
|
3218
|
+
// `planChat` plans this for a live chat whose service has no
|
|
3219
|
+
// introspection client: mint it once, push only its two env vars and
|
|
3220
|
+
// redeploy. Never the re-key paths or a fresh provision.
|
|
3221
|
+
if (action.desired.refreshIntrospection === true) {
|
|
3222
|
+
const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
|
|
3223
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
3224
|
+
const projectId = recorded[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
|
|
3225
|
+
if (!serviceAppId || !zitadelOrgId || !projectId) {
|
|
3226
|
+
throw new Error(`coolify:chat introspection for ${org.slug}: the chat service app, the ZITADEL org or the messaging project is not recorded yet`);
|
|
3227
|
+
}
|
|
3228
|
+
const hadMarker = Boolean(recorded[CHAT_INTROSPECTION_SCOPE_KEY]);
|
|
3229
|
+
const client = await this.ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context);
|
|
3230
|
+
await this.ports.coolify.bulkUpsertEnvs(serviceAppId, [
|
|
3231
|
+
{ key: 'CHAT_INTROSPECTION_CLIENT_ID', value: client.clientId },
|
|
3232
|
+
{ key: 'CHAT_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
|
|
3233
|
+
]);
|
|
3234
|
+
// Deploy first, marker last: a failed deploy leaves the marker unset,
|
|
3235
|
+
// so the next plan still carries this step and the container gets the env.
|
|
3236
|
+
if (client.changed || !hadMarker)
|
|
3237
|
+
await this.ports.coolify.deploy(serviceAppId);
|
|
3238
|
+
context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, client.appId);
|
|
3239
|
+
return { resourceId: serviceAppId };
|
|
3240
|
+
}
|
|
3138
3241
|
if (action.desired.usesDomain === false) {
|
|
3139
3242
|
throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
|
|
3140
3243
|
}
|
|
@@ -3268,6 +3371,7 @@ export class FoundryStepExecutor {
|
|
|
3268
3371
|
rsaKeyPem: secrets.value.masRsaKeyPem,
|
|
3269
3372
|
ecKeyPem: secrets.value.masEcKeyPem,
|
|
3270
3373
|
}));
|
|
3374
|
+
const introspection = await this.ensureChatIntrospection(org, zitadelOrgId, project.id, service.appId, context);
|
|
3271
3375
|
await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
|
|
3272
3376
|
orgSlug: org.slug,
|
|
3273
3377
|
companyName: org.displayName,
|
|
@@ -3277,6 +3381,8 @@ export class FoundryStepExecutor {
|
|
|
3277
3381
|
agoraHost,
|
|
3278
3382
|
masClientId: secrets.value.masClientId,
|
|
3279
3383
|
masClientSecret: secrets.value.masClientSecret,
|
|
3384
|
+
introspectionClientId: introspection.clientId,
|
|
3385
|
+
introspectionClientSecret: introspection.clientSecret,
|
|
3280
3386
|
}));
|
|
3281
3387
|
// Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
|
|
3282
3388
|
// (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
|
|
@@ -3294,11 +3400,14 @@ export class FoundryStepExecutor {
|
|
|
3294
3400
|
for (const app of [mas, synapse, service]) {
|
|
3295
3401
|
if (!app.existed ||
|
|
3296
3402
|
secrets.changed ||
|
|
3297
|
-
(app === service && serviceDbPush.changed) ||
|
|
3403
|
+
(app === service && (serviceDbPush.changed || introspection.changed)) ||
|
|
3298
3404
|
!isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
|
|
3299
3405
|
await this.ports.coolify.deploy(app.appId);
|
|
3300
3406
|
}
|
|
3301
3407
|
}
|
|
3408
|
+
// After the write and the deploys, so a failure before them leaves the
|
|
3409
|
+
// marker unset and the next apply repeats the step.
|
|
3410
|
+
context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, introspection.appId);
|
|
3302
3411
|
// #456: recorded here too, not only on the `refreshDatabaseSplit` replan
|
|
3303
3412
|
// path above -- without it a fresh provision's very first apply never
|
|
3304
3413
|
// sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
|
|
@@ -3603,11 +3712,25 @@ export class FoundryStepExecutor {
|
|
|
3603
3712
|
* actually differs, or whenever the last restart this step confirmed
|
|
3604
3713
|
* ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
|
|
3605
3714
|
* applied before this existed, whose live env already matched desired
|
|
3606
|
-
* with nothing ever confirmed running it).
|
|
3607
|
-
*
|
|
3608
|
-
*
|
|
3609
|
-
*
|
|
3610
|
-
*
|
|
3715
|
+
* with nothing ever confirmed running it). The activation marker is
|
|
3716
|
+
* recorded last, after the restart (if any) returns without throwing, so a
|
|
3717
|
+
* failed restart leaves it stale and the next plan replans this step even
|
|
3718
|
+
* though Coolify's own env store already matches desired.
|
|
3719
|
+
*
|
|
3720
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
3721
|
+
* ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
|
|
3722
|
+
* sets `instant_deploy`, and Coolify defaults it to false, so without an
|
|
3723
|
+
* explicit deploy the app sat created but never built or started.
|
|
3724
|
+
*
|
|
3725
|
+
* #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
|
|
3726
|
+
* itself, so Traefik has a port to route to from the moment the app
|
|
3727
|
+
* exists. An app `findOrCreateCallsApp` instead *found* may predate this
|
|
3728
|
+
* fix and carry no `ports_exposes` at all (or the wrong one), so its live
|
|
3729
|
+
* value is read and, when it differs, patched -- and, since the container
|
|
3730
|
+
* listens on `ports_exposes` only at its next deploy (the same rule
|
|
3731
|
+
* `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
|
|
3732
|
+
* there), redeployed even on a run that already has its one #504 deploy
|
|
3733
|
+
* behind it.
|
|
3611
3734
|
*/
|
|
3612
3735
|
async coolifyAgora(action, org, context) {
|
|
3613
3736
|
// The host values the plan read, carried in the step: never read again
|
|
@@ -3637,9 +3760,22 @@ export class FoundryStepExecutor {
|
|
|
3637
3760
|
name,
|
|
3638
3761
|
domains: coolifyDomains([agoraHostnameFor(org)]),
|
|
3639
3762
|
dockerfile_location: AGORA_APP_DOCKERFILE,
|
|
3763
|
+
ports_exposes: AGORA_APP_PORTS_EXPOSES,
|
|
3640
3764
|
}));
|
|
3641
3765
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3642
3766
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
3767
|
+
// #506: an app found rather than just created may predate passing
|
|
3768
|
+
// `ports_exposes` at create -- Traefik then routes to a port nothing
|
|
3769
|
+
// listens on and Agora answers 502 behind the tunnel. A freshly created
|
|
3770
|
+
// app already got the right port above, so only a found one is checked.
|
|
3771
|
+
const portDiffers = app.existed &&
|
|
3772
|
+
(await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
|
|
3773
|
+
AGORA_APP_PORTS_EXPOSES;
|
|
3774
|
+
if (portDiffers) {
|
|
3775
|
+
await this.ports.coolify.updateApplication(app.appId, {
|
|
3776
|
+
ports_exposes: AGORA_APP_PORTS_EXPOSES,
|
|
3777
|
+
});
|
|
3778
|
+
}
|
|
3643
3779
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
3644
3780
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
3645
3781
|
// can no longer tell a changed value from one already live.
|
|
@@ -3650,7 +3786,25 @@ export class FoundryStepExecutor {
|
|
|
3650
3786
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
3651
3787
|
const revision = agoraEnvRevision(env);
|
|
3652
3788
|
const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
|
|
3653
|
-
|
|
3789
|
+
// #504: `createGithubAppApplication` never sets `instant_deploy`, and
|
|
3790
|
+
// Coolify defaults it to false -- a brand new app, and one found by name
|
|
3791
|
+
// that a previous apply created but crashed before deploying, both sat
|
|
3792
|
+
// built but never started. `restart` cannot fix either: it starts a
|
|
3793
|
+
// container that was never built. Deployed once this executor has
|
|
3794
|
+
// confirmed (the marker never changes value once set), never again -- a
|
|
3795
|
+
// live app is only ever restarted from here on, unless #506's port fix
|
|
3796
|
+
// below needs its own deploy.
|
|
3797
|
+
if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
|
|
3798
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3799
|
+
context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
|
|
3800
|
+
}
|
|
3801
|
+
else if (portDiffers) {
|
|
3802
|
+
// #506: a port fix needs its own full deploy -- env drift alone only
|
|
3803
|
+
// needs the cheaper restart, and this deploy already carries this
|
|
3804
|
+
// run's env (pushed above), so the two never both run.
|
|
3805
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3806
|
+
}
|
|
3807
|
+
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
3654
3808
|
await this.ports.coolify.restart(app.appId);
|
|
3655
3809
|
}
|
|
3656
3810
|
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
@@ -3679,6 +3833,10 @@ export class FoundryStepExecutor {
|
|
|
3679
3833
|
*
|
|
3680
3834
|
* `AUTH_APP_KEY` is minted once here; its broker entry is the next step
|
|
3681
3835
|
* (`bouleBrokerEntry`).
|
|
3836
|
+
*
|
|
3837
|
+
* #504: a brand new app is never restarted -- but it is deployed, once
|
|
3838
|
+
* ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
|
|
3839
|
+
* shape {@link coolifyAgora}'s own doc comment explains.
|
|
3682
3840
|
*/
|
|
3683
3841
|
async coolifyBoule(action, org, context) {
|
|
3684
3842
|
// The issuer and Archon URL the plan read, carried in the step: the env
|
|
@@ -3730,7 +3888,17 @@ export class FoundryStepExecutor {
|
|
|
3730
3888
|
if (!dbPush.pushed) {
|
|
3731
3889
|
throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
3732
3890
|
}
|
|
3733
|
-
|
|
3891
|
+
// #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
|
|
3892
|
+
// never sets `instant_deploy`, so a brand new app (or one found by name
|
|
3893
|
+
// that a previous apply created but crashed before deploying) sat built
|
|
3894
|
+
// but never started. Deployed once this executor has confirmed, never
|
|
3895
|
+
// again -- a live app is only ever restarted from here on.
|
|
3896
|
+
if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
|
|
3897
|
+
await this.ports.coolify.deploy(app.appId);
|
|
3898
|
+
context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
|
|
3899
|
+
}
|
|
3900
|
+
else if (app.existed &&
|
|
3901
|
+
(envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
|
|
3734
3902
|
await this.ports.coolify.restart(app.appId);
|
|
3735
3903
|
}
|
|
3736
3904
|
return { resourceId: app.appId };
|
|
@@ -4219,8 +4387,17 @@ export class FoundryStepExecutor {
|
|
|
4219
4387
|
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
4220
4388
|
* its own already-recorded zone is unaffected: the store then names this
|
|
4221
4389
|
* same org as the owner.
|
|
4390
|
+
*
|
|
4391
|
+
* #510: also records the zone's own id under the flat `cloudflare_zone`
|
|
4392
|
+
* scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
|
|
4393
|
+
* already records from this method's return value. Without it, nothing
|
|
4394
|
+
* ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
|
|
4395
|
+
* scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
|
|
4396
|
+
* so that reader's `listResources` always returned `[]` and `diff.ts`'s
|
|
4397
|
+
* `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
|
|
4398
|
+
* once the zone was live.
|
|
4222
4399
|
*/
|
|
4223
|
-
async cloudflareZone(action, org) {
|
|
4400
|
+
async cloudflareZone(action, org, context) {
|
|
4224
4401
|
const domain = action.key;
|
|
4225
4402
|
const cf = this.ports.cloudflare;
|
|
4226
4403
|
const existing = await cf.findZoneByApex(domain);
|
|
@@ -4229,9 +4406,11 @@ export class FoundryStepExecutor {
|
|
|
4229
4406
|
if (owner !== null && owner !== org.id) {
|
|
4230
4407
|
throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
|
|
4231
4408
|
}
|
|
4409
|
+
context.recordScopeId('cloudflare_zone', existing.id);
|
|
4232
4410
|
return { resourceId: existing.id };
|
|
4233
4411
|
}
|
|
4234
4412
|
const created = await cf.createZone(domain);
|
|
4413
|
+
context.recordScopeId('cloudflare_zone', created.id);
|
|
4235
4414
|
return { resourceId: created.id };
|
|
4236
4415
|
}
|
|
4237
4416
|
// ---- mail -----------------------------------------------------------
|
|
@@ -5318,7 +5497,14 @@ export class FoundryStepExecutor {
|
|
|
5318
5497
|
// `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
|
|
5319
5498
|
// for this org again to finish removing it once destroy deletes
|
|
5320
5499
|
// the ZITADEL project and org below, so it has to go here too.
|
|
5321
|
-
|
|
5500
|
+
// A shared instance host's trusted domain is not this org's to remove.
|
|
5501
|
+
// The flag may be missing, so the recorded host is also compared
|
|
5502
|
+
// with the platform's shared auth host.
|
|
5503
|
+
const sharedHost = await this.ports.platformOrg?.authHostname?.();
|
|
5504
|
+
const isShared = recordedIds[LOGIN_HOST_SHARED_SCOPE_KEY] === '1' ||
|
|
5505
|
+
(sharedHost != null && target.id === sharedHost);
|
|
5506
|
+
const own = isShared ? [] : [target.id];
|
|
5507
|
+
for (const host of [...own, ...pendingRemovalHosts(recordedIds)]) {
|
|
5322
5508
|
try {
|
|
5323
5509
|
await this.ports.zitadel.deleteTrustedDomain(host);
|
|
5324
5510
|
}
|
|
@@ -22,18 +22,22 @@ import type { DeclaredOrg } from '../org.js';
|
|
|
22
22
|
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
23
23
|
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
24
24
|
*
|
|
25
|
-
* #488:
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* host
|
|
29
|
-
*
|
|
30
|
-
* duplicate host
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* is `null` when the caller has none to compare against (no platform
|
|
34
|
-
* declared, or sign-in not ready yet), in which case this never refuses.
|
|
25
|
+
* #488: the platform's own org is not handled here: it signs in on the shared
|
|
26
|
+
* ZITADEL instance's own host (`platformAuthHostname`), whatever
|
|
27
|
+
* `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
|
|
28
|
+
* that host itself. That host must never get a trusted
|
|
29
|
+
* domain or a login binding of its own:
|
|
30
|
+
* Archon's login-host write throws on a duplicate host, and `auth`'s own
|
|
31
|
+
* `company-host.ts` denies every request through one, the shared one
|
|
32
|
+
* included (`planLoginHost` and `zitadelLoginHost` skip both for it).
|
|
35
33
|
*/
|
|
36
|
-
export declare function loginHostnameFor(org: DeclaredOrg
|
|
34
|
+
export declare function loginHostnameFor(org: DeclaredOrg): string;
|
|
35
|
+
/**
|
|
36
|
+
* Scope id set to `'1'` once an org's recorded login host is the platform's
|
|
37
|
+
* shared instance host: that host's trusted domain belongs to the shared
|
|
38
|
+
* instance, so `destroy` must not remove it.
|
|
39
|
+
*/
|
|
40
|
+
export declare const LOGIN_HOST_SHARED_SCOPE_KEY = "zitadel_login_host_shared";
|
|
37
41
|
/**
|
|
38
42
|
* One entry in zitadel-login's own `LOGIN_HOST_BINDINGS`
|
|
39
43
|
* (`auth/docs/company-login-hosts.md`) -- a single-line JSON document naming
|
package/dist/plan/login-host.js
CHANGED
|
@@ -23,30 +23,30 @@ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
|
|
|
23
23
|
* org's own `<slug>.oikos.host` can collide with this subdomain, the same
|
|
24
24
|
* way `AGORA_SUFFIX` reserves `-agora`.
|
|
25
25
|
*
|
|
26
|
-
* #488:
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* host
|
|
30
|
-
*
|
|
31
|
-
* duplicate host
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* is `null` when the caller has none to compare against (no platform
|
|
35
|
-
* declared, or sign-in not ready yet), in which case this never refuses.
|
|
26
|
+
* #488: the platform's own org is not handled here: it signs in on the shared
|
|
27
|
+
* ZITADEL instance's own host (`platformAuthHostname`), whatever
|
|
28
|
+
* `issuer.usesDomain` says (William, 2026-10-06), and `planLoginHost` picks
|
|
29
|
+
* that host itself. That host must never get a trusted
|
|
30
|
+
* domain or a login binding of its own:
|
|
31
|
+
* Archon's login-host write throws on a duplicate host, and `auth`'s own
|
|
32
|
+
* `company-host.ts` denies every request through one, the shared one
|
|
33
|
+
* included (`planLoginHost` and `zitadelLoginHost` skip both for it).
|
|
36
34
|
*/
|
|
37
|
-
export function loginHostnameFor(org
|
|
35
|
+
export function loginHostnameFor(org) {
|
|
38
36
|
// `issuer.usesDomain` is the issuer's opt-in into the org's domain
|
|
39
37
|
// (`org.ts`'s `IssuerConfig`): a domain alone never moves it, so a domain
|
|
40
38
|
// with `usesDomain` false stays on the tier 1 host.
|
|
41
39
|
if (org.domain && org.issuer.usesDomain) {
|
|
42
|
-
|
|
43
|
-
if (platformAuthHostname && host === platformAuthHostname) {
|
|
44
|
-
return `${org.slug}-auth.oikos.host`;
|
|
45
|
-
}
|
|
46
|
-
return host;
|
|
40
|
+
return `auth.${org.domain}`;
|
|
47
41
|
}
|
|
48
42
|
return `${org.slug}-auth.oikos.host`;
|
|
49
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Scope id set to `'1'` once an org's recorded login host is the platform's
|
|
46
|
+
* shared instance host: that host's trusted domain belongs to the shared
|
|
47
|
+
* instance, so `destroy` must not remove it.
|
|
48
|
+
*/
|
|
49
|
+
export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
|
|
50
50
|
/**
|
|
51
51
|
* Scope id holding what the binding was last written with
|
|
52
52
|
* ({@link loginBindingState}). Client ids are not secrets. The planner
|
|
@@ -47,6 +47,9 @@ export class PlatformOrgPorts {
|
|
|
47
47
|
const token = await this.held('jobs', JOBS_ADMIN_TOKEN_REF);
|
|
48
48
|
return new HttpJobsSchedulerClient(`https://${platform.jobs.hostname}`, token, this.options.fetch);
|
|
49
49
|
}
|
|
50
|
+
async authHostname() {
|
|
51
|
+
return (await this.options.store.get())?.auth.hostname ?? null;
|
|
52
|
+
}
|
|
50
53
|
async recorded(service, scopeKey) {
|
|
51
54
|
const value = (await this.options.store.getServiceIds())[scopeKey];
|
|
52
55
|
if (!value) {
|
|
@@ -640,8 +640,9 @@ export declare const PLATFORM_AI_MANAGEMENT_KEY_REF = "ai:platform:managementKey
|
|
|
640
640
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
641
641
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
642
642
|
* configuration only (offerings, provisioning, offers, budgets, usage, cost)
|
|
643
|
-
* plus deleting (archiving) an organisation
|
|
644
|
-
*
|
|
643
|
+
* plus deleting (archiving) an organisation and setting another
|
|
644
|
+
* organisation's webhook address and budget, never connections, runs or
|
|
645
|
+
* webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
|
|
645
646
|
* this same list, so a request for more than this is a 403, not a wider key.
|
|
646
647
|
*/
|
|
647
648
|
export declare const PLATFORM_AI_MANAGEMENT_PERMISSIONS: readonly string[];
|
|
@@ -2330,11 +2330,14 @@ const PLATFORM_ARCHON_PROJECT = 'platform';
|
|
|
2330
2330
|
const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
|
|
2331
2331
|
const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
|
|
2332
2332
|
/**
|
|
2333
|
-
* The database
|
|
2334
|
-
*
|
|
2335
|
-
* `
|
|
2333
|
+
* The database Archon runs on. Live platform-archon moved to `archon_v2` on
|
|
2334
|
+
* the same server (2026-10-02, the move onto `@wtfalch/db`), while its Coolify
|
|
2335
|
+
* resource still names `archon` as its first database. So both URLs built
|
|
2336
|
+
* here set this database themselves, never `internal_db_url`'s path.
|
|
2337
|
+
* Archon's own migrations grant to `current_database() || '_rt'`, so
|
|
2338
|
+
* {@link ARCHON_RUNTIME_ROLE} follows.
|
|
2336
2339
|
*/
|
|
2337
|
-
const ARCHON_POSTGRES_DB = '
|
|
2340
|
+
const ARCHON_POSTGRES_DB = 'archon_v2';
|
|
2338
2341
|
const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
|
|
2339
2342
|
/** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
|
|
2340
2343
|
const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
|
|
@@ -2668,11 +2671,12 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2668
2671
|
// The owner URL must name the adopted database's host, or the app's two
|
|
2669
2672
|
// database URLs would point at different databases.
|
|
2670
2673
|
const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
|
|
2674
|
+
const ownerUrl = archonDatabaseUrl(db.internal_db_url);
|
|
2671
2675
|
if (liveOwnerUrl) {
|
|
2672
2676
|
assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
|
|
2673
2677
|
}
|
|
2674
2678
|
else {
|
|
2675
|
-
envs.push({ key: 'DATABASE_URL_OWNER', value:
|
|
2679
|
+
envs.push({ key: 'DATABASE_URL_OWNER', value: ownerUrl });
|
|
2676
2680
|
}
|
|
2677
2681
|
const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
|
|
2678
2682
|
if (!liveReportingSite) {
|
|
@@ -2692,7 +2696,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2692
2696
|
// reflects the env Archon actually ends up running with, not just this
|
|
2693
2697
|
// run's delta.
|
|
2694
2698
|
const archonEnvHashOverrides = new Map([
|
|
2695
|
-
['DATABASE_URL_OWNER', liveOwnerUrl ??
|
|
2699
|
+
['DATABASE_URL_OWNER', liveOwnerUrl ?? ownerUrl],
|
|
2696
2700
|
['REPORTING_SITE', liveReportingSite ?? 'archon'],
|
|
2697
2701
|
]);
|
|
2698
2702
|
const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
|
|
@@ -3625,10 +3629,9 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
3625
3629
|
return null;
|
|
3626
3630
|
}
|
|
3627
3631
|
let host;
|
|
3628
|
-
let pathname;
|
|
3629
3632
|
let search;
|
|
3630
3633
|
try {
|
|
3631
|
-
({ host,
|
|
3634
|
+
({ host, search } = new URL(dbInternalUrl));
|
|
3632
3635
|
}
|
|
3633
3636
|
catch {
|
|
3634
3637
|
// Node's `ERR_INVALID_URL` carries the whole input, credential included, so
|
|
@@ -3636,7 +3639,19 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
3636
3639
|
throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
|
|
3637
3640
|
}
|
|
3638
3641
|
const password = randomBytes(24).toString('base64url');
|
|
3639
|
-
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}
|
|
3642
|
+
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}/${ARCHON_POSTGRES_DB}${search}`;
|
|
3643
|
+
}
|
|
3644
|
+
/** `internalUrl` on {@link ARCHON_POSTGRES_DB}. The error never carries the URL: it holds a credential. */
|
|
3645
|
+
function archonDatabaseUrl(internalUrl) {
|
|
3646
|
+
let url;
|
|
3647
|
+
try {
|
|
3648
|
+
url = new URL(internalUrl);
|
|
3649
|
+
}
|
|
3650
|
+
catch {
|
|
3651
|
+
throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL_OWNER. Check the database in Coolify and apply again');
|
|
3652
|
+
}
|
|
3653
|
+
url.pathname = `/${ARCHON_POSTGRES_DB}`;
|
|
3654
|
+
return url.toString();
|
|
3640
3655
|
}
|
|
3641
3656
|
/**
|
|
3642
3657
|
* Thrown by {@link createAiManagementKeyMinter} (never by a test's own
|
|
@@ -3720,8 +3735,9 @@ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
|
3720
3735
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
3721
3736
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
3722
3737
|
* configuration only (offerings, provisioning, offers, budgets, usage, cost)
|
|
3723
|
-
* plus deleting (archiving) an organisation
|
|
3724
|
-
*
|
|
3738
|
+
* plus deleting (archiving) an organisation and setting another
|
|
3739
|
+
* organisation's webhook address and budget, never connections, runs or
|
|
3740
|
+
* webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
|
|
3725
3741
|
* this same list, so a request for more than this is a 403, not a wider key.
|
|
3726
3742
|
*/
|
|
3727
3743
|
export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
@@ -3732,6 +3748,8 @@ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
|
3732
3748
|
'ai.organisations:provision',
|
|
3733
3749
|
'ai.organisations:offer',
|
|
3734
3750
|
'ai.organisations:delete',
|
|
3751
|
+
'ai.organisations:webhook',
|
|
3752
|
+
'ai.organisations:budget',
|
|
3735
3753
|
'ai.keys:issue',
|
|
3736
3754
|
'ai.budgets:read',
|
|
3737
3755
|
'ai.budgets:update',
|