@wtfalch/foundry 0.9.0 → 0.10.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/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +30 -4
- package/dist/org.js +82 -1
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +104 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +260 -42
- package/dist/plan/executor.d.ts +44 -0
- package/dist/plan/executor.js +219 -90
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +16 -0
- package/dist/plan/plan-apply.js +42 -2
- package/dist/plan/platform-diff.d.ts +60 -13
- package/dist/plan/platform-diff.js +109 -15
- package/dist/plan/platform-providers.d.ts +95 -2
- package/dist/plan/platform-providers.js +393 -27
- package/dist/store/postgres.js +11 -3
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -2,12 +2,14 @@ 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, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
|
|
5
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
6
|
+
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
6
7
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
7
8
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
9
|
+
import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
8
10
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
9
11
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
10
|
-
import {
|
|
12
|
+
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, } from './platform-diff.js';
|
|
11
13
|
/**
|
|
12
14
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
13
15
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -48,25 +50,6 @@ export class NotBuiltError extends Error {
|
|
|
48
50
|
// ---------------------------------------------------------------------------
|
|
49
51
|
// Hostnames
|
|
50
52
|
// ---------------------------------------------------------------------------
|
|
51
|
-
/**
|
|
52
|
-
* The Coolify name of a hosting entry's app: `<slug>` for the `app` entry, so
|
|
53
|
-
* the move to a list finds the app that runs today, `<slug>-<key>` for any
|
|
54
|
-
* other.
|
|
55
|
-
*/
|
|
56
|
-
function appNameFor(org, entry) {
|
|
57
|
-
return entry.key === APP_ENTRY_KEY ? org.slug : `${org.slug}-${entry.key}`;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
|
|
61
|
-
* `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
|
|
62
|
-
* `planSite` document, reused here for the org's own hosted app, its
|
|
63
|
-
* Cloudflare tunnel ingress and its ZITADEL issuer apps' redirect URIs
|
|
64
|
-
* (task brief: "Hostnames: `<slug>.oikos.host` ... or `DeclaredOrg.domain`
|
|
65
|
-
* when `hosting.usesDomain`").
|
|
66
|
-
*/
|
|
67
|
-
function hostnameFor(org) {
|
|
68
|
-
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
69
|
-
}
|
|
70
53
|
/**
|
|
71
54
|
* #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
|
|
72
55
|
* live app's ZITADEL-reported login base URI and the one this step itself
|
|
@@ -104,36 +87,6 @@ function callsHostnameFor(org) {
|
|
|
104
87
|
? `calls.${org.domain}`
|
|
105
88
|
: `${org.slug}-calls.oikos.host`;
|
|
106
89
|
}
|
|
107
|
-
/**
|
|
108
|
-
* The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
|
|
109
|
-
* (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
|
|
110
|
-
* (`org.ts`'s `SiteConfig.enabled`) has no single app hostname to reuse -- it
|
|
111
|
-
* needs both its public site at `org.domain` and its cms studio (the sign-in
|
|
112
|
-
* and editing surface, `@wtfalch/cms`) at `cms.<domain>`, so both are named
|
|
113
|
-
* here; `org.domain` is non-null in that branch, guaranteed by `org.ts`'s
|
|
114
|
-
* `validateSite` ("site.enabled requires domain"). Every other org --
|
|
115
|
-
* concretely a hosting org, whose one app hostname this served before #115
|
|
116
|
-
* ever existed -- keeps exactly the prior single-hostname behavior
|
|
117
|
-
* ({@link hostnameFor}), unchanged.
|
|
118
|
-
*
|
|
119
|
-
* #145: an own-domain calls service adds `calls.<domain>` on top of whichever
|
|
120
|
-
* of those a site/hosting org already gets, since calls can run alongside
|
|
121
|
-
* either (or neither).
|
|
122
|
-
*/
|
|
123
|
-
function tunnelIngressHostnames(org) {
|
|
124
|
-
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
|
|
125
|
-
if (org.calls.enabled && org.calls.usesDomain && org.domain) {
|
|
126
|
-
hostnames.push(`calls.${org.domain}`);
|
|
127
|
-
}
|
|
128
|
-
// #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
|
|
129
|
-
// -- no toggle to gate this on. A level-1 org (no domain) adds nothing
|
|
130
|
-
// here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
|
|
131
|
-
// tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
|
|
132
|
-
if (org.domain) {
|
|
133
|
-
hostnames.push(agoraHostnameFor(org));
|
|
134
|
-
}
|
|
135
|
-
return hostnames;
|
|
136
|
-
}
|
|
137
90
|
/**
|
|
138
91
|
* The health URLs an outside prober should call for this org (#43): one per
|
|
139
92
|
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
|
@@ -143,8 +96,17 @@ function tunnelIngressHostnames(org) {
|
|
|
143
96
|
* this file's hostname rules.
|
|
144
97
|
*/
|
|
145
98
|
export function healthUrls(org) {
|
|
146
|
-
|
|
147
|
-
|
|
99
|
+
// One URL per entry that has a public name; an internal entry has none.
|
|
100
|
+
if (org.hosting.enabled && appEntry(org.hosting)) {
|
|
101
|
+
return org.hosting.entries.flatMap((entry) => {
|
|
102
|
+
const [hostname] = entryHostnames(org, entry);
|
|
103
|
+
if (!hostname)
|
|
104
|
+
return [];
|
|
105
|
+
return [
|
|
106
|
+
`https://${hostname}${entry.healthPath ?? (entry.buildPack === 'static' ? '/' : '/api/health')}`,
|
|
107
|
+
];
|
|
108
|
+
});
|
|
109
|
+
}
|
|
148
110
|
if (org.site.enabled && org.domain) {
|
|
149
111
|
return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
|
|
150
112
|
}
|
|
@@ -166,7 +128,7 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
166
128
|
/**
|
|
167
129
|
* Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
|
|
168
130
|
* itself points that proxy's routing at whatever `domains` an application
|
|
169
|
-
* declares (`coolifyApp`'s `
|
|
131
|
+
* declares (`coolifyApp`'s `appHostname(org)`) -- so a tunnel's ingress only
|
|
170
132
|
* has to reach the proxy, never a specific app container.
|
|
171
133
|
* `wtfalch/app-template`'s bootstrap (`bootstrap/steps/cf-tunnel.ts`, prior
|
|
172
134
|
* art for the exact org this file's `cloudflareTunnel` implements, cited by
|
|
@@ -587,6 +549,8 @@ export class FoundryStepExecutor {
|
|
|
587
549
|
return this.zitadelCustomers(org, context);
|
|
588
550
|
case 'coolify:app':
|
|
589
551
|
return this.coolifyApp(action, org, context);
|
|
552
|
+
case 'coolify:broker-entry':
|
|
553
|
+
return this.coolifyBrokerEntry(action, org, context);
|
|
590
554
|
case 'coolify:database':
|
|
591
555
|
return this.coolifyDatabase(action, org, context);
|
|
592
556
|
case 'coolify:calls':
|
|
@@ -637,7 +601,12 @@ export class FoundryStepExecutor {
|
|
|
637
601
|
* any policy but foundry's, since that policy could let others in.
|
|
638
602
|
*/
|
|
639
603
|
async cloudflareAccess(org) {
|
|
640
|
-
|
|
604
|
+
// Only the `app` entry has a public name (`planAccess`); per-entry
|
|
605
|
+
// hostnames are issue 309.
|
|
606
|
+
const entry = appEntry(org.hosting);
|
|
607
|
+
if (!entry)
|
|
608
|
+
throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
|
|
609
|
+
const domain = appHostname(org);
|
|
641
610
|
const name = `foundry:${org.id}`;
|
|
642
611
|
const cf = this.ports.cloudflare;
|
|
643
612
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
@@ -655,7 +624,7 @@ export class FoundryStepExecutor {
|
|
|
655
624
|
await cf.createAccessPolicy(app.id, {
|
|
656
625
|
name: ACCESS_POLICY_NAME,
|
|
657
626
|
decision: 'allow',
|
|
658
|
-
include:
|
|
627
|
+
include: entry.accessEmails.map((email) => ({ email: { email } })),
|
|
659
628
|
});
|
|
660
629
|
}
|
|
661
630
|
return { resourceId: app.id };
|
|
@@ -842,6 +811,9 @@ export class FoundryStepExecutor {
|
|
|
842
811
|
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
843
812
|
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
844
813
|
: []),
|
|
814
|
+
...Object.keys(recorded)
|
|
815
|
+
.filter((key) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
|
|
816
|
+
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
|
|
845
817
|
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
846
818
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
847
819
|
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
@@ -1025,7 +997,7 @@ export class FoundryStepExecutor {
|
|
|
1025
997
|
? cmsHostnameFor(org)
|
|
1026
998
|
: isAgoraClient
|
|
1027
999
|
? agoraHostnameFor(org)
|
|
1028
|
-
:
|
|
1000
|
+
: appHostname(org);
|
|
1029
1001
|
// Native apps sign in via device code, with no redirect of their own
|
|
1030
1002
|
// (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
|
|
1031
1003
|
// file) -- empty arrays rather than a web app's callback/logout URLs.
|
|
@@ -1037,11 +1009,23 @@ export class FoundryStepExecutor {
|
|
|
1037
1009
|
const redirectUri = isAgoraClient
|
|
1038
1010
|
? `https://${hostname}/callback`
|
|
1039
1011
|
: `https://${hostname}/auth/callback`;
|
|
1012
|
+
// An entry with `uses.auth` signs in at its own exact hostnames, one pair
|
|
1013
|
+
// of addresses each; a `*` adds none (`entryHostnames`).
|
|
1014
|
+
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
1015
|
+
const hosts = authEntry ? entryHostnames(org, authEntry) : null;
|
|
1040
1016
|
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
1041
1017
|
name: action.key,
|
|
1042
1018
|
appType,
|
|
1043
|
-
redirectUris: appType === 'native'
|
|
1044
|
-
|
|
1019
|
+
redirectUris: appType === 'native'
|
|
1020
|
+
? []
|
|
1021
|
+
: hosts
|
|
1022
|
+
? hosts.map((host) => `https://${host}/auth/callback`)
|
|
1023
|
+
: [redirectUri],
|
|
1024
|
+
postLogoutRedirectUris: appType === 'native'
|
|
1025
|
+
? []
|
|
1026
|
+
: hosts
|
|
1027
|
+
? hosts.map((host) => `https://${host}/`)
|
|
1028
|
+
: [`https://${hostname}/`],
|
|
1045
1029
|
});
|
|
1046
1030
|
if (appType === 'web') {
|
|
1047
1031
|
// `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
|
|
@@ -1056,7 +1040,9 @@ export class FoundryStepExecutor {
|
|
|
1056
1040
|
? CMS_CLIENT_ID_SCOPE_KEY
|
|
1057
1041
|
: isAgoraClient
|
|
1058
1042
|
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
1059
|
-
:
|
|
1043
|
+
: authEntry
|
|
1044
|
+
? entryClientIdScopeKey(action.key)
|
|
1045
|
+
: APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
1060
1046
|
}
|
|
1061
1047
|
return { resourceId: created.appId };
|
|
1062
1048
|
}
|
|
@@ -1111,9 +1097,9 @@ export class FoundryStepExecutor {
|
|
|
1111
1097
|
await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
|
|
1112
1098
|
return { resourceId: recordedAppId };
|
|
1113
1099
|
}
|
|
1114
|
-
const entry =
|
|
1100
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1115
1101
|
if (!entry)
|
|
1116
|
-
throw new Error(`coolify:app for ${org.slug}: no
|
|
1102
|
+
throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
|
|
1117
1103
|
// A level-1 org with no bespoke app of its own gets the shared standard
|
|
1118
1104
|
// app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
|
|
1119
1105
|
// with no `repo`.
|
|
@@ -1139,6 +1125,15 @@ export class FoundryStepExecutor {
|
|
|
1139
1125
|
environmentIds: [environment.id],
|
|
1140
1126
|
});
|
|
1141
1127
|
}
|
|
1128
|
+
// An entry with no names is internal: the other entries reach it as
|
|
1129
|
+
// `<slug>-<key>`, and it gets no domain, route or DNS record.
|
|
1130
|
+
const names = entryHostnames(org, entry);
|
|
1131
|
+
const isStatic = entry.buildPack === 'static';
|
|
1132
|
+
const folderField = isStatic ? 'publish_directory' : 'base_directory';
|
|
1133
|
+
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
1134
|
+
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
1135
|
+
let haveNames;
|
|
1136
|
+
let have;
|
|
1142
1137
|
let appId;
|
|
1143
1138
|
if (existing) {
|
|
1144
1139
|
appId = existing.uuid;
|
|
@@ -1152,6 +1147,8 @@ export class FoundryStepExecutor {
|
|
|
1152
1147
|
if (!isRunningOrStarting(live.status)) {
|
|
1153
1148
|
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1154
1149
|
}
|
|
1150
|
+
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1151
|
+
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1155
1152
|
}
|
|
1156
1153
|
else {
|
|
1157
1154
|
const server = await this.resolveServer();
|
|
@@ -1166,13 +1163,49 @@ export class FoundryStepExecutor {
|
|
|
1166
1163
|
git_branch: entry.branch,
|
|
1167
1164
|
build_pack: entry.buildPack,
|
|
1168
1165
|
name: appNameFor(org, entry),
|
|
1169
|
-
domains:
|
|
1166
|
+
domains: names.length > 0 ? names.join(',') : undefined,
|
|
1167
|
+
autogenerate_domain: names.length > 0 ? undefined : false,
|
|
1168
|
+
// A static site is served by Coolify's own web server on port 80.
|
|
1169
|
+
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
1170
|
+
is_auto_deploy_enabled: entry.autoDeploy,
|
|
1171
|
+
[folderField]: entry.folder === null ? undefined : `/${entry.folder}`,
|
|
1170
1172
|
// Only the shared standard app builds from this repo; a bespoke
|
|
1171
1173
|
// app's Dockerfile is at its own repo's root.
|
|
1172
1174
|
dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
|
|
1173
1175
|
});
|
|
1174
1176
|
appId = created.uuid;
|
|
1175
|
-
|
|
1177
|
+
// Create sent every setting, so nothing is left to patch.
|
|
1178
|
+
have = appSettingsOf(entry);
|
|
1179
|
+
haveNames = names;
|
|
1180
|
+
}
|
|
1181
|
+
// Patch what differs from what the app has. A crash before the marker is
|
|
1182
|
+
// recorded below leaves the old one, so the retry patches the same
|
|
1183
|
+
// difference again.
|
|
1184
|
+
const want = appSettingsOf(entry);
|
|
1185
|
+
const patch = {};
|
|
1186
|
+
if (want.folder !== have.folder)
|
|
1187
|
+
patch[folderField] = want.folder ? `/${want.folder}` : '/';
|
|
1188
|
+
if (want.port !== have.port && want.port !== null && !isStatic) {
|
|
1189
|
+
patch.ports_exposes = String(want.port);
|
|
1190
|
+
}
|
|
1191
|
+
if (want.autoDeploy !== have.autoDeploy)
|
|
1192
|
+
patch.is_auto_deploy_enabled = want.autoDeploy;
|
|
1193
|
+
// Create names the app after `domains`; a changed name reaches a live app
|
|
1194
|
+
// here (rule 3). The internal name is set after create, as the calls
|
|
1195
|
+
// stack's apps do.
|
|
1196
|
+
if (JSON.stringify(names) !== JSON.stringify(haveNames))
|
|
1197
|
+
patch.domains = names.join(',');
|
|
1198
|
+
if (names.length === 0 && (!existing || patch.domains !== undefined)) {
|
|
1199
|
+
patch.custom_internal_name = appNameFor(org, entry);
|
|
1200
|
+
}
|
|
1201
|
+
if (Object.keys(patch).length > 0)
|
|
1202
|
+
await this.ports.coolify.updateApplication(appId, patch);
|
|
1203
|
+
// The container builds and listens from these only at its next deploy.
|
|
1204
|
+
const settingsNeedDeploy = Boolean(patch.base_directory ||
|
|
1205
|
+
patch.publish_directory ||
|
|
1206
|
+
patch.ports_exposes ||
|
|
1207
|
+
patch.domains !== undefined ||
|
|
1208
|
+
patch.custom_internal_name);
|
|
1176
1209
|
// The database plans before the app (`diff.ts`'s `computePlanDetail`,
|
|
1177
1210
|
// hosting-wiring campaign) so that, on the same apply, the database's
|
|
1178
1211
|
// resource id is already recorded by the time this runs -- but this also
|
|
@@ -1188,7 +1221,7 @@ export class FoundryStepExecutor {
|
|
|
1188
1221
|
// Restart when the push changed something, or when no earlier run got as
|
|
1189
1222
|
// far as restarting after one (marker missing): a crash between the push
|
|
1190
1223
|
// and the restart leaves the env right and the container stale.
|
|
1191
|
-
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[
|
|
1224
|
+
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbEnvAppliedScopeKey(entry.key)]);
|
|
1192
1225
|
// Every app keeps its own logs across a redeploy (#75). Unconditional,
|
|
1193
1226
|
// like the env push below: found by mount path before create, so a
|
|
1194
1227
|
// re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
|
|
@@ -1196,27 +1229,42 @@ export class FoundryStepExecutor {
|
|
|
1196
1229
|
// `ensureLogVolume` whether this app was already running before this
|
|
1197
1230
|
// call -- see its own doc comment for why that decides whether to
|
|
1198
1231
|
// restart it.
|
|
1199
|
-
await this.ensureLogVolume(appId, org, Boolean(existing));
|
|
1232
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
|
|
1200
1233
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
1201
1234
|
// unlike the database push above: every app has a hostname, and an
|
|
1202
1235
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
1203
|
-
const appEnv = await this.pushAppEnv(appId, org, entry, context);
|
|
1236
|
+
const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null);
|
|
1204
1237
|
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
1205
1238
|
// that was already there is restarted, once, when its database URLs or its
|
|
1206
1239
|
// declared appEnv changed.
|
|
1207
|
-
|
|
1240
|
+
// A new folder needs a build, which a restart does not do, so a settings
|
|
1241
|
+
// change deploys instead (rule 3), and that carries the env too.
|
|
1242
|
+
if (settingsNeedDeploy)
|
|
1243
|
+
await this.ports.coolify.deploy(appId);
|
|
1244
|
+
else if ((appEnv.changed || dbNeedsRestart) && existing)
|
|
1208
1245
|
await this.ports.coolify.restart(appId);
|
|
1209
|
-
// Recorded only after the
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1246
|
+
// Recorded only after the redeploy succeeded: a failed one leaves the old
|
|
1247
|
+
// value, so the next plan replans (rule 3). The db marker is not a URL.
|
|
1248
|
+
// A port taken back to null was not patched, so the marker keeps the old
|
|
1249
|
+
// one and `planApp` reports it instead of planning it.
|
|
1250
|
+
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
1251
|
+
context.recordScopeId(domainsKey, JSON.stringify(names));
|
|
1252
|
+
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
1253
|
+
if (entry.uses?.auth) {
|
|
1254
|
+
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
1255
|
+
}
|
|
1212
1256
|
if (dbPush.pushed)
|
|
1213
|
-
context.recordScopeId(
|
|
1257
|
+
context.recordScopeId(dbEnvAppliedScopeKey(entry.key), '1');
|
|
1214
1258
|
// #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
|
|
1215
1259
|
// Lettermint project, domain restriction and `MAIL_FROM` (only the app
|
|
1216
1260
|
// started by this same step, per the design's "only apps that send mail
|
|
1217
1261
|
// get it" -- cms and calls do not). A no-op until `coolify:mail` has
|
|
1218
1262
|
// actually resolved a mail domain for this org.
|
|
1219
|
-
|
|
1263
|
+
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1264
|
+
// second entry sharing the name would delete the first's live token.
|
|
1265
|
+
if (entry.key === APP_ENTRY_KEY) {
|
|
1266
|
+
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
|
|
1267
|
+
}
|
|
1220
1268
|
return { resourceId: appId };
|
|
1221
1269
|
}
|
|
1222
1270
|
/**
|
|
@@ -1240,13 +1288,13 @@ export class FoundryStepExecutor {
|
|
|
1240
1288
|
* `createGithubAppApplication`'s own instant-deploy path, so a restart
|
|
1241
1289
|
* here would only be a redundant extra one).
|
|
1242
1290
|
*/
|
|
1243
|
-
async ensureLogVolume(appId, org, wasAlreadyRunning) {
|
|
1291
|
+
async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
|
|
1244
1292
|
const storages = await this.ports.coolify.listStorages(appId);
|
|
1245
1293
|
const already = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
|
|
1246
1294
|
if (already)
|
|
1247
1295
|
return;
|
|
1248
1296
|
await this.ports.coolify.createPersistentStorage(appId, {
|
|
1249
|
-
name: `${
|
|
1297
|
+
name: `${appName}-logs`,
|
|
1250
1298
|
mount_path: LOG_DIR,
|
|
1251
1299
|
});
|
|
1252
1300
|
if (wasAlreadyRunning) {
|
|
@@ -1293,14 +1341,16 @@ export class FoundryStepExecutor {
|
|
|
1293
1341
|
// Handles the database being provisioned after the app already exists
|
|
1294
1342
|
// (`coolifyApp`'s own push handles the more common order, database
|
|
1295
1343
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1344
|
+
const dbEntry = org.hosting.entries.find((e) => e.database);
|
|
1345
|
+
const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
|
|
1346
|
+
if (dbEntry && appId) {
|
|
1347
|
+
const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
|
|
1298
1348
|
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
1299
1349
|
if (push.pushed) {
|
|
1300
|
-
if (push.changed || !context.recordedIds()[
|
|
1350
|
+
if (push.changed || !context.recordedIds()[markerKey]) {
|
|
1301
1351
|
await this.ports.coolify.restart(appId);
|
|
1302
1352
|
}
|
|
1303
|
-
context.recordScopeId(
|
|
1353
|
+
context.recordScopeId(markerKey, '1');
|
|
1304
1354
|
}
|
|
1305
1355
|
}
|
|
1306
1356
|
return { resourceId: dbId };
|
|
@@ -1390,21 +1440,41 @@ export class FoundryStepExecutor {
|
|
|
1390
1440
|
* that call runs only once the `app` entry's `database` is declared and
|
|
1391
1441
|
* provisioned, where this one is unconditional (#239).
|
|
1392
1442
|
*/
|
|
1393
|
-
async pushAppEnv(appId, org, entry, context) {
|
|
1443
|
+
async pushAppEnv(appId, org, entry, context, authIssuer) {
|
|
1394
1444
|
const recorded = context.recordedIds();
|
|
1395
1445
|
const envs = [
|
|
1396
|
-
|
|
1446
|
+
// Only the `app` entry has a public name; the others are internal.
|
|
1447
|
+
...(entry.key === APP_ENTRY_KEY
|
|
1448
|
+
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
1449
|
+
: []),
|
|
1397
1450
|
// #75: read back by the package-side pino sink (not this repo's job,
|
|
1398
1451
|
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
1399
1452
|
// matching volume.
|
|
1400
1453
|
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
1401
1454
|
];
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
const
|
|
1406
|
-
if (
|
|
1407
|
-
|
|
1455
|
+
// An entry with `uses.auth` signs in one organisation, with its own client
|
|
1456
|
+
// and the issuer; any other entry keeps the org's staff organisation and
|
|
1457
|
+
// the one hosted-app client.
|
|
1458
|
+
const authEnv = entryAuthEnv(org, entry, recorded, authIssuer);
|
|
1459
|
+
if (entry.uses?.auth) {
|
|
1460
|
+
// A customers entry never falls back to the staff organisation's id.
|
|
1461
|
+
if (!authEnv.AUTH_ORGANIZATION_ID) {
|
|
1462
|
+
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth.users}, whose organisation id is not recorded yet`);
|
|
1463
|
+
}
|
|
1464
|
+
for (const [key, value] of Object.entries(authEnv))
|
|
1465
|
+
envs.push({ key, value });
|
|
1466
|
+
const appKey = await this.ensureAuthAppKey(appId);
|
|
1467
|
+
if (appKey)
|
|
1468
|
+
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
1469
|
+
}
|
|
1470
|
+
else {
|
|
1471
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
1472
|
+
if (zitadelOrgId)
|
|
1473
|
+
envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
|
|
1474
|
+
const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
|
|
1475
|
+
if (clientId)
|
|
1476
|
+
envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
|
|
1477
|
+
}
|
|
1408
1478
|
// The invited owner's ZITADEL user id (gap P3): the app uses it to make
|
|
1409
1479
|
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
1410
1480
|
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
@@ -1433,7 +1503,66 @@ export class FoundryStepExecutor {
|
|
|
1433
1503
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1434
1504
|
// domain, never a secret.
|
|
1435
1505
|
const applied = JSON.stringify(appEnv);
|
|
1436
|
-
|
|
1506
|
+
// The sign-in values, recorded by `coolifyApp` the same way; the key is
|
|
1507
|
+
// never part of them.
|
|
1508
|
+
const authApplied = JSON.stringify(authEnv);
|
|
1509
|
+
return {
|
|
1510
|
+
changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
1511
|
+
(Boolean(entry.uses?.auth) &&
|
|
1512
|
+
authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
|
|
1513
|
+
applied,
|
|
1514
|
+
authApplied,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
|
|
1519
|
+
* of at least 32 characters (the broker refuses a shorter one). `null` once
|
|
1520
|
+
* it has one: the app's env is where the key lives, and
|
|
1521
|
+
* `coolifyBrokerEntry` reads it back from there. It goes to this env and
|
|
1522
|
+
* the broker's, nowhere else.
|
|
1523
|
+
*/
|
|
1524
|
+
async ensureAuthAppKey(appId) {
|
|
1525
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1526
|
+
const already = envs.some((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview && (e.value ?? '').length >= 32);
|
|
1527
|
+
return already ? null : randomBytes(32).toString('base64url');
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* `plan/diff.ts`'s `planBrokerEntries` (F5): the entry's `AUTH_APPS` entry,
|
|
1531
|
+
* written by `writeBrokerEntry` with the key `pushAppEnv` put in the app's
|
|
1532
|
+
* env. Records what it wrote (never the key) and any foreign entry the
|
|
1533
|
+
* broker would drop.
|
|
1534
|
+
*/
|
|
1535
|
+
async coolifyBrokerEntry(action, org, context) {
|
|
1536
|
+
const broker = this.ports.broker;
|
|
1537
|
+
if (!broker)
|
|
1538
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: the broker port is not wired`);
|
|
1539
|
+
const recorded = context.recordedIds();
|
|
1540
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1541
|
+
if (!entry?.uses?.auth) {
|
|
1542
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: no uses.auth entry for ${action.key}`);
|
|
1543
|
+
}
|
|
1544
|
+
const wanted = wantedBrokerEntry(org, entry, recorded);
|
|
1545
|
+
if ('missing' in wanted) {
|
|
1546
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
1547
|
+
}
|
|
1548
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
1549
|
+
if (!appId) {
|
|
1550
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
|
|
1551
|
+
}
|
|
1552
|
+
const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
1553
|
+
if (!appKey || appKey.length < 32) {
|
|
1554
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
1555
|
+
}
|
|
1556
|
+
const previousRaw = recorded[brokerEntryAppliedScopeKey(entry.key)];
|
|
1557
|
+
const result = await writeBrokerEntry(broker, {
|
|
1558
|
+
want: wanted.want,
|
|
1559
|
+
appKey,
|
|
1560
|
+
previous: previousRaw ? JSON.parse(previousRaw) : null,
|
|
1561
|
+
});
|
|
1562
|
+
context.recordScopeId(brokerEntryWarningsScopeKey(entry.key), JSON.stringify(result.invalidForeign));
|
|
1563
|
+
// Last: a run that failed above plans the step again.
|
|
1564
|
+
context.recordScopeId(brokerEntryAppliedScopeKey(entry.key), JSON.stringify(wanted.want));
|
|
1565
|
+
return {};
|
|
1437
1566
|
}
|
|
1438
1567
|
/**
|
|
1439
1568
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
@@ -2554,7 +2683,7 @@ export class FoundryStepExecutor {
|
|
|
2554
2683
|
// offline.tf's bootstrap, cited by
|
|
2555
2684
|
// https://github.com/wtfalch/foundry/issues/71): every app on the
|
|
2556
2685
|
// Coolify box already gets a Traefik route from the `domains` its
|
|
2557
|
-
// `coolify:app` step declared (`
|
|
2686
|
+
// `coolify:app` step declared (`appHostname(org)`, this file's
|
|
2558
2687
|
// `coolifyApp`), so a cloudflared connector joined to the same Docker
|
|
2559
2688
|
// network only has to reach Traefik and let it route by Host header --
|
|
2560
2689
|
// the connector never needs to know the app's own container name or
|
|
@@ -2802,7 +2931,7 @@ export class FoundryStepExecutor {
|
|
|
2802
2931
|
}
|
|
2803
2932
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
2804
2933
|
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
2805
|
-
const hostnames =
|
|
2934
|
+
const hostnames = tunnelDnsHostnames(org, domain);
|
|
2806
2935
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
2807
2936
|
const conflicts = [];
|
|
2808
2937
|
const toCreate = [];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
4
|
+
* domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
|
|
5
|
+
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
6
|
+
* acted on elsewhere.
|
|
7
|
+
*/
|
|
8
|
+
export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
|
|
9
|
+
/**
|
|
10
|
+
* The one primary name of the org's app: its first, or the name the app was
|
|
11
|
+
* served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
|
|
12
|
+
* and the jobs registration read it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function appHostname(org: DeclaredOrg): string;
|
|
15
|
+
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
16
|
+
export declare function legacyAppHostname(org: DeclaredOrg): string;
|
|
17
|
+
/**
|
|
18
|
+
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
19
|
+
* #164): a site's two names, every entry's names, an own-domain calls name and
|
|
20
|
+
* Agora's. An org that does not host keeps the single name it always had.
|
|
21
|
+
*/
|
|
22
|
+
export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
|
|
23
|
+
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
24
|
+
export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string): string[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { APP_ENTRY_KEY } from '../org.js';
|
|
2
|
+
import { agoraHostnameFor } from './agora.js';
|
|
3
|
+
/**
|
|
4
|
+
* The full names an entry is served at. With `hosting.usesDomain` off, or no
|
|
5
|
+
* domain, `hostnames` is not read: the `app` entry is at `<slug>.oikos.host`
|
|
6
|
+
* and every other entry is internal. `'*'` names nothing here; the wildcard is
|
|
7
|
+
* acted on elsewhere.
|
|
8
|
+
*/
|
|
9
|
+
export function entryHostnames(org, entry) {
|
|
10
|
+
const { domain } = org;
|
|
11
|
+
if (!org.hosting.usesDomain || !domain) {
|
|
12
|
+
return entry.key === APP_ENTRY_KEY ? [`${org.slug}.oikos.host`] : [];
|
|
13
|
+
}
|
|
14
|
+
return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The one primary name of the org's app: its first, or the name the app was
|
|
18
|
+
* served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
|
|
19
|
+
* and the jobs registration read it.
|
|
20
|
+
*/
|
|
21
|
+
export function appHostname(org) {
|
|
22
|
+
const entry = org.hosting.entries.find((e) => e.key === APP_ENTRY_KEY);
|
|
23
|
+
const first = entry ? entryHostnames(org, entry)[0] : undefined;
|
|
24
|
+
return first ?? legacyAppHostname(org);
|
|
25
|
+
}
|
|
26
|
+
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
27
|
+
export function legacyAppHostname(org) {
|
|
28
|
+
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
32
|
+
* #164): a site's two names, every entry's names, an own-domain calls name and
|
|
33
|
+
* Agora's. An org that does not host keeps the single name it always had.
|
|
34
|
+
*/
|
|
35
|
+
export function tunnelIngressHostnames(org) {
|
|
36
|
+
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
|
|
37
|
+
if (org.hosting.enabled) {
|
|
38
|
+
for (const entry of org.hosting.entries)
|
|
39
|
+
hostnames.push(...entryHostnames(org, entry));
|
|
40
|
+
}
|
|
41
|
+
else if (hostnames.length === 0) {
|
|
42
|
+
hostnames.push(legacyAppHostname(org));
|
|
43
|
+
}
|
|
44
|
+
if (org.calls.enabled && org.calls.usesDomain && org.domain) {
|
|
45
|
+
hostnames.push(`calls.${org.domain}`);
|
|
46
|
+
}
|
|
47
|
+
// #164: Agora always moves onto `agora.<domain>` once `org.domain` is set.
|
|
48
|
+
if (org.domain) {
|
|
49
|
+
hostnames.push(agoraHostnameFor(org));
|
|
50
|
+
}
|
|
51
|
+
return [...new Set(hostnames)];
|
|
52
|
+
}
|
|
53
|
+
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
54
|
+
export function tunnelDnsHostnames(org, domain) {
|
|
55
|
+
return tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
56
|
+
}
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCop
|
|
|
24
24
|
export type { ScopeResolver, RecordedIds } from './providers-readers.js';
|
|
25
25
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
26
26
|
export type { AgoraHostPort, AgoraHostValues } from './agora.js';
|
|
27
|
+
export type { BrokerPort } from './broker-entries.js';
|
|
28
|
+
export { PlatformBrokerPort } from './broker-entries.js';
|
|
27
29
|
export type { LoginHostBinding, LoginHostBindingsPort } from './login-host.js';
|
|
28
30
|
export { loginHostnameFor } from './login-host.js';
|
|
29
31
|
export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
|
12
12
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
13
13
|
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
14
14
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
15
|
+
export { PlatformBrokerPort } from './broker-entries.js';
|
|
15
16
|
export { loginHostnameFor } from './login-host.js';
|
|
16
17
|
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
17
18
|
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
4
|
+
* take one: Agora, the cms studio when the site is on, calls when it uses the
|
|
5
|
+
* domain, the sign-in host when the issuer does, and every platform service
|
|
6
|
+
* hostname that falls under the domain. Empty for an org with no domain.
|
|
7
|
+
*/
|
|
8
|
+
export declare function lockedHostnames(org: DeclaredOrg, platformHostnames: readonly string[]): Set<string>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { agoraHostnameFor } from './agora.js';
|
|
2
|
+
import { loginHostnameFor } from './login-host.js';
|
|
3
|
+
/**
|
|
4
|
+
* The names under the org's domain that foundry owns, so no hosting entry may
|
|
5
|
+
* take one: Agora, the cms studio when the site is on, calls when it uses the
|
|
6
|
+
* domain, the sign-in host when the issuer does, and every platform service
|
|
7
|
+
* hostname that falls under the domain. Empty for an org with no domain.
|
|
8
|
+
*/
|
|
9
|
+
export function lockedHostnames(org, platformHostnames) {
|
|
10
|
+
const { domain } = org;
|
|
11
|
+
const locked = new Set();
|
|
12
|
+
if (!domain)
|
|
13
|
+
return locked;
|
|
14
|
+
locked.add(agoraHostnameFor(org));
|
|
15
|
+
if (org.site.enabled)
|
|
16
|
+
locked.add(`cms.${domain}`);
|
|
17
|
+
if (org.calls.enabled && org.calls.usesDomain)
|
|
18
|
+
locked.add(`calls.${domain}`);
|
|
19
|
+
if (org.issuer.usesDomain)
|
|
20
|
+
locked.add(loginHostnameFor(org));
|
|
21
|
+
for (const hostname of platformHostnames) {
|
|
22
|
+
if (hostname.endsWith(`.${domain}`))
|
|
23
|
+
locked.add(hostname);
|
|
24
|
+
}
|
|
25
|
+
return locked;
|
|
26
|
+
}
|