@wtfalch/foundry 0.41.2 → 0.42.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/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0054_org_signin_in_app.sql +6 -0
- package/dist/org.d.ts +17 -0
- package/dist/org.js +14 -0
- package/dist/plan/agora.d.ts +4 -1
- package/dist/plan/agora.js +16 -2
- package/dist/plan/boule.d.ts +5 -2
- package/dist/plan/boule.js +14 -2
- package/dist/plan/coolify-previews.d.ts +10 -0
- package/dist/plan/coolify-previews.js +14 -0
- package/dist/plan/diff.js +92 -9
- package/dist/plan/edge-ratelimits.d.ts +5 -0
- package/dist/plan/edge-ratelimits.js +16 -6
- package/dist/plan/executor.d.ts +45 -0
- package/dist/plan/executor.js +283 -22
- package/dist/plan/login-host.d.ts +23 -2
- package/dist/plan/login-host.js +30 -5
- package/dist/plan/own-diff.js +6 -0
- package/dist/plan/platform-diff.js +4 -0
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-org.js +5 -0
- package/dist/plan/platform-providers.d.ts +16 -1
- package/dist/plan/platform-providers.js +255 -8
- package/dist/plan/providers-readers.js +6 -2
- package/dist/plan/sign-in-archon.d.ts +46 -0
- package/dist/plan/sign-in-archon.js +71 -0
- package/dist/plan/sign-in-bindings.d.ts +16 -0
- package/dist/plan/sign-in-bindings.js +44 -0
- package/dist/plan/sign-in.d.ts +135 -0
- package/dist/plan/sign-in.js +172 -0
- package/dist/store/postgres.js +7 -1
- package/dist/store/schema.d.ts +17 -0
- package/dist/store/schema.js +2 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -14,6 +14,7 @@ import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerNam
|
|
|
14
14
|
import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
|
|
15
15
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
16
16
|
import { BACKUP_SCOPE_PREFIX, COMPANY_BACKUP_DATABASE_NAMES, backupScopeKey, } from './company-backups.js';
|
|
17
|
+
import { previewsOn } from './coolify-previews.js';
|
|
17
18
|
import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
|
|
18
19
|
import { waitForNewDeployment, } from './deploy-health.js';
|
|
19
20
|
import { PLATFORM_NETWORK, companyNetworkName, ensureDestination, findDestination, onPlatformNetwork, placed, placedByScope, } from './destinations.js';
|
|
@@ -43,7 +44,9 @@ import { PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY
|
|
|
43
44
|
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, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
|
|
44
45
|
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
|
|
45
46
|
import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
|
|
47
|
+
import { editSignInBindings } from './sign-in-bindings.js';
|
|
46
48
|
import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
|
|
49
|
+
import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
|
|
47
50
|
import { moveSigninDatabase } from './signin-db-move.js';
|
|
48
51
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
49
52
|
/**
|
|
@@ -122,6 +125,32 @@ export class NotBuiltError extends Error {
|
|
|
122
125
|
* so a host is never read as "not in use" purely because ZITADEL's own
|
|
123
126
|
* casing or slashing differs from this step's.
|
|
124
127
|
*/
|
|
128
|
+
/**
|
|
129
|
+
* The in-app hosts a login-host step carries, read back and checked: a plan from an older build carries
|
|
130
|
+
* none, and a malformed entry is refused by name (never echoed) rather than bound.
|
|
131
|
+
*/
|
|
132
|
+
function inAppAppsOf(raw, slug) {
|
|
133
|
+
if (raw === undefined)
|
|
134
|
+
return [];
|
|
135
|
+
if (!Array.isArray(raw))
|
|
136
|
+
throw new Error(`zitadel:login-host for ${slug}: apps is not a list`);
|
|
137
|
+
return raw.map((entry) => {
|
|
138
|
+
const e = entry;
|
|
139
|
+
if (!e ||
|
|
140
|
+
(e.app !== 'agora' && e.app !== 'boule') ||
|
|
141
|
+
typeof e.host !== 'string' ||
|
|
142
|
+
!e.host ||
|
|
143
|
+
typeof e.clientId !== 'string' ||
|
|
144
|
+
!e.clientId) {
|
|
145
|
+
throw new Error(`zitadel:login-host for ${slug}: an in-app host entry is malformed`);
|
|
146
|
+
}
|
|
147
|
+
return { app: e.app, host: e.host, clientId: e.clientId };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** The ZITADEL ids of the company's Agora and Boule clients: the apps in-app sign-in can move. */
|
|
151
|
+
function ownInAppAppIds(recorded) {
|
|
152
|
+
return new Set([recorded['zitadel:app:agora'], recorded['zitadel:app:boule']].filter((id) => Boolean(id)));
|
|
153
|
+
}
|
|
125
154
|
function normalizedLoginBaseUri(uri) {
|
|
126
155
|
return uri.toLowerCase().replace(/\/$/, '');
|
|
127
156
|
}
|
|
@@ -1101,7 +1130,7 @@ export class FoundryStepExecutor {
|
|
|
1101
1130
|
case 'cloudflare:gate':
|
|
1102
1131
|
return this.cloudflareGate(action, org, context);
|
|
1103
1132
|
case 'cloudflare:ratelimit':
|
|
1104
|
-
return this.cloudflareRateLimit(org);
|
|
1133
|
+
return this.cloudflareRateLimit(org, context);
|
|
1105
1134
|
case 'cloudflare:worker':
|
|
1106
1135
|
return this.cloudflareWorker(action, org, context);
|
|
1107
1136
|
case 'files:organisation':
|
|
@@ -1310,7 +1339,7 @@ export class FoundryStepExecutor {
|
|
|
1310
1339
|
* `cloudflareAccess`'s own refusal when a foreign Access app already
|
|
1311
1340
|
* holds the hostname.
|
|
1312
1341
|
*/
|
|
1313
|
-
async cloudflareRateLimit(org) {
|
|
1342
|
+
async cloudflareRateLimit(org, context) {
|
|
1314
1343
|
if (!org.domain) {
|
|
1315
1344
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
|
|
1316
1345
|
}
|
|
@@ -1319,20 +1348,41 @@ export class FoundryStepExecutor {
|
|
|
1319
1348
|
if (!zone) {
|
|
1320
1349
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
|
|
1321
1350
|
}
|
|
1351
|
+
// The zone's one Free-plan rule: the base one, or with in-app sign-in the same limit that also
|
|
1352
|
+
// covers POSTs under `/sign-in/` (static assets there stay unlimited).
|
|
1353
|
+
const inApp = inAppSignIn(org);
|
|
1354
|
+
const wanted = inApp
|
|
1355
|
+
? { description: SIGN_IN_RATE_LIMIT_DESCRIPTION, expression: SIGN_IN_RATE_LIMIT_EXPRESSION }
|
|
1356
|
+
: { description: RATE_LIMIT_RULE_DESCRIPTION, expression: SIGN_IN_PATH_EXPRESSION };
|
|
1357
|
+
const ours = [RATE_LIMIT_RULE_DESCRIPTION, SIGN_IN_RATE_LIMIT_DESCRIPTION];
|
|
1322
1358
|
const rateLimits = this.rateLimitPort();
|
|
1323
1359
|
const rules = await rateLimits.listRateLimitRules(zone.id);
|
|
1324
|
-
const own = rules.find((r) => r.description ===
|
|
1360
|
+
const own = rules.find((r) => r.description === wanted.description);
|
|
1325
1361
|
if (!own) {
|
|
1326
|
-
const foreign = rules
|
|
1362
|
+
const foreign = rules.find((r) => !ours.includes(r.description ?? ''));
|
|
1327
1363
|
if (foreign) {
|
|
1328
1364
|
throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
|
|
1329
1365
|
}
|
|
1366
|
+
// The other variant of foundry's own rule holds the zone's one slot: remove it, then make this one.
|
|
1367
|
+
const stale = rules.find((r) => ours.includes(r.description ?? ''));
|
|
1368
|
+
if (stale) {
|
|
1369
|
+
if (!rateLimits.deleteRateLimitRule) {
|
|
1370
|
+
throw new Error(`cloudflare:ratelimit for ${org.slug}: the rate limit rule must change but this host's rate limit port cannot delete a rule`);
|
|
1371
|
+
}
|
|
1372
|
+
try {
|
|
1373
|
+
await rateLimits.deleteRateLimitRule(zone.id, stale.id);
|
|
1374
|
+
}
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
if (!isNotFoundError(error))
|
|
1377
|
+
throw error;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1330
1380
|
}
|
|
1331
1381
|
// Edge adopts a live rule with no edge row (made before edge owned rate
|
|
1332
1382
|
// limits) and then makes the rule once: a repeat returns it unchanged.
|
|
1333
1383
|
const created = await rateLimits.createRateLimitRule(zone.id, {
|
|
1334
|
-
description:
|
|
1335
|
-
expression:
|
|
1384
|
+
description: wanted.description,
|
|
1385
|
+
expression: wanted.expression,
|
|
1336
1386
|
action: 'block',
|
|
1337
1387
|
ratelimit: {
|
|
1338
1388
|
characteristics: ['cf.colo.id', 'ip.src'],
|
|
@@ -1341,6 +1391,8 @@ export class FoundryStepExecutor {
|
|
|
1341
1391
|
mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
|
|
1342
1392
|
},
|
|
1343
1393
|
});
|
|
1394
|
+
// Last: a crash before here plans the step again.
|
|
1395
|
+
context.recordScopeId(SIGN_IN_RATE_LIMIT_SCOPE_KEY, inApp ? '1' : '');
|
|
1344
1396
|
return { resourceId: created.id };
|
|
1345
1397
|
}
|
|
1346
1398
|
/**
|
|
@@ -1745,6 +1797,13 @@ export class FoundryStepExecutor {
|
|
|
1745
1797
|
* client id that binding holds. Apps created later are not in it yet:
|
|
1746
1798
|
* the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
|
|
1747
1799
|
*
|
|
1800
|
+
* In-app sign-in (`signIn.inApp`, `plan/sign-in.ts` has the order): an app host is trusted first. It is
|
|
1801
|
+
* bound, and its app pointed at `/sign-in/`, only once its app runs `SIGNIN_UPSTREAM` (the app step
|
|
1802
|
+
* records that). The bind moves the app's client id off the company's binding in the same write (the
|
|
1803
|
+
* login refuses a client id in two bindings); a company binding left with no client is removed.
|
|
1804
|
+
* Switched off, the client id goes back and the base URI returns to the login host, and a trusted
|
|
1805
|
+
* domain is given up only after the app dropped the env.
|
|
1806
|
+
*
|
|
1748
1807
|
* #274: when the org's host has moved (`recorded[key]`, the *previous*
|
|
1749
1808
|
* run's resourceId, differs from this run's `host`), also removes every
|
|
1750
1809
|
* host that is no longer the org's current one from both places --
|
|
@@ -1812,8 +1871,13 @@ export class FoundryStepExecutor {
|
|
|
1812
1871
|
if (!zitadelOrgId) {
|
|
1813
1872
|
throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
1814
1873
|
}
|
|
1815
|
-
//
|
|
1816
|
-
|
|
1874
|
+
// A company that runs its own services writes the host table of its own in-app login app; every
|
|
1875
|
+
// other company, the shared one's through the host's port. `planLoginHost` blocks the step while
|
|
1876
|
+
// the shared port is not wired.
|
|
1877
|
+
const own = ownsServices(org);
|
|
1878
|
+
const bindings = own
|
|
1879
|
+
? this.ownSignInBindings(org, recorded, action.desired.host)
|
|
1880
|
+
: this.ports.loginHostBindings;
|
|
1817
1881
|
if (!bindings) {
|
|
1818
1882
|
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
1819
1883
|
}
|
|
@@ -1821,13 +1885,84 @@ export class FoundryStepExecutor {
|
|
|
1821
1885
|
// The platform's own shared sign-in host: already trusted and routed by
|
|
1822
1886
|
// the shared instance, so it gets no trusted domain or binding here.
|
|
1823
1887
|
const shared = action.desired.shared === true;
|
|
1824
|
-
|
|
1888
|
+
// In-app sign-in: the plan carries the app hosts to bind (empty with the switch off).
|
|
1889
|
+
const inApp = inAppAppsOf(action.desired.apps, org.slug);
|
|
1890
|
+
// The instance ignores an app's own base URI unless it is told not to: a port that cannot say so
|
|
1891
|
+
// stops the step before anything is written, rather than recording a base URI that has no effect.
|
|
1892
|
+
if (!shared && own && inApp.length > 0 && !this.ports.zitadel.allowPerAppLoginBaseUri) {
|
|
1893
|
+
throw new Error(`zitadel:login-host for ${org.slug}: the ZITADEL port cannot allow a per-app login base URI, so the app hosts cannot sign in on their own addresses`);
|
|
1894
|
+
}
|
|
1895
|
+
// The app hosts that are bound and get the app's own address as base URI: only once their app runs
|
|
1896
|
+
// with `SIGNIN_UPSTREAM` (the app steps record it), never before. Every other app host is trusted
|
|
1897
|
+
// and waits, and the company's own binding keeps its client id, so the old path signs in meanwhile.
|
|
1898
|
+
const targets = inAppTargets(inApp, recorded);
|
|
1899
|
+
const activePairs = inAppStatePairs(targets.active);
|
|
1900
|
+
const activeHosts = new Set(activePairs.map((pair) => pair.host));
|
|
1901
|
+
const envLive = signInEnvLiveHosts(recorded);
|
|
1902
|
+
// The login refuses a client id held by two bindings: an active app host's client id is in its own binding only.
|
|
1903
|
+
const clientIds = loginHostClientIds(org, recorded, activePairs.map((pair) => pair.clientId));
|
|
1825
1904
|
if (!shared) {
|
|
1826
|
-
|
|
1905
|
+
// The company's own login host is its instance's external domain: trusted and routed already.
|
|
1906
|
+
if (!own)
|
|
1907
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
1908
|
+
// Each app host is trusted first.
|
|
1909
|
+
for (const app of inApp)
|
|
1910
|
+
await this.ports.zitadel.ensureTrustedDomain(app.host);
|
|
1911
|
+
// An app host bound before and not active now gives its client id back to the company's binding:
|
|
1912
|
+
// its own binding goes first, so no write leaves the id in two bindings.
|
|
1913
|
+
let unbound = false;
|
|
1914
|
+
for (const old of inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY])) {
|
|
1915
|
+
if (activeHosts.has(old))
|
|
1916
|
+
continue;
|
|
1917
|
+
try {
|
|
1918
|
+
await bindings.deleteBinding(old);
|
|
1919
|
+
}
|
|
1920
|
+
catch (error) {
|
|
1921
|
+
if (!isNotFoundError(error))
|
|
1922
|
+
throw error;
|
|
1923
|
+
}
|
|
1924
|
+
unbound = true;
|
|
1925
|
+
}
|
|
1926
|
+
if (unbound)
|
|
1927
|
+
await bindings.flush?.();
|
|
1827
1928
|
// zitadel-login's own routing table, written before any app is repointed
|
|
1828
1929
|
// so no app ever points at a host with no binding.
|
|
1829
|
-
|
|
1930
|
+
if (!own) {
|
|
1931
|
+
if (clientIds.length > 0 || activePairs.length === 0) {
|
|
1932
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
1933
|
+
}
|
|
1934
|
+
else {
|
|
1935
|
+
// Every client of the company signs in on its app's own address: the login refuses a binding
|
|
1936
|
+
// with no client ids, so the company's own binding goes (nothing signs in on this host).
|
|
1937
|
+
try {
|
|
1938
|
+
await bindings.deleteBinding(host);
|
|
1939
|
+
}
|
|
1940
|
+
catch (error) {
|
|
1941
|
+
if (!isNotFoundError(error))
|
|
1942
|
+
throw error;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
for (const app of inApp) {
|
|
1947
|
+
if (!activeHosts.has(app.host))
|
|
1948
|
+
continue;
|
|
1949
|
+
await bindings.upsertBinding({ ...inAppBindingFor(app), organizationId: zitadelOrgId });
|
|
1950
|
+
}
|
|
1951
|
+
// One write for every upsert above (one redeploy of the login), before any app is repointed.
|
|
1952
|
+
await bindings.flush?.();
|
|
1953
|
+
if (own && activeHosts.size > 0) {
|
|
1954
|
+
await this.ports.zitadel.allowPerAppLoginBaseUri?.(`https://${host}/`);
|
|
1955
|
+
}
|
|
1830
1956
|
}
|
|
1957
|
+
// The base URI each app's sign-in starts from: its own address under `/sign-in/` in-app, else the login host.
|
|
1958
|
+
const appBaseUris = new Map();
|
|
1959
|
+
for (const app of inApp) {
|
|
1960
|
+
const appId = recorded[`zitadel:app:${app.app}`];
|
|
1961
|
+
if (appId && activeHosts.has(app.host)) {
|
|
1962
|
+
appBaseUris.set(appId, `https://${app.host}${SIGN_IN_BASE_PATH}/`);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
const baseUriOf = (appId) => appBaseUris.get(appId) ?? `https://${host}/`;
|
|
1831
1966
|
// Only apps whose client id the binding now holds: any other app in the
|
|
1832
1967
|
// project would lose sign-in. The rest move on a later run, once the
|
|
1833
1968
|
// recorded client ids differ from the binding and replan this step.
|
|
@@ -1858,7 +1993,10 @@ export class FoundryStepExecutor {
|
|
|
1858
1993
|
for (const app of liveApps) {
|
|
1859
1994
|
if (!boundAppIds.has(app.id))
|
|
1860
1995
|
continue;
|
|
1861
|
-
|
|
1996
|
+
// A company's own instance has the login host as its default; only the in-app apps move.
|
|
1997
|
+
if (own && !appBaseUris.has(app.id) && !ownInAppAppIds(recorded).has(app.id))
|
|
1998
|
+
continue;
|
|
1999
|
+
await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, baseUriOf(app.id));
|
|
1862
2000
|
}
|
|
1863
2001
|
}
|
|
1864
2002
|
// Fix round 3 point 5: a bound app's current base URI is `host` for
|
|
@@ -1870,7 +2008,7 @@ export class FoundryStepExecutor {
|
|
|
1870
2008
|
// compare against, same as an app this loop never visits at all.
|
|
1871
2009
|
const currentLoginBaseUris = new Set();
|
|
1872
2010
|
for (const app of liveApps) {
|
|
1873
|
-
const uri = boundAppIds.has(app.id) ?
|
|
2011
|
+
const uri = boundAppIds.has(app.id) ? baseUriOf(app.id) : app.loginBaseUri;
|
|
1874
2012
|
if (typeof uri === 'string')
|
|
1875
2013
|
currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
|
|
1876
2014
|
}
|
|
@@ -1897,6 +2035,19 @@ export class FoundryStepExecutor {
|
|
|
1897
2035
|
candidates.delete(host);
|
|
1898
2036
|
if (previousHost && previousHost !== host)
|
|
1899
2037
|
candidates.add(previousHost);
|
|
2038
|
+
// An in-app host applied before and no longer wanted (switch off, or the app is gone) is owed a removal
|
|
2039
|
+
// the same way an old login host is; a host still wanted is never a candidate.
|
|
2040
|
+
// An app that still holds `SIGNIN_UPSTREAM` keeps its binding and trusted domain: it is switched off
|
|
2041
|
+
// in reverse order, the base URI first, then the app's env, then these.
|
|
2042
|
+
for (const old of [
|
|
2043
|
+
...inAppStateHosts(recorded[SIGN_IN_TRUSTED_SCOPE_KEY]),
|
|
2044
|
+
...inAppStateHosts(recorded[SIGN_IN_IN_APP_SCOPE_KEY]),
|
|
2045
|
+
]) {
|
|
2046
|
+
if (!envLive.has(old))
|
|
2047
|
+
candidates.add(old);
|
|
2048
|
+
}
|
|
2049
|
+
for (const app of inApp)
|
|
2050
|
+
candidates.delete(app.host);
|
|
1900
2051
|
// #274: a removal, once the new host is fully live (trusted, bound, and
|
|
1901
2052
|
// every repointable app repointed) -- never the other way round, so an
|
|
1902
2053
|
// org is never left with no trusted host at all if this throws partway
|
|
@@ -1905,9 +2056,11 @@ export class FoundryStepExecutor {
|
|
|
1905
2056
|
// its connector app and its tunnel): a domain already gone must not skip
|
|
1906
2057
|
// the binding delete that follows it.
|
|
1907
2058
|
const stillPending = [];
|
|
2059
|
+
let removed = false;
|
|
1908
2060
|
for (const candidate of candidates) {
|
|
1909
2061
|
if (!canDecideSafely ||
|
|
1910
|
-
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`))
|
|
2062
|
+
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}/`)) ||
|
|
2063
|
+
currentLoginBaseUris.has(normalizedLoginBaseUri(`https://${candidate}${SIGN_IN_BASE_PATH}/`))) {
|
|
1911
2064
|
stillPending.push(candidate);
|
|
1912
2065
|
continue;
|
|
1913
2066
|
}
|
|
@@ -1918,6 +2071,7 @@ export class FoundryStepExecutor {
|
|
|
1918
2071
|
if (!isNotFoundError(error))
|
|
1919
2072
|
throw error;
|
|
1920
2073
|
}
|
|
2074
|
+
removed = true;
|
|
1921
2075
|
try {
|
|
1922
2076
|
await bindings.deleteBinding(candidate);
|
|
1923
2077
|
}
|
|
@@ -1926,6 +2080,9 @@ export class FoundryStepExecutor {
|
|
|
1926
2080
|
throw error;
|
|
1927
2081
|
}
|
|
1928
2082
|
}
|
|
2083
|
+
// Nothing queued, nothing to write: a step that only added hosts has already flushed once.
|
|
2084
|
+
if (removed)
|
|
2085
|
+
await bindings.flush?.();
|
|
1929
2086
|
// Both last, so a crash above reruns the whole step rather than losing
|
|
1930
2087
|
// track of either. A client id recorded after this point differs from
|
|
1931
2088
|
// the binding and replans the step next run; a nonempty pending list
|
|
@@ -1933,11 +2090,44 @@ export class FoundryStepExecutor {
|
|
|
1933
2090
|
// normally alone decides it.
|
|
1934
2091
|
if (shared)
|
|
1935
2092
|
context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
|
|
1936
|
-
else
|
|
1937
|
-
|
|
2093
|
+
else {
|
|
2094
|
+
if (!own)
|
|
2095
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
2096
|
+
context.recordScopeId(SIGN_IN_TRUSTED_SCOPE_KEY, targets.trusted);
|
|
2097
|
+
context.recordScopeId(SIGN_IN_IN_APP_SCOPE_KEY, targets.active);
|
|
2098
|
+
}
|
|
1938
2099
|
context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
|
|
1939
2100
|
return { resourceId: host };
|
|
1940
2101
|
}
|
|
2102
|
+
/**
|
|
2103
|
+
* The host table of a company's own in-app login app as a bindings port that collects its edits and
|
|
2104
|
+
* writes them once on `flush` (one redeploy, none when nothing changed). Used for a company that runs
|
|
2105
|
+
* its own services, which has no host-supplied bindings port.
|
|
2106
|
+
*/
|
|
2107
|
+
ownSignInBindings(org, recorded, instanceHost) {
|
|
2108
|
+
const appId = recorded[SIGN_IN_LOGIN_APP_SCOPE_KEY];
|
|
2109
|
+
if (!appId) {
|
|
2110
|
+
throw new Error(`zitadel:login-host for ${org.slug}: the company's in-app login app is not recorded yet`);
|
|
2111
|
+
}
|
|
2112
|
+
const upsert = [];
|
|
2113
|
+
const remove = [];
|
|
2114
|
+
return {
|
|
2115
|
+
upsertBinding: async (binding) => {
|
|
2116
|
+
upsert.push(binding);
|
|
2117
|
+
},
|
|
2118
|
+
deleteBinding: async (host) => {
|
|
2119
|
+
remove.push(host);
|
|
2120
|
+
},
|
|
2121
|
+
flush: async () => {
|
|
2122
|
+
if (upsert.length === 0 && remove.length === 0)
|
|
2123
|
+
return;
|
|
2124
|
+
const edits = { appId, instanceHost, upsert: [...upsert], remove: [...remove] };
|
|
2125
|
+
upsert.length = 0;
|
|
2126
|
+
remove.length = 0;
|
|
2127
|
+
await editSignInBindings(this.ports.coolify, edits);
|
|
2128
|
+
},
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
1941
2131
|
/**
|
|
1942
2132
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
1943
2133
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
@@ -2403,6 +2593,7 @@ export class FoundryStepExecutor {
|
|
|
2403
2593
|
const server = await this.resolveServer(org, context);
|
|
2404
2594
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
2405
2595
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
2596
|
+
is_preview_deployments_enabled: false,
|
|
2406
2597
|
project_uuid: projectUuid,
|
|
2407
2598
|
server_uuid: server.uuid,
|
|
2408
2599
|
environment_name: environment.name,
|
|
@@ -2438,6 +2629,10 @@ export class FoundryStepExecutor {
|
|
|
2438
2629
|
}
|
|
2439
2630
|
if (want.autoDeploy !== have.autoDeploy)
|
|
2440
2631
|
patch.is_auto_deploy_enabled = want.autoDeploy;
|
|
2632
|
+
// #788: an app found with previews on gets them off (`planApp` plans the
|
|
2633
|
+
// step for it). A created app has them off from the create.
|
|
2634
|
+
if (previewsOn(live))
|
|
2635
|
+
patch.is_preview_deployments_enabled = false;
|
|
2441
2636
|
// #543: the health-check quartet too, every run -- the same reason
|
|
2442
2637
|
// Archon's own doc comment gives (without one, Coolify's rolling update
|
|
2443
2638
|
// has nothing to wait on and stops the old container immediately). The
|
|
@@ -3857,6 +4052,7 @@ export class FoundryStepExecutor {
|
|
|
3857
4052
|
const server = await this.resolveServer(input.org, input.context);
|
|
3858
4053
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
3859
4054
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
4055
|
+
is_preview_deployments_enabled: false,
|
|
3860
4056
|
project_uuid: input.projectUuid,
|
|
3861
4057
|
server_uuid: server.uuid,
|
|
3862
4058
|
environment_name: input.environment.name,
|
|
@@ -4389,6 +4585,7 @@ export class FoundryStepExecutor {
|
|
|
4389
4585
|
}
|
|
4390
4586
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
4391
4587
|
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
4588
|
+
is_preview_deployments_enabled: false,
|
|
4392
4589
|
project_uuid: projectUuid,
|
|
4393
4590
|
server_uuid: server.uuid,
|
|
4394
4591
|
environment_name: environment.name,
|
|
@@ -4413,6 +4610,7 @@ export class FoundryStepExecutor {
|
|
|
4413
4610
|
instant_deploy: false,
|
|
4414
4611
|
}), { startIfStopped: false });
|
|
4415
4612
|
const service = await this.findOrCreateCallsApp(names.service, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
4613
|
+
is_preview_deployments_enabled: false,
|
|
4416
4614
|
project_uuid: projectUuid,
|
|
4417
4615
|
server_uuid: server.uuid,
|
|
4418
4616
|
environment_name: environment.name,
|
|
@@ -4613,6 +4811,7 @@ export class FoundryStepExecutor {
|
|
|
4613
4811
|
}
|
|
4614
4812
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
4615
4813
|
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
4814
|
+
is_preview_deployments_enabled: false,
|
|
4616
4815
|
project_uuid: projectUuid,
|
|
4617
4816
|
server_uuid: server.uuid,
|
|
4618
4817
|
environment_name: environment.name,
|
|
@@ -4819,6 +5018,7 @@ export class FoundryStepExecutor {
|
|
|
4819
5018
|
const server = await this.resolveServer(org, context);
|
|
4820
5019
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
4821
5020
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
5021
|
+
is_preview_deployments_enabled: false,
|
|
4822
5022
|
project_uuid: projectUuid,
|
|
4823
5023
|
server_uuid: server.uuid,
|
|
4824
5024
|
environment_name: environment.name,
|
|
@@ -5199,6 +5399,7 @@ export class FoundryStepExecutor {
|
|
|
5199
5399
|
const server = await this.resolveServer(org, context);
|
|
5200
5400
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
5201
5401
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
5402
|
+
is_preview_deployments_enabled: false,
|
|
5202
5403
|
project_uuid: projectUuid,
|
|
5203
5404
|
server_uuid: server.uuid,
|
|
5204
5405
|
environment_name: environment.name,
|
|
@@ -5233,10 +5434,10 @@ export class FoundryStepExecutor {
|
|
|
5233
5434
|
// #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
|
|
5234
5435
|
// Coolify's own env store matching `env`, which is why that store alone
|
|
5235
5436
|
// can no longer tell a changed value from one already live.
|
|
5236
|
-
const
|
|
5237
|
-
|
|
5238
|
-
.map((e) => [e.key, e.value]));
|
|
5437
|
+
const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
|
|
5438
|
+
const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
|
|
5239
5439
|
const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
5440
|
+
await this.dropStaleSignInUpstream(app.appId, env, liveRows);
|
|
5240
5441
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
5241
5442
|
const revision = agoraEnvRevision(env);
|
|
5242
5443
|
const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
|
|
@@ -5262,9 +5463,43 @@ export class FoundryStepExecutor {
|
|
|
5262
5463
|
else if (app.existed && (envChanged || !alreadyActivated)) {
|
|
5263
5464
|
await this.ports.coolify.restart(app.appId);
|
|
5264
5465
|
}
|
|
5466
|
+
// The login-host step points the sign-in at this app's own address only once the env is running. Before the
|
|
5467
|
+
// activation marker, so a crash between the two plans this step again and records it.
|
|
5468
|
+
this.recordSignInEnvLive(context, 'agora', agoraHostnameFor(org), env);
|
|
5265
5469
|
context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
|
|
5266
5470
|
return { resourceId: app.appId };
|
|
5267
5471
|
}
|
|
5472
|
+
/**
|
|
5473
|
+
* Records whether an app's running env holds `SIGNIN_UPSTREAM` (the app's host while it does, `''` once it
|
|
5474
|
+
* does not), so the login-host step knows when to point the base URI at the app and when it may give the
|
|
5475
|
+
* host up. Written only when the value changes.
|
|
5476
|
+
*/
|
|
5477
|
+
recordSignInEnvLive(context, app, host, env) {
|
|
5478
|
+
const key = `${SIGN_IN_ENV_LIVE_SCOPE_PREFIX}${app}`;
|
|
5479
|
+
const value = env[SIGN_IN_UPSTREAM_ENV] !== undefined ? host : '';
|
|
5480
|
+
if ((context.recordedIds()[key] ?? '') !== value)
|
|
5481
|
+
context.recordScopeId(key, value);
|
|
5482
|
+
}
|
|
5483
|
+
/**
|
|
5484
|
+
* In-app sign-in switched off (or its host given up): the app's live `SIGNIN_UPSTREAM` is deleted, because
|
|
5485
|
+
* `bulkUpsertEnvs` never removes a key. Left in place, the app would keep proxying `/sign-in/*` to the login.
|
|
5486
|
+
* Nothing happens while the env is desired or the app does not hold it.
|
|
5487
|
+
*/
|
|
5488
|
+
async dropStaleSignInUpstream(appId, desired, liveRows) {
|
|
5489
|
+
if (desired[SIGN_IN_UPSTREAM_ENV] !== undefined)
|
|
5490
|
+
return;
|
|
5491
|
+
for (const row of liveRows) {
|
|
5492
|
+
if (row.key !== SIGN_IN_UPSTREAM_ENV || !row.uuid)
|
|
5493
|
+
continue;
|
|
5494
|
+
try {
|
|
5495
|
+
await this.ports.coolify.deleteEnv(appId, row.uuid);
|
|
5496
|
+
}
|
|
5497
|
+
catch (error) {
|
|
5498
|
+
if (!isNotFoundError(error))
|
|
5499
|
+
throw error;
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
}
|
|
5268
5503
|
// ---- boule (#161) --------------------------------------------------------
|
|
5269
5504
|
/**
|
|
5270
5505
|
* `plan/diff.ts`'s `planBoule`: provisions the org's own Boule
|
|
@@ -5318,6 +5553,7 @@ export class FoundryStepExecutor {
|
|
|
5318
5553
|
const server = await this.resolveServer(org, context);
|
|
5319
5554
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
5320
5555
|
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
5556
|
+
is_preview_deployments_enabled: false,
|
|
5321
5557
|
project_uuid: projectUuid,
|
|
5322
5558
|
server_uuid: server.uuid,
|
|
5323
5559
|
environment_name: environment.name,
|
|
@@ -5339,10 +5575,10 @@ export class FoundryStepExecutor {
|
|
|
5339
5575
|
...healthCheckSettings('/api/health', BOULE_PORT),
|
|
5340
5576
|
...(await this.routedLabels(name, bouleHostnameFor(org), BOULE_PORT)),
|
|
5341
5577
|
});
|
|
5342
|
-
const
|
|
5343
|
-
|
|
5344
|
-
.map((e) => [e.key, e.value]));
|
|
5578
|
+
const liveRows = (await this.ports.coolify.listEnvs(app.appId)).filter((e) => !e.is_preview);
|
|
5579
|
+
const liveEnv = new Map(liveRows.map((e) => [e.key, e.value]));
|
|
5345
5580
|
const envChanged = bouleEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
|
|
5581
|
+
await this.dropStaleSignInUpstream(app.appId, env, liveRows);
|
|
5346
5582
|
const envs = Object.entries(env).map(([key, value]) => ({ key, value }));
|
|
5347
5583
|
const cookieSecret = await this.ensureAuthCookieSecret(app.appId);
|
|
5348
5584
|
if (cookieSecret)
|
|
@@ -5416,6 +5652,8 @@ export class FoundryStepExecutor {
|
|
|
5416
5652
|
if (action.desired.platformOwner === true) {
|
|
5417
5653
|
context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
|
|
5418
5654
|
}
|
|
5655
|
+
// Last: the login-host step points the sign-in at this app's own address only once the env is running.
|
|
5656
|
+
this.recordSignInEnvLive(context, 'boule', bouleHostnameFor(org), env);
|
|
5419
5657
|
return { resourceId: app.appId };
|
|
5420
5658
|
}
|
|
5421
5659
|
/**
|
|
@@ -5858,6 +6096,9 @@ export class FoundryStepExecutor {
|
|
|
5858
6096
|
smtpSender: `noreply@${hn.mailDomain}`,
|
|
5859
6097
|
defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
|
|
5860
6098
|
ownerCompany: null,
|
|
6099
|
+
// In-app sign-in: the company's own stack gets the second login app. Its host table starts
|
|
6100
|
+
// empty; the login-host step writes it once the apps' clients exist.
|
|
6101
|
+
...(inAppSignIn(org) ? { signInLogin: { bindings: null } } : {}),
|
|
5861
6102
|
});
|
|
5862
6103
|
if (refChanged) {
|
|
5863
6104
|
const now = scope.recordedIds();
|
|
@@ -7953,3 +8194,23 @@ export function isNotFoundError(error) {
|
|
|
7953
8194
|
const message = error instanceof Error ? error.message : String(error);
|
|
7954
8195
|
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
7955
8196
|
}
|
|
8197
|
+
/** ZITADEL's error code for an update that changes nothing (`COMMAND-1m88i`, "No changes"). */
|
|
8198
|
+
const ZITADEL_NO_CHANGES_CODE = 'COMMAND-1m88i';
|
|
8199
|
+
/** True when ZITADEL refused an update because the value already is the wanted one. Matches the error code, never the free text. */
|
|
8200
|
+
export function isNoChangesError(error) {
|
|
8201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8202
|
+
return message.includes(ZITADEL_NO_CHANGES_CODE);
|
|
8203
|
+
}
|
|
8204
|
+
/**
|
|
8205
|
+
* {@link ZitadelPort.updateOidcAppLoginBaseUri} that counts an app already holding the wanted base URI as
|
|
8206
|
+
* done: ZITADEL answers that write with "No changes" and a repeat apply must not fail on it.
|
|
8207
|
+
*/
|
|
8208
|
+
export async function setOidcAppLoginBaseUri(zitadel, orgId, projectId, appId, loginBaseUri) {
|
|
8209
|
+
try {
|
|
8210
|
+
await zitadel.updateOidcAppLoginBaseUri(orgId, projectId, appId, loginBaseUri);
|
|
8211
|
+
}
|
|
8212
|
+
catch (error) {
|
|
8213
|
+
if (!isNoChangesError(error))
|
|
8214
|
+
throw error;
|
|
8215
|
+
}
|
|
8216
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import { type InAppApp, type SIGN_IN_BASE_PATH } from './sign-in.js';
|
|
2
3
|
/**
|
|
3
4
|
* #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
|
|
4
5
|
* need to agree on for an org's own company login host -- the hostname and
|
|
@@ -99,7 +100,17 @@ export interface LoginHostBinding {
|
|
|
99
100
|
host: string;
|
|
100
101
|
organizationId: string;
|
|
101
102
|
clientIds: string[];
|
|
103
|
+
/** In-app sign-in: which app each client id is, so the login draws that app's room. Absent: the company's own room. */
|
|
104
|
+
apps?: Record<string, 'agora' | 'boule' | 'archon'>;
|
|
105
|
+
/** In-app sign-in: the login serves this host under `/sign-in`. Absent: the root. */
|
|
106
|
+
basePath?: typeof SIGN_IN_BASE_PATH;
|
|
102
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The app hosts that sign in on their own address when the company has `signIn.inApp`: Agora's and
|
|
110
|
+
* Boule's, each only once its client id is recorded. Empty with the switch off. Archon (owner company
|
|
111
|
+
* only) is not here: the platform's Archon step owns its host (`sign-in-archon.ts`).
|
|
112
|
+
*/
|
|
113
|
+
export declare function inAppApps(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): InAppApp[];
|
|
103
114
|
/**
|
|
104
115
|
* Host-supplied write access to zitadel-login's own `LOGIN_HOST_BINDINGS`
|
|
105
116
|
* env (`auth/docs/company-login-hosts.md`'s own "Set `LOGIN_HOST_BINDINGS`
|
|
@@ -121,6 +132,12 @@ export interface LoginHostBindingsPort {
|
|
|
121
132
|
* package.
|
|
122
133
|
*/
|
|
123
134
|
deleteBinding(host: string): Promise<void>;
|
|
135
|
+
/**
|
|
136
|
+
* A port that queues its upserts writes them here, once: one redeploy of the login however many hosts
|
|
137
|
+
* the step bound. A port without it writes on each call. `deleteBinding` writes at once on either.
|
|
138
|
+
* The login-host step calls it after all its upserts and before any app is repointed.
|
|
139
|
+
*/
|
|
140
|
+
flush?(): Promise<void>;
|
|
124
141
|
}
|
|
125
142
|
/**
|
|
126
143
|
* Scope id holding what the binding was last written with
|
|
@@ -137,8 +154,12 @@ export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
|
|
|
137
154
|
* is no longer declared is not here, so it stays bound as before.
|
|
138
155
|
*/
|
|
139
156
|
export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
|
|
140
|
-
/**
|
|
141
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule),
|
|
159
|
+
* deduplicated and sorted, bar `except`: the login refuses a client id held by two bindings, so the ids of the
|
|
160
|
+
* app hosts that have their own binding (in-app sign-in) are not in the company's.
|
|
161
|
+
*/
|
|
162
|
+
export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, except?: readonly string[]): string[];
|
|
142
163
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
143
164
|
export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
|
|
144
165
|
/**
|