@agentconnect.md/setup 1.61.0-rc.186 → 1.61.0-rc.190
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/README.md +6 -5
- package/dist/index.js +643 -213
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -38257,6 +38257,7 @@ const initializer = (inst, issues) => {
|
|
|
38257
38257
|
}
|
|
38258
38258
|
});
|
|
38259
38259
|
};
|
|
38260
|
+
const ZodError = /*@__PURE__*/ $constructor("ZodError", initializer);
|
|
38260
38261
|
const ZodRealError = /*@__PURE__*/ $constructor("ZodError", initializer, void 0, { Parent: Error });
|
|
38261
38262
|
//#endregion
|
|
38262
38263
|
//#region ../../node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/classic/parse.js
|
|
@@ -38264,7 +38265,7 @@ const parse$3 = /* @__PURE__ */ _parse(ZodRealError);
|
|
|
38264
38265
|
const parseAsync = /* @__PURE__ */ _parseAsync(ZodRealError);
|
|
38265
38266
|
const safeParse = /* @__PURE__ */ _safeParse(ZodRealError);
|
|
38266
38267
|
const safeParseAsync = /* @__PURE__ */ _safeParseAsync(ZodRealError);
|
|
38267
|
-
const encode$
|
|
38268
|
+
const encode$2 = /* @__PURE__ */ _encode(ZodRealError);
|
|
38268
38269
|
const decode$1 = /* @__PURE__ */ _decode(ZodRealError);
|
|
38269
38270
|
const encodeAsync = /* @__PURE__ */ _encodeAsync(ZodRealError);
|
|
38270
38271
|
const decodeAsync = /* @__PURE__ */ _decodeAsync(ZodRealError);
|
|
@@ -38416,7 +38417,7 @@ const ZodType = /*@__PURE__*/ $constructor("ZodType", (inst, def) => {
|
|
|
38416
38417
|
return validateAsync$1(this, data, params);
|
|
38417
38418
|
},
|
|
38418
38419
|
encode: function _encode(data, params) {
|
|
38419
|
-
return encode$
|
|
38420
|
+
return encode$2(this, data, params, { callee: _encode });
|
|
38420
38421
|
},
|
|
38421
38422
|
decode: function _decode(data, params) {
|
|
38422
38423
|
return decode$1(this, data, params, { callee: _decode });
|
|
@@ -60648,6 +60649,10 @@ const SlackAppSchema = preprocess(withoutProviderUrlSnapshot, strictObject({
|
|
|
60648
60649
|
clientId: string().trim().min(1)
|
|
60649
60650
|
}));
|
|
60650
60651
|
const LinearAppSchema = preprocess(withoutProviderUrlSnapshot, strictObject({ clientId: string().trim().min(1) }));
|
|
60652
|
+
const GoogleChatAppSchema = preprocess(withoutProviderUrlSnapshot, strictObject({
|
|
60653
|
+
projectId: string().trim().regex(/^(?:[a-z0-9.-]+:)?[a-z][a-z0-9-]{4,28}[a-z0-9]$/, "must be a Google Cloud project ID"),
|
|
60654
|
+
projectNumber: string().trim().regex(/^[1-9]\d{0,19}$/, "must be a numeric Google Cloud project number")
|
|
60655
|
+
}));
|
|
60651
60656
|
/** Version 1 of the JSONB document persisted in `deployment_config.values`. */
|
|
60652
60657
|
const DeploymentConfigValuesV1Schema = strictObject({
|
|
60653
60658
|
auth: AuthSchema,
|
|
@@ -60656,6 +60661,8 @@ const DeploymentConfigValuesV1Schema = strictObject({
|
|
|
60656
60661
|
gitea: GiteaInstanceSchema.nullable().optional(),
|
|
60657
60662
|
slack: SlackAppSchema.nullable(),
|
|
60658
60663
|
linear: LinearAppSchema.nullable().optional(),
|
|
60664
|
+
/** The deployment-owned Google Chat app; its key is the write-only 'googleChat.serviceAccountKey' secret. */
|
|
60665
|
+
googleChat: GoogleChatAppSchema.nullable().optional(),
|
|
60659
60666
|
/** Regional Login Apps used as the tenant anchor for Bot App admission. */
|
|
60660
60667
|
feishu: RegionalLoginAppSchema.nullable().optional(),
|
|
60661
60668
|
lark: RegionalLoginAppSchema.nullable().optional(),
|
|
@@ -60717,6 +60724,7 @@ const DEPLOYMENT_SECRET_KEYS = [
|
|
|
60717
60724
|
"slack.signingSecret",
|
|
60718
60725
|
"linear.clientSecret",
|
|
60719
60726
|
"linear.signingSecret",
|
|
60727
|
+
"googleChat.serviceAccountKey",
|
|
60720
60728
|
"feishu.loginAppSecret",
|
|
60721
60729
|
"lark.loginAppSecret",
|
|
60722
60730
|
"logto.managementAppSecret",
|
|
@@ -60807,6 +60815,7 @@ function deploymentSecretsRequiringRefresh(previous, next) {
|
|
|
60807
60815
|
const gitlabClientChanged = next.gitlab != null && (previous?.gitlab?.clientId !== next.gitlab.clientId || previous != null && effectiveGitlabBaseUrl(previous) !== effectiveGitlabBaseUrl(next));
|
|
60808
60816
|
const slackIdentityChanged = next.slack && (previous?.slack?.appId !== next.slack.appId || previous?.slack?.clientId !== next.slack.clientId);
|
|
60809
60817
|
const linearIdentityChanged = next.linear && previous?.linear?.clientId !== next.linear.clientId;
|
|
60818
|
+
const googleChatProjectChanged = next.googleChat && previous?.googleChat?.projectId !== next.googleChat.projectId;
|
|
60810
60819
|
const feishuIdentityChanged = next.feishu && previous?.feishu?.loginAppId !== next.feishu.loginAppId;
|
|
60811
60820
|
const larkIdentityChanged = next.lark && previous?.lark?.loginAppId !== next.lark.loginAppId;
|
|
60812
60821
|
const logtoIdentityChanged = next.logto && previous?.logto?.managementAppId !== next.logto.managementAppId;
|
|
@@ -60819,6 +60828,7 @@ function deploymentSecretsRequiringRefresh(previous, next) {
|
|
|
60819
60828
|
...gitlabClientChanged ? ["gitlab.clientSecret"] : [],
|
|
60820
60829
|
...slackIdentityChanged ? ["slack.clientSecret", "slack.signingSecret"] : [],
|
|
60821
60830
|
...linearIdentityChanged ? ["linear.clientSecret", "linear.signingSecret"] : [],
|
|
60831
|
+
...googleChatProjectChanged ? ["googleChat.serviceAccountKey"] : [],
|
|
60822
60832
|
...feishuIdentityChanged ? ["feishu.loginAppSecret"] : [],
|
|
60823
60833
|
...larkIdentityChanged ? ["lark.loginAppSecret"] : [],
|
|
60824
60834
|
...logtoIdentityChanged ? ["logto.managementAppSecret"] : [],
|
|
@@ -60833,6 +60843,7 @@ function requiredSecrets(values) {
|
|
|
60833
60843
|
...values.gitlab ? ["gitlab.clientSecret"] : [],
|
|
60834
60844
|
...values.slack ? ["slack.clientSecret", "slack.signingSecret"] : [],
|
|
60835
60845
|
...values.linear ? ["linear.clientSecret", "linear.signingSecret"] : [],
|
|
60846
|
+
...values.googleChat ? ["googleChat.serviceAccountKey"] : [],
|
|
60836
60847
|
...values.feishu ? ["feishu.loginAppSecret"] : [],
|
|
60837
60848
|
...values.lark ? ["lark.loginAppSecret"] : [],
|
|
60838
60849
|
...values.logto ? ["logto.managementAppSecret"] : [],
|
|
@@ -61687,6 +61698,21 @@ function linearDeploymentPut(current, application) {
|
|
|
61687
61698
|
...Object.keys(secrets).length > 0 ? { secrets } : {}
|
|
61688
61699
|
});
|
|
61689
61700
|
}
|
|
61701
|
+
/** Null clears the app. Google Chat apps are configured by hand in Google Cloud Console (google-chat-integration.md §3). */
|
|
61702
|
+
function googleChatDeploymentPut(current, app) {
|
|
61703
|
+
if (app && !app.serviceAccountKey && current.values.googleChat?.projectId !== app.projectId) throw new Error("a new Google Cloud project requires its service-account key");
|
|
61704
|
+
const secret = app ? app.serviceAccountKey : null;
|
|
61705
|
+
return DeploymentConfigPutSchema.parse({
|
|
61706
|
+
values: {
|
|
61707
|
+
...current.values,
|
|
61708
|
+
googleChat: app ? {
|
|
61709
|
+
projectId: app.projectId,
|
|
61710
|
+
projectNumber: app.projectNumber
|
|
61711
|
+
} : null
|
|
61712
|
+
},
|
|
61713
|
+
...secret === void 0 ? {} : { secrets: { "googleChat.serviceAccountKey": secret } }
|
|
61714
|
+
});
|
|
61715
|
+
}
|
|
61690
61716
|
function logtoGoogleConnectorPut(current, credentials) {
|
|
61691
61717
|
if (!current.values.logto) throw new Error("save Logto configuration before configuring its Google connector");
|
|
61692
61718
|
if (!credentials.clientSecret && current.values.logto.googleConnector?.clientId !== credentials.clientId) throw new Error("Google OAuth client secret is required for a new client id");
|
|
@@ -64031,7 +64057,7 @@ function gitlabConfiguredUrls(config, baseUrl = GITLAB_DEFAULT_BASE_URL) {
|
|
|
64031
64057
|
* be this process guessing about someone else's network.
|
|
64032
64058
|
*/
|
|
64033
64059
|
/** Give up on a silent instance rather than hold the save request open. */
|
|
64034
|
-
const PROBE_TIMEOUT_MS$
|
|
64060
|
+
const PROBE_TIMEOUT_MS$2 = 5e3;
|
|
64035
64061
|
/** Node's TLS verification failures, as they surface through `fetch`'s cause chain. */
|
|
64036
64062
|
const TLS_FAILURE_CODES$1 = /* @__PURE__ */ new Set([
|
|
64037
64063
|
"CERT_HAS_EXPIRED",
|
|
@@ -64065,7 +64091,7 @@ async function probeGitlabInstance(rawBaseUrl, fetchImpl) {
|
|
|
64065
64091
|
response = await fetchImpl(`${baseUrl}/api/v4/version`, {
|
|
64066
64092
|
method: "GET",
|
|
64067
64093
|
headers: { accept: "application/json" },
|
|
64068
|
-
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS$
|
|
64094
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS$2)
|
|
64069
64095
|
});
|
|
64070
64096
|
} catch (error) {
|
|
64071
64097
|
if (isTlsFailure$1(error)) return {
|
|
@@ -64122,7 +64148,7 @@ function isTlsFailure$1(error) {
|
|
|
64122
64148
|
* Control Plane, and refusing would be this process guessing about someone else's network.
|
|
64123
64149
|
*/
|
|
64124
64150
|
/** Give up on a silent instance rather than hold the save request open. */
|
|
64125
|
-
const PROBE_TIMEOUT_MS = 5e3;
|
|
64151
|
+
const PROBE_TIMEOUT_MS$1 = 5e3;
|
|
64126
64152
|
/** Node's TLS verification failures, as they surface through `fetch`'s cause chain. */
|
|
64127
64153
|
const TLS_FAILURE_CODES = /* @__PURE__ */ new Set([
|
|
64128
64154
|
"CERT_HAS_EXPIRED",
|
|
@@ -64179,7 +64205,7 @@ async function probeGiteaInstance(rawBaseUrl, fetchImpl) {
|
|
|
64179
64205
|
response = await fetchImpl(`${baseUrl}/api/v1/version`, {
|
|
64180
64206
|
method: "GET",
|
|
64181
64207
|
headers: { accept: "application/json" },
|
|
64182
|
-
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
64208
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS$1)
|
|
64183
64209
|
});
|
|
64184
64210
|
} catch (error) {
|
|
64185
64211
|
if (isTlsFailure(error)) return {
|
|
@@ -64254,211 +64280,23 @@ function linearConfiguredUrls(config) {
|
|
|
64254
64280
|
};
|
|
64255
64281
|
}
|
|
64256
64282
|
//#endregion
|
|
64257
|
-
//#region
|
|
64258
|
-
/**
|
|
64259
|
-
|
|
64260
|
-
|
|
64261
|
-
|
|
64262
|
-
|
|
64263
|
-
|
|
64264
|
-
|
|
64265
|
-
|
|
64266
|
-
|
|
64267
|
-
|
|
64268
|
-
*
|
|
64269
|
-
* The shared protocol module owns the canonical desired state. This module adds
|
|
64270
|
-
* Control Plane URL helpers and merge rules for already-installed Apps.
|
|
64271
|
-
*/
|
|
64272
|
-
function asRecord$2(value) {
|
|
64273
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
64274
|
-
}
|
|
64275
|
-
function buildManagedManifest(name, options = {}) {
|
|
64276
|
-
return buildSlackAppManifest(name, {
|
|
64277
|
-
...options.httpRelayBase ? { relayUrl: options.httpRelayBase } : {},
|
|
64278
|
-
...options.backgroundColor ? { backgroundColor: options.backgroundColor } : {}
|
|
64279
|
-
});
|
|
64280
|
-
}
|
|
64281
|
-
/**
|
|
64282
|
-
* Build the app manifest object for `apps.manifest.create`. `redirectUrl` is the
|
|
64283
|
-
* CP's OAuth callback (`<PUBLIC_CP_URL>/v1/integrations/slack/oauth/callback`)
|
|
64284
|
-
* and is declared as the app's sole redirect URL so the ensuing OAuth install
|
|
64285
|
-
* lands back on us. `name` falls back to the default when blank.
|
|
64286
|
-
* `options.httpRelayBase` set ⇒ HTTP-mode (Events API request_urls + Socket Mode
|
|
64287
|
-
* off); absent ⇒ Socket Mode.
|
|
64288
|
-
*/
|
|
64289
|
-
function buildInstallManifest(name, redirectUrl, options = {}) {
|
|
64290
|
-
const managed = buildManagedManifest(name, options);
|
|
64291
|
-
return {
|
|
64292
|
-
...managed,
|
|
64293
|
-
oauth_config: {
|
|
64294
|
-
...asRecord$2(managed.oauth_config),
|
|
64295
|
-
redirect_urls: [.../* @__PURE__ */ new Set([redirectUrl, ...options.additionalRedirectUrls ?? []])]
|
|
64296
|
-
}
|
|
64297
|
-
};
|
|
64298
|
-
}
|
|
64299
|
-
/** The PLATFORM (distributed) app's OAuth callback path (preset-agents.md §5.3),
|
|
64300
|
-
* public `/v1` form for the same rewrite/alias reasons as the sibling above. It
|
|
64301
|
-
* must be registered as a redirect URL on the platform Slack app. */
|
|
64302
|
-
const SLACK_PLATFORM_OAUTH_CALLBACK_PATH = "/v1/integrations/slack/platform/callback";
|
|
64303
|
-
/** `<publicCpUrl>/v1/integrations/slack/platform/callback`, trimming a trailing slash. */
|
|
64304
|
-
function slackPlatformOAuthRedirectUri(publicCpUrl) {
|
|
64305
|
-
return `${publicCpUrl.replace(/\/$/, "")}${SLACK_PLATFORM_OAUTH_CALLBACK_PATH}`;
|
|
64306
|
-
}
|
|
64307
|
-
//#endregion
|
|
64308
|
-
//#region src/slack-app.ts
|
|
64309
|
-
function appendPath$1(base, path) {
|
|
64310
|
-
return `${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
64311
|
-
}
|
|
64312
|
-
function requireProviderAppEndpoints(config) {
|
|
64313
|
-
const entries = [
|
|
64314
|
-
["Web", config.services.web],
|
|
64315
|
-
["Control Plane", config.services.controlPlane],
|
|
64316
|
-
["Relay", config.services.relay]
|
|
64317
|
-
];
|
|
64318
|
-
for (const [label, value] of entries) if (!value || new URL(value).protocol !== "https:") throw new Error(`app creation requires a saved HTTPS ${label} public URL`);
|
|
64319
|
-
}
|
|
64320
|
-
/** Keep this deployment manifest aligned with the Control Plane's install manifest. */
|
|
64321
|
-
function buildSlackDeploymentManifest(config, name, login) {
|
|
64322
|
-
requireProviderAppEndpoints(config);
|
|
64323
|
-
const redirectUrl = slackPlatformOAuthRedirectUri(config.services.controlPlane);
|
|
64324
|
-
const loginRedirectUrl = login ? appendPath$1(login.logtoEndpoint, `/callback/${login.connectorId}`) : void 0;
|
|
64325
|
-
const socialLinkRedirectUrl = login ? appendPath$1(config.services.web, "/auth/social/callback") : void 0;
|
|
64326
|
-
if (loginRedirectUrl && new URL(loginRedirectUrl).protocol !== "https:") throw new Error("Slack sign-in requires an HTTPS Logto endpoint");
|
|
64327
|
-
return buildInstallManifest(name, redirectUrl, {
|
|
64328
|
-
httpRelayBase: config.services.relay,
|
|
64329
|
-
...loginRedirectUrl && socialLinkRedirectUrl ? { additionalRedirectUrls: [loginRedirectUrl, socialLinkRedirectUrl] } : {}
|
|
64330
|
-
});
|
|
64331
|
-
}
|
|
64332
|
-
function slackConfiguredUrls(manifest) {
|
|
64333
|
-
const oauth = asRecord$1(manifest.oauth_config);
|
|
64334
|
-
const settings = asRecord$1(manifest.settings);
|
|
64335
|
-
const events = asRecord$1(settings.event_subscriptions);
|
|
64336
|
-
const interactivity = asRecord$1(settings.interactivity);
|
|
64337
|
-
const redirects = asStrings(oauth.redirect_urls);
|
|
64338
|
-
if (redirects.length !== 1 && redirects.length !== 3 || typeof events.request_url !== "string" || typeof interactivity.request_url !== "string") throw new Error("Slack App manifest is missing managed URLs");
|
|
64339
|
-
return {
|
|
64340
|
-
oauthRedirectUrl: redirects[0],
|
|
64341
|
-
eventsUrl: events.request_url,
|
|
64342
|
-
interactionsUrl: interactivity.request_url,
|
|
64343
|
-
...redirects[1] ? { loginRedirectUrl: redirects[1] } : {},
|
|
64344
|
-
...redirects[2] ? { socialLinkRedirectUrl: redirects[2] } : {}
|
|
64345
|
-
};
|
|
64346
|
-
}
|
|
64347
|
-
function asRecord$1(value) {
|
|
64348
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
64349
|
-
}
|
|
64350
|
-
function asStrings(value) {
|
|
64351
|
-
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
64352
|
-
}
|
|
64353
|
-
function sameStrings$1(left, right) {
|
|
64354
|
-
return JSON.stringify([...asStrings(left)].sort()) === JSON.stringify([...asStrings(right)].sort());
|
|
64355
|
-
}
|
|
64356
|
-
/** Returns only public App settings; secrets never appear in this diff. */
|
|
64357
|
-
function diffSlackManifest(actual, expected) {
|
|
64358
|
-
const diff = [];
|
|
64359
|
-
const actualOauth = asRecord$1(actual.oauth_config);
|
|
64360
|
-
const expectedOauth = asRecord$1(expected.oauth_config);
|
|
64361
|
-
const actualScopes = asRecord$1(actualOauth.scopes);
|
|
64362
|
-
const expectedScopes = asRecord$1(expectedOauth.scopes);
|
|
64363
|
-
const actualSettings = asRecord$1(actual.settings);
|
|
64364
|
-
const expectedSettings = asRecord$1(expected.settings);
|
|
64365
|
-
const actualEvents = asRecord$1(actualSettings.event_subscriptions);
|
|
64366
|
-
const expectedEvents = asRecord$1(expectedSettings.event_subscriptions);
|
|
64367
|
-
const actualInteractivity = asRecord$1(actualSettings.interactivity);
|
|
64368
|
-
const expectedInteractivity = asRecord$1(expectedSettings.interactivity);
|
|
64369
|
-
for (const scope of asStrings(asRecord$1(expectedScopes).bot)) if (!asStrings(asRecord$1(actualScopes).bot).includes(scope)) diff.push({
|
|
64370
|
-
id: `scope:${scope}`,
|
|
64371
|
-
field: `Bot scope: ${scope}`,
|
|
64372
|
-
current: "Missing",
|
|
64373
|
-
expected: "Required"
|
|
64374
|
-
});
|
|
64375
|
-
for (const event of asStrings(expectedEvents.bot_events)) if (!asStrings(actualEvents.bot_events).includes(event)) diff.push({
|
|
64376
|
-
id: `event:${event}`,
|
|
64377
|
-
field: `Bot event: ${event}`,
|
|
64378
|
-
current: "Missing",
|
|
64379
|
-
expected: "Required"
|
|
64380
|
-
});
|
|
64381
|
-
for (const [id, field, expectedValue] of [
|
|
64382
|
-
[
|
|
64383
|
-
"oauth_config.redirect_urls",
|
|
64384
|
-
"OAuth redirect URLs",
|
|
64385
|
-
expectedOauth.redirect_urls
|
|
64386
|
-
],
|
|
64387
|
-
[
|
|
64388
|
-
"settings.event_subscriptions.request_url",
|
|
64389
|
-
"Events request URL",
|
|
64390
|
-
expectedEvents.request_url
|
|
64391
|
-
],
|
|
64392
|
-
[
|
|
64393
|
-
"settings.interactivity.request_url",
|
|
64394
|
-
"Interactivity request URL",
|
|
64395
|
-
expectedInteractivity.request_url
|
|
64396
|
-
],
|
|
64397
|
-
[
|
|
64398
|
-
"settings.interactivity.message_menu_options_url",
|
|
64399
|
-
"Message menu options URL",
|
|
64400
|
-
expectedInteractivity.message_menu_options_url
|
|
64401
|
-
],
|
|
64402
|
-
[
|
|
64403
|
-
"settings.socket_mode_enabled",
|
|
64404
|
-
"Socket Mode enabled",
|
|
64405
|
-
expectedSettings.socket_mode_enabled
|
|
64406
|
-
]
|
|
64407
|
-
]) {
|
|
64408
|
-
const actualValue = id === "oauth_config.redirect_urls" ? actualOauth.redirect_urls : id === "settings.event_subscriptions.request_url" ? actualEvents.request_url : id === "settings.interactivity.request_url" ? actualInteractivity.request_url : id === "settings.interactivity.message_menu_options_url" ? actualInteractivity.message_menu_options_url : actualSettings.socket_mode_enabled;
|
|
64409
|
-
if (!(id === "oauth_config.redirect_urls" ? sameStrings$1(actualValue, expectedValue) : JSON.stringify(actualValue) === JSON.stringify(expectedValue))) diff.push({
|
|
64410
|
-
id,
|
|
64411
|
-
field,
|
|
64412
|
-
current: actualValue ?? null,
|
|
64413
|
-
expected: expectedValue ?? null
|
|
64414
|
-
});
|
|
64415
|
-
}
|
|
64416
|
-
return diff;
|
|
64417
|
-
}
|
|
64418
|
-
/** `actual` with every field `diffSlackManifest` inspects brought to `expected`, everything else kept, so reconcile-then-diff is empty. */
|
|
64419
|
-
function reconcileSlackManifest(actual, expected) {
|
|
64420
|
-
const actualOauth = asRecord$1(actual.oauth_config);
|
|
64421
|
-
const expectedOauth = asRecord$1(expected.oauth_config);
|
|
64422
|
-
const actualScopes = asRecord$1(actualOauth.scopes);
|
|
64423
|
-
const expectedScopes = asRecord$1(expectedOauth.scopes);
|
|
64424
|
-
const actualSettings = asRecord$1(actual.settings);
|
|
64425
|
-
const expectedSettings = asRecord$1(expected.settings);
|
|
64426
|
-
const actualEvents = asRecord$1(actualSettings.event_subscriptions);
|
|
64427
|
-
const expectedEvents = asRecord$1(expectedSettings.event_subscriptions);
|
|
64428
|
-
const actualInteractivity = asRecord$1(actualSettings.interactivity);
|
|
64429
|
-
const expectedInteractivity = asRecord$1(expectedSettings.interactivity);
|
|
64430
|
-
const union = (current, required) => [.../* @__PURE__ */ new Set([...asStrings(current), ...asStrings(required)])];
|
|
64431
|
-
const urlOf = (value, key) => typeof value === "string" ? { [key]: value } : {};
|
|
64283
|
+
//#region src/google-chat-app.ts
|
|
64284
|
+
/** Relay-terminated Chat interaction events (google-chat-integration.md §2); the relay module must mount this same path. */
|
|
64285
|
+
const GOOGLE_CHAT_EVENTS_PATH = "/googlechat/events";
|
|
64286
|
+
/** The authentication audience the Chat app configuration must select (§2). */
|
|
64287
|
+
const GOOGLE_CHAT_AUDIENCE_SETTING = "Project Number";
|
|
64288
|
+
/** The generic Chat API configuration page; a project-specific URL would not resolve for another operator. */
|
|
64289
|
+
const GOOGLE_CHAT_CONFIGURATION_URL = "https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat";
|
|
64290
|
+
/** Google has no public Chat app creation API, so setup only publishes what to enter by hand (§3). */
|
|
64291
|
+
function googleChatConfiguredUrls(config) {
|
|
64292
|
+
const relay = config.services.relay;
|
|
64293
|
+
if (!relay || new URL(relay).protocol !== "https:") throw new Error("the Google Chat app requires a saved HTTPS ingress public URL");
|
|
64432
64294
|
return {
|
|
64433
|
-
|
|
64434
|
-
|
|
64435
|
-
|
|
64436
|
-
redirect_urls: asStrings(expectedOauth.redirect_urls),
|
|
64437
|
-
scopes: {
|
|
64438
|
-
...actualScopes,
|
|
64439
|
-
bot: union(actualScopes.bot, expectedScopes.bot)
|
|
64440
|
-
}
|
|
64441
|
-
},
|
|
64442
|
-
settings: {
|
|
64443
|
-
...actualSettings,
|
|
64444
|
-
event_subscriptions: {
|
|
64445
|
-
...actualEvents,
|
|
64446
|
-
bot_events: union(actualEvents.bot_events, expectedEvents.bot_events),
|
|
64447
|
-
...urlOf(expectedEvents.request_url, "request_url")
|
|
64448
|
-
},
|
|
64449
|
-
interactivity: {
|
|
64450
|
-
...actualInteractivity,
|
|
64451
|
-
...urlOf(expectedInteractivity.request_url, "request_url"),
|
|
64452
|
-
...urlOf(expectedInteractivity.message_menu_options_url, "message_menu_options_url")
|
|
64453
|
-
},
|
|
64454
|
-
socket_mode_enabled: expectedSettings.socket_mode_enabled
|
|
64455
|
-
}
|
|
64295
|
+
callbackUrl: `${relay.replace(/\/$/, "")}${GOOGLE_CHAT_EVENTS_PATH}`,
|
|
64296
|
+
audienceSetting: GOOGLE_CHAT_AUDIENCE_SETTING,
|
|
64297
|
+
configurationUrl: GOOGLE_CHAT_CONFIGURATION_URL
|
|
64456
64298
|
};
|
|
64457
64299
|
}
|
|
64458
|
-
/** Returns stable field names only; it never includes provider response values. */
|
|
64459
|
-
function auditSlackManifest(actual, expected) {
|
|
64460
|
-
return diffSlackManifest(actual, expected).map((item) => item.id);
|
|
64461
|
-
}
|
|
64462
64300
|
//#endregion
|
|
64463
64301
|
//#region ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/lib/buffer_utils.js
|
|
64464
64302
|
const encoder = new TextEncoder();
|
|
@@ -64471,7 +64309,7 @@ function concat(...buffers) {
|
|
|
64471
64309
|
return buf;
|
|
64472
64310
|
}
|
|
64473
64311
|
const NON_ASCII = /[^\x00-\x7f]/;
|
|
64474
|
-
function encode(string) {
|
|
64312
|
+
function encode$1(string) {
|
|
64475
64313
|
if (typeof string == "string" && string.length >= 128) {
|
|
64476
64314
|
if (NON_ASCII.test(string)) throw new TypeError("non-ASCII string encountered in encode()");
|
|
64477
64315
|
return encoder.encode(string);
|
|
@@ -64484,6 +64322,16 @@ function encode(string) {
|
|
|
64484
64322
|
}
|
|
64485
64323
|
return bytes;
|
|
64486
64324
|
}
|
|
64325
|
+
function encodeBase64(input, url = !1) {
|
|
64326
|
+
if (Uint8Array.prototype.toBase64) return input.toBase64({
|
|
64327
|
+
alphabet: url ? "base64url" : "base64",
|
|
64328
|
+
omitPadding: url
|
|
64329
|
+
});
|
|
64330
|
+
const CHUNK_SIZE = 32768, arr = [];
|
|
64331
|
+
for (let i = 0; i < input.length; i += CHUNK_SIZE) arr.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE)));
|
|
64332
|
+
const encoded = btoa(arr.join(""));
|
|
64333
|
+
return url ? encoded.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") : encoded;
|
|
64334
|
+
}
|
|
64487
64335
|
function decodeBase64(encoded, url = !1) {
|
|
64488
64336
|
if (Uint8Array.fromBase64) return Uint8Array.fromBase64(encoded, { alphabet: url ? "base64url" : "base64" });
|
|
64489
64337
|
if (url) {
|
|
@@ -64590,6 +64438,9 @@ function decode(input) {
|
|
|
64590
64438
|
throw new TypeError(invalid, { cause });
|
|
64591
64439
|
}
|
|
64592
64440
|
}
|
|
64441
|
+
function encode(input) {
|
|
64442
|
+
return encodeBase64(typeof input == "string" ? encoder.encode(input) : input, !0);
|
|
64443
|
+
}
|
|
64593
64444
|
//#endregion
|
|
64594
64445
|
//#region ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/lib/validate.js
|
|
64595
64446
|
function isObject(input) {
|
|
@@ -64608,6 +64459,9 @@ function isDisjoint(...headers) {
|
|
|
64608
64459
|
}
|
|
64609
64460
|
return !0;
|
|
64610
64461
|
}
|
|
64462
|
+
function assertNotSet(value, name) {
|
|
64463
|
+
if (value !== void 0) throw new TypeError(`${name} can only be called once`);
|
|
64464
|
+
}
|
|
64611
64465
|
function decodeBase64url(value, label, ErrorClass) {
|
|
64612
64466
|
try {
|
|
64613
64467
|
return decode(value);
|
|
@@ -64617,7 +64471,7 @@ function decodeBase64url(value, label, ErrorClass) {
|
|
|
64617
64471
|
}
|
|
64618
64472
|
function encodeBase64url(value, label, ErrorClass) {
|
|
64619
64473
|
try {
|
|
64620
|
-
return encode(value);
|
|
64474
|
+
return encode$1(value);
|
|
64621
64475
|
} catch {
|
|
64622
64476
|
throw new ErrorClass(`The ${label} is not a valid base64url string`);
|
|
64623
64477
|
}
|
|
@@ -64640,6 +64494,10 @@ function validateAlgorithms(option, algorithms) {
|
|
|
64640
64494
|
if (algorithms !== void 0 && (!Array.isArray(algorithms) || algorithms.some((s) => typeof s != "string"))) throw new TypeError(`"${option}" option must be an array of strings`);
|
|
64641
64495
|
return algorithms === void 0 ? void 0 : new Set(algorithms);
|
|
64642
64496
|
}
|
|
64497
|
+
function validateCritDuplicates(Err, protectedHeader) {
|
|
64498
|
+
const { crit } = protectedHeader ?? {};
|
|
64499
|
+
if (Array.isArray(crit) && new Set(crit).size !== crit.length) throw new Err("\"crit\" (Critical) Header Parameter MUST NOT contain duplicate values");
|
|
64500
|
+
}
|
|
64643
64501
|
function validateCrit(Err, recognizedDefault, recognizedOption, protectedHeader, joseHeader) {
|
|
64644
64502
|
if (joseHeader.crit !== void 0 && protectedHeader?.crit === void 0) throw new Err("\"crit\" (Critical) Header Parameter MUST be integrity protected");
|
|
64645
64503
|
if (!protectedHeader || protectedHeader.crit === void 0) return [];
|
|
@@ -64664,6 +64522,16 @@ function validateB64(protectedHeader, extensions) {
|
|
|
64664
64522
|
}
|
|
64665
64523
|
return !0;
|
|
64666
64524
|
}
|
|
64525
|
+
function serializeJoseHeader(Err, header) {
|
|
64526
|
+
let serialized, parsed;
|
|
64527
|
+
try {
|
|
64528
|
+
serialized = JSON.stringify(header), parsed = JSON.parse(serialized);
|
|
64529
|
+
} catch (cause) {
|
|
64530
|
+
throw new Err("JOSE Header is not valid JSON", { cause });
|
|
64531
|
+
}
|
|
64532
|
+
if (!isObject(parsed)) throw new Err("JOSE Header is not a JSON object");
|
|
64533
|
+
return [parsed, serialized];
|
|
64534
|
+
}
|
|
64667
64535
|
//#endregion
|
|
64668
64536
|
//#region ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/lib/key.js
|
|
64669
64537
|
const tag = (key) => key[Symbol.toStringTag];
|
|
@@ -64903,7 +64771,7 @@ function parseProtectedHeader(encodedProtected) {
|
|
|
64903
64771
|
}
|
|
64904
64772
|
function encodeCompactUnencodedPayload(payload) {
|
|
64905
64773
|
try {
|
|
64906
|
-
return encode(payload);
|
|
64774
|
+
return encode$1(payload);
|
|
64907
64775
|
} catch {
|
|
64908
64776
|
throw new JWSInvalid("JWS Compact Serialization payload must use only ASCII characters");
|
|
64909
64777
|
}
|
|
@@ -64923,7 +64791,7 @@ async function verifySignature(jws, shared, key, encodeUnencodedPayload, parsedP
|
|
|
64923
64791
|
const signingPayload = b64 || typeof inputPayload != "string" ? inputPayload : encodeUnencodedPayload(inputPayload);
|
|
64924
64792
|
let resolvedKey = !1;
|
|
64925
64793
|
typeof key == "function" && (key = await key(parsedProt, jws), resolvedKey = !0);
|
|
64926
|
-
const entry = jwsAlgorithm(alg), data = concat(encodedProtected !== void 0 ? encode(encodedProtected) : /* @__PURE__ */ new Uint8Array(), encode("."), typeof signingPayload == "string" ? shared[2] ??= encodeBase64url(signingPayload, "payload", JWSInvalid) : signingPayload), signature = decodeBase64url(jws.signature, "signature", JWSInvalid), k = await prepareKey(entry, key, "verify"), cryptoKey = await rawKey(k, entry.subtle, "verify");
|
|
64794
|
+
const entry = jwsAlgorithm(alg), data = concat(encodedProtected !== void 0 ? encode$1(encodedProtected) : /* @__PURE__ */ new Uint8Array(), encode$1("."), typeof signingPayload == "string" ? shared[2] ??= encodeBase64url(signingPayload, "payload", JWSInvalid) : signingPayload), signature = decodeBase64url(jws.signature, "signature", JWSInvalid), k = await prepareKey(entry, key, "verify"), cryptoKey = await rawKey(k, entry.subtle, "verify");
|
|
64927
64795
|
entry.minRsaBits && checkModulusLength(entry.alg, cryptoKey);
|
|
64928
64796
|
let verified = !1;
|
|
64929
64797
|
try {
|
|
@@ -64973,6 +64841,15 @@ function validateInput(label, input) {
|
|
|
64973
64841
|
if (!Number.isFinite(input)) throw new TypeError(`Invalid ${label} input`);
|
|
64974
64842
|
return input;
|
|
64975
64843
|
}
|
|
64844
|
+
function validateStringClaim(claim, value) {
|
|
64845
|
+
if (typeof value != "string") throw new TypeError(`"${claim}" claim must be a string`);
|
|
64846
|
+
}
|
|
64847
|
+
function validateAudienceClaim(value) {
|
|
64848
|
+
if (typeof value != "string" && (!Array.isArray(value) || Array.from(value).some((member) => typeof member != "string"))) throw new TypeError("\"aud\" claim must be a string or an array of strings");
|
|
64849
|
+
}
|
|
64850
|
+
function numericDate(value, label) {
|
|
64851
|
+
return typeof value == "number" ? validateInput(label, value) : value instanceof Date ? validateInput(label, epoch(value)) : epoch(/* @__PURE__ */ new Date()) + secs(value);
|
|
64852
|
+
}
|
|
64976
64853
|
const normalizeTyp = (value) => {
|
|
64977
64854
|
const normalized = value.toLowerCase();
|
|
64978
64855
|
return value.includes("/") ? normalized : `application/${normalized}`;
|
|
@@ -65019,6 +64896,50 @@ function validateClaimsSet(protectedHeader, encodedPayload, options = {}) {
|
|
|
65019
64896
|
}
|
|
65020
64897
|
return payload;
|
|
65021
64898
|
}
|
|
64899
|
+
let producerPayloads;
|
|
64900
|
+
function producerPayload(producer) {
|
|
64901
|
+
return producerPayloads.get(producer);
|
|
64902
|
+
}
|
|
64903
|
+
function jwtData(producer) {
|
|
64904
|
+
const payload = producerPayload(producer);
|
|
64905
|
+
for (const claim of [
|
|
64906
|
+
"iat",
|
|
64907
|
+
"nbf",
|
|
64908
|
+
"exp"
|
|
64909
|
+
]) {
|
|
64910
|
+
const value = payload[claim];
|
|
64911
|
+
if (typeof value == "number" && !Number.isFinite(value)) throw new TypeError(`"${claim}" claim must be a finite number`);
|
|
64912
|
+
}
|
|
64913
|
+
return encoder.encode(JSON.stringify(payload));
|
|
64914
|
+
}
|
|
64915
|
+
var JWTClaimsBuilder = class {
|
|
64916
|
+
constructor(payload = {}) {
|
|
64917
|
+
if (!isObject(payload)) throw new TypeError("JWT Claims Set MUST be an object");
|
|
64918
|
+
(producerPayloads ||= /* @__PURE__ */ new WeakMap()).set(this, structuredClone(payload));
|
|
64919
|
+
}
|
|
64920
|
+
setIssuer(value) {
|
|
64921
|
+
return validateStringClaim("iss", value), producerPayload(this).iss = value, this;
|
|
64922
|
+
}
|
|
64923
|
+
setSubject(value) {
|
|
64924
|
+
return validateStringClaim("sub", value), producerPayload(this).sub = value, this;
|
|
64925
|
+
}
|
|
64926
|
+
setAudience(value) {
|
|
64927
|
+
return validateAudienceClaim(value), producerPayload(this).aud = value, this;
|
|
64928
|
+
}
|
|
64929
|
+
setJti(value) {
|
|
64930
|
+
return validateStringClaim("jti", value), producerPayload(this).jti = value, this;
|
|
64931
|
+
}
|
|
64932
|
+
setNotBefore(value) {
|
|
64933
|
+
return producerPayload(this).nbf = numericDate(value, "setNotBefore"), this;
|
|
64934
|
+
}
|
|
64935
|
+
setExpirationTime(value) {
|
|
64936
|
+
return producerPayload(this).exp = numericDate(value, "setExpirationTime"), this;
|
|
64937
|
+
}
|
|
64938
|
+
setIssuedAt(value) {
|
|
64939
|
+
const payload = producerPayload(this);
|
|
64940
|
+
return value === void 0 ? payload.iat = epoch(/* @__PURE__ */ new Date()) : typeof value == "string" ? payload.iat = validateInput("setIssuedAt", epoch(/* @__PURE__ */ new Date()) + secs(value)) : payload.iat = numericDate(value, "setIssuedAt"), this;
|
|
64941
|
+
}
|
|
64942
|
+
};
|
|
65022
64943
|
//#endregion
|
|
65023
64944
|
//#region ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/jwt/verify.js
|
|
65024
64945
|
async function jwtVerify(jwt, key, options) {
|
|
@@ -65031,6 +64952,63 @@ async function jwtVerify(jwt, key, options) {
|
|
|
65031
64952
|
};
|
|
65032
64953
|
}
|
|
65033
64954
|
//#endregion
|
|
64955
|
+
//#region ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/lib/jws_sign.js
|
|
64956
|
+
async function createSignature(input, key, rejectUnencoded) {
|
|
64957
|
+
let [payload, protectedHeader, unprotectedHeader, crit] = input, protectedHeaderString = "";
|
|
64958
|
+
if (protectedHeader !== void 0) {
|
|
64959
|
+
const normalized = serializeJoseHeader(JWSInvalid, protectedHeader);
|
|
64960
|
+
protectedHeader = normalized[0], protectedHeaderString = encode(normalized[1]);
|
|
64961
|
+
}
|
|
64962
|
+
if (unprotectedHeader !== void 0 && (unprotectedHeader = serializeJoseHeader(JWSInvalid, unprotectedHeader)[0]), !protectedHeader && !unprotectedHeader) throw new JWSInvalid("either setProtectedHeader or setUnprotectedHeader must be called before #sign()");
|
|
64963
|
+
if (!isDisjoint(protectedHeader, unprotectedHeader)) throw new JWSInvalid("JWS Protected and JWS Unprotected Header Parameter names must be disjoint");
|
|
64964
|
+
const joseHeader = {
|
|
64965
|
+
...protectedHeader,
|
|
64966
|
+
...unprotectedHeader
|
|
64967
|
+
};
|
|
64968
|
+
validateCritDuplicates(JWSInvalid, protectedHeader);
|
|
64969
|
+
const b64 = validateB64(protectedHeader, validateCrit(JWSInvalid, JWS_RECOGNIZED, crit, protectedHeader, joseHeader));
|
|
64970
|
+
b64 || rejectUnencoded?.();
|
|
64971
|
+
const { alg } = joseHeader;
|
|
64972
|
+
if (typeof alg != "string" || !alg) throw new JWSInvalid("JWS \"alg\" (Algorithm) Header Parameter missing or invalid");
|
|
64973
|
+
const entry = jwsAlgorithm(alg);
|
|
64974
|
+
let payloadS = "", payloadB = payload, data;
|
|
64975
|
+
if (b64) {
|
|
64976
|
+
const encoded = input[4];
|
|
64977
|
+
encoded ? (payloadS = encoded[0] ??= encode(payload), payloadB = encoded[1] ??= encode$1(payloadS)) : (payloadS = encode(payload), data = encoder.encode(`${protectedHeaderString}.${payloadS}`));
|
|
64978
|
+
}
|
|
64979
|
+
data ??= concat(encode$1(protectedHeaderString), encode$1("."), payloadB);
|
|
64980
|
+
const k = await rawKey(await prepareKey(entry, key, "sign"), entry.subtle, "sign");
|
|
64981
|
+
entry.minRsaBits && checkModulusLength(entry.alg, k);
|
|
64982
|
+
const jws = {
|
|
64983
|
+
signature: encode(new Uint8Array(await crypto.subtle.sign(entry.signing, k, data))),
|
|
64984
|
+
payload: payloadS
|
|
64985
|
+
};
|
|
64986
|
+
return protectedHeader && (jws.protected = protectedHeaderString), unprotectedHeader && (jws.header = unprotectedHeader), [jws, b64];
|
|
64987
|
+
}
|
|
64988
|
+
async function createCompactSignature(payload, protectedHeader, crit, key, rejectUnencoded) {
|
|
64989
|
+
const [jws] = await createSignature([
|
|
64990
|
+
payload,
|
|
64991
|
+
protectedHeader,
|
|
64992
|
+
void 0,
|
|
64993
|
+
crit
|
|
64994
|
+
], key, rejectUnencoded);
|
|
64995
|
+
return `${jws.protected}.${jws.payload}.${jws.signature}`;
|
|
64996
|
+
}
|
|
64997
|
+
//#endregion
|
|
64998
|
+
//#region ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/jwt/sign.js
|
|
64999
|
+
const SignJWT_base = JWTClaimsBuilder;
|
|
65000
|
+
var SignJWT = class extends SignJWT_base {
|
|
65001
|
+
#protectedHeader;
|
|
65002
|
+
setProtectedHeader(protectedHeader) {
|
|
65003
|
+
return assertNotSet(this.#protectedHeader, "setProtectedHeader"), this.#protectedHeader = protectedHeader, this;
|
|
65004
|
+
}
|
|
65005
|
+
async sign(key, options) {
|
|
65006
|
+
return createCompactSignature(jwtData(this), this.#protectedHeader, options?.crit, key, () => {
|
|
65007
|
+
throw new JWTInvalid("JWTs MUST NOT use unencoded payload");
|
|
65008
|
+
});
|
|
65009
|
+
}
|
|
65010
|
+
};
|
|
65011
|
+
//#endregion
|
|
65034
65012
|
//#region ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/jwks/local.js
|
|
65035
65013
|
function isUsableJWK(jwk, entry, alg, kid) {
|
|
65036
65014
|
const { kty, key_ops: keyOps, ext, kid: jwkKid, alg: jwkAlg, use, crv } = jwk;
|
|
@@ -65167,6 +65145,382 @@ function createRemoteJWKSet(url, options) {
|
|
|
65167
65145
|
}
|
|
65168
65146
|
});
|
|
65169
65147
|
}
|
|
65148
|
+
//#endregion
|
|
65149
|
+
//#region src/google-chat-probe.ts
|
|
65150
|
+
/** Google Chat credential validation (google-chat-integration.md §3): key shape, project identity, then one app-authenticated Chat API read. */
|
|
65151
|
+
/** Google's fixed OAuth token endpoint; the key's own `token_uri` is never followed. */
|
|
65152
|
+
const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
65153
|
+
/** The one bounded read that proves the credential; nothing is ever posted. */
|
|
65154
|
+
const GOOGLE_CHAT_PROBE_URL = "https://chat.googleapis.com/v1/spaces?pageSize=1";
|
|
65155
|
+
/** App authentication only: no user impersonation and no domain-wide delegation (§3). */
|
|
65156
|
+
const GOOGLE_CHAT_BOT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
|
65157
|
+
/** Give up on a silent endpoint rather than hold the save request open. */
|
|
65158
|
+
const PROBE_TIMEOUT_MS = 5e3;
|
|
65159
|
+
/** Bound provider text echoed into an operator-facing message. */
|
|
65160
|
+
const MAX_PROVIDER_DETAIL = 300;
|
|
65161
|
+
/** Connectivity failures are retryable; every other failure is the credential's or the project's. */
|
|
65162
|
+
function probeFailureIsConnectivity(status) {
|
|
65163
|
+
return status === "unreachable" || status === "google_unavailable";
|
|
65164
|
+
}
|
|
65165
|
+
/** Accept only a service-account key of the declared project; every other credential shape is refused (§3). */
|
|
65166
|
+
function checkServiceAccountKey(raw, projectId) {
|
|
65167
|
+
let parsed;
|
|
65168
|
+
try {
|
|
65169
|
+
parsed = JSON.parse(raw);
|
|
65170
|
+
} catch {
|
|
65171
|
+
return {
|
|
65172
|
+
status: "invalid_key",
|
|
65173
|
+
message: "the service-account key is not valid JSON; paste the whole downloaded key file"
|
|
65174
|
+
};
|
|
65175
|
+
}
|
|
65176
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
|
|
65177
|
+
status: "invalid_key",
|
|
65178
|
+
message: "the service-account key must be a JSON object"
|
|
65179
|
+
};
|
|
65180
|
+
const fields = parsed;
|
|
65181
|
+
if (fields.type !== "service_account") return {
|
|
65182
|
+
status: "invalid_key",
|
|
65183
|
+
message: "only a service-account JSON key is accepted; create one under IAM & Admin → Service Accounts → Keys"
|
|
65184
|
+
};
|
|
65185
|
+
const text = (name) => {
|
|
65186
|
+
const value = fields[name];
|
|
65187
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
|
|
65188
|
+
};
|
|
65189
|
+
const keyProjectId = text("project_id");
|
|
65190
|
+
const clientEmail = text("client_email");
|
|
65191
|
+
const privateKey = text("private_key");
|
|
65192
|
+
if (!keyProjectId || !clientEmail || !privateKey) return {
|
|
65193
|
+
status: "invalid_key",
|
|
65194
|
+
message: "the service-account key is missing project_id, client_email, or private_key"
|
|
65195
|
+
};
|
|
65196
|
+
if (keyProjectId !== projectId) return {
|
|
65197
|
+
status: "project_mismatch",
|
|
65198
|
+
message: `the key belongs to project ${keyProjectId}, not ${projectId}; keep the Chat app and its service account in one project`
|
|
65199
|
+
};
|
|
65200
|
+
let signingKey;
|
|
65201
|
+
try {
|
|
65202
|
+
signingKey = createPrivateKey({
|
|
65203
|
+
key: privateKey,
|
|
65204
|
+
format: "pem"
|
|
65205
|
+
});
|
|
65206
|
+
} catch {
|
|
65207
|
+
return {
|
|
65208
|
+
status: "invalid_key",
|
|
65209
|
+
message: "the private_key in the service-account key could not be read"
|
|
65210
|
+
};
|
|
65211
|
+
}
|
|
65212
|
+
if (signingKey.asymmetricKeyType !== "rsa") return {
|
|
65213
|
+
status: "invalid_key",
|
|
65214
|
+
message: "the private_key in the service-account key is not an RSA key"
|
|
65215
|
+
};
|
|
65216
|
+
const privateKeyId = text("private_key_id");
|
|
65217
|
+
return {
|
|
65218
|
+
status: "ok",
|
|
65219
|
+
key: {
|
|
65220
|
+
projectId,
|
|
65221
|
+
clientEmail,
|
|
65222
|
+
...privateKeyId ? { privateKeyId } : {},
|
|
65223
|
+
signingKey,
|
|
65224
|
+
json: JSON.stringify(parsed)
|
|
65225
|
+
}
|
|
65226
|
+
};
|
|
65227
|
+
}
|
|
65228
|
+
/** Mint an app token for `chat.bot` and list at most one space; never sends a message (§3). */
|
|
65229
|
+
async function probeGoogleChatCredential(key, fetchImpl, now = () => /* @__PURE__ */ new Date()) {
|
|
65230
|
+
const issuedAt = Math.floor(now().getTime() / 1e3);
|
|
65231
|
+
const assertion = await new SignJWT({ scope: GOOGLE_CHAT_BOT_SCOPE }).setProtectedHeader({
|
|
65232
|
+
alg: "RS256",
|
|
65233
|
+
typ: "JWT",
|
|
65234
|
+
...key.privateKeyId ? { kid: key.privateKeyId } : {}
|
|
65235
|
+
}).setIssuer(key.clientEmail).setAudience(GOOGLE_TOKEN_ENDPOINT).setIssuedAt(issuedAt).setExpirationTime(issuedAt + 3600).sign(key.signingKey);
|
|
65236
|
+
let tokenResponse;
|
|
65237
|
+
try {
|
|
65238
|
+
tokenResponse = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
|
|
65239
|
+
method: "POST",
|
|
65240
|
+
headers: {
|
|
65241
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
65242
|
+
accept: "application/json"
|
|
65243
|
+
},
|
|
65244
|
+
body: new URLSearchParams({
|
|
65245
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
65246
|
+
assertion
|
|
65247
|
+
}).toString(),
|
|
65248
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
65249
|
+
});
|
|
65250
|
+
} catch {
|
|
65251
|
+
return unreachable("Google's token endpoint");
|
|
65252
|
+
}
|
|
65253
|
+
const token = await readJson(tokenResponse);
|
|
65254
|
+
if (tokenResponse.status >= 500 || tokenResponse.status === 429) return unavailable("Google's token endpoint", tokenResponse.status);
|
|
65255
|
+
if (!tokenResponse.ok) {
|
|
65256
|
+
const error = detail(token?.error) ?? `HTTP ${tokenResponse.status}`;
|
|
65257
|
+
const description = detail(token?.error_description);
|
|
65258
|
+
return {
|
|
65259
|
+
status: "key_rejected",
|
|
65260
|
+
message: `Authentication failed: Google rejected the service-account key for ${key.clientEmail} (${error}${description ? `: ${description}` : ""}). The key may have been deleted or disabled, or its service account removed; create a new JSON key for that service account and paste it here.`
|
|
65261
|
+
};
|
|
65262
|
+
}
|
|
65263
|
+
const accessToken = typeof token?.access_token === "string" ? token.access_token : void 0;
|
|
65264
|
+
if (!accessToken) return unavailable("Google's token endpoint", tokenResponse.status);
|
|
65265
|
+
let chatResponse;
|
|
65266
|
+
try {
|
|
65267
|
+
chatResponse = await fetchImpl(GOOGLE_CHAT_PROBE_URL, {
|
|
65268
|
+
method: "GET",
|
|
65269
|
+
headers: {
|
|
65270
|
+
authorization: `Bearer ${accessToken}`,
|
|
65271
|
+
accept: "application/json"
|
|
65272
|
+
},
|
|
65273
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
65274
|
+
});
|
|
65275
|
+
} catch {
|
|
65276
|
+
return unreachable("the Google Chat API");
|
|
65277
|
+
}
|
|
65278
|
+
if (chatResponse.ok) return {
|
|
65279
|
+
status: "ok",
|
|
65280
|
+
message: `${key.clientEmail} authenticated to the Google Chat API as the project's Chat app`
|
|
65281
|
+
};
|
|
65282
|
+
if (chatResponse.status >= 500 || chatResponse.status === 429) return unavailable("the Google Chat API", chatResponse.status);
|
|
65283
|
+
const reason = detail(((await readJson(chatResponse))?.error)?.message) ?? "no details";
|
|
65284
|
+
if (chatResponse.status === 401) return {
|
|
65285
|
+
status: "key_rejected",
|
|
65286
|
+
message: `Authentication failed: the Google Chat API did not accept the token issued to ${key.clientEmail} (${reason}). Create a new JSON key for that service account and paste it here.`
|
|
65287
|
+
};
|
|
65288
|
+
return {
|
|
65289
|
+
status: "chat_api_refused",
|
|
65290
|
+
message: chatResponse.status === 404 ? `Chat app not found: project ${key.projectId} has no configured Chat app (${reason}). Configure the Chat app in Google Cloud Console first, then save again.` : `Chat API refused the app: HTTP ${chatResponse.status} for ${key.clientEmail} (${reason}). Enable the Google Chat API in project ${key.projectId} and configure its Chat app.`
|
|
65291
|
+
};
|
|
65292
|
+
}
|
|
65293
|
+
function unreachable(target) {
|
|
65294
|
+
return {
|
|
65295
|
+
status: "unreachable",
|
|
65296
|
+
message: `Connection failed: the Setup Server could not reach ${target}. Check outbound HTTPS access to Google and try again; nothing was saved.`
|
|
65297
|
+
};
|
|
65298
|
+
}
|
|
65299
|
+
function unavailable(target, status) {
|
|
65300
|
+
return {
|
|
65301
|
+
status: "google_unavailable",
|
|
65302
|
+
message: `Connection failed: ${target} answered HTTP ${status} without a usable result. Try again later; nothing was saved.`
|
|
65303
|
+
};
|
|
65304
|
+
}
|
|
65305
|
+
async function readJson(response) {
|
|
65306
|
+
try {
|
|
65307
|
+
const body = await response.json();
|
|
65308
|
+
return typeof body === "object" && body !== null && !Array.isArray(body) ? body : void 0;
|
|
65309
|
+
} catch {
|
|
65310
|
+
return;
|
|
65311
|
+
}
|
|
65312
|
+
}
|
|
65313
|
+
function detail(value) {
|
|
65314
|
+
if (typeof value !== "string" || value.trim() === "") return void 0;
|
|
65315
|
+
const trimmed = value.trim();
|
|
65316
|
+
return trimmed.length > MAX_PROVIDER_DETAIL ? `${trimmed.slice(0, MAX_PROVIDER_DETAIL)}…` : trimmed;
|
|
65317
|
+
}
|
|
65318
|
+
//#endregion
|
|
65319
|
+
//#region ../control-plane/dist/http/slack-manifest.js
|
|
65320
|
+
/**
|
|
65321
|
+
* Server-side Slack app manifest for the config-token auto-install funnel
|
|
65322
|
+
* (docs/designs/slack-install-smoothing.md §Tier B).
|
|
65323
|
+
*
|
|
65324
|
+
* In the MANUAL flow the console builds the manifest and deep-links the user to
|
|
65325
|
+
* Slack (packages/web/src/components/console/platforms/slack/manifest.ts). In the
|
|
65326
|
+
* AUTO flow the CP creates the app itself via `apps.manifest.create`, so it must build the same manifest —
|
|
65327
|
+
* PLUS `oauth_config.redirect_urls` pointing at the CP's own OAuth callback. The
|
|
65328
|
+
* CP owns that field on purpose: a client-supplied redirect URL would be an
|
|
65329
|
+
* open-redirect / token-theft hole.
|
|
65330
|
+
*
|
|
65331
|
+
* The shared protocol module owns the canonical desired state. This module adds
|
|
65332
|
+
* Control Plane URL helpers and merge rules for already-installed Apps.
|
|
65333
|
+
*/
|
|
65334
|
+
function asRecord$2(value) {
|
|
65335
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
65336
|
+
}
|
|
65337
|
+
function buildManagedManifest(name, options = {}) {
|
|
65338
|
+
return buildSlackAppManifest(name, {
|
|
65339
|
+
...options.httpRelayBase ? { relayUrl: options.httpRelayBase } : {},
|
|
65340
|
+
...options.backgroundColor ? { backgroundColor: options.backgroundColor } : {}
|
|
65341
|
+
});
|
|
65342
|
+
}
|
|
65343
|
+
/**
|
|
65344
|
+
* Build the app manifest object for `apps.manifest.create`. `redirectUrl` is the
|
|
65345
|
+
* CP's OAuth callback (`<PUBLIC_CP_URL>/v1/integrations/slack/oauth/callback`)
|
|
65346
|
+
* and is declared as the app's sole redirect URL so the ensuing OAuth install
|
|
65347
|
+
* lands back on us. `name` falls back to the default when blank.
|
|
65348
|
+
* `options.httpRelayBase` set ⇒ HTTP-mode (Events API request_urls + Socket Mode
|
|
65349
|
+
* off); absent ⇒ Socket Mode.
|
|
65350
|
+
*/
|
|
65351
|
+
function buildInstallManifest(name, redirectUrl, options = {}) {
|
|
65352
|
+
const managed = buildManagedManifest(name, options);
|
|
65353
|
+
return {
|
|
65354
|
+
...managed,
|
|
65355
|
+
oauth_config: {
|
|
65356
|
+
...asRecord$2(managed.oauth_config),
|
|
65357
|
+
redirect_urls: [.../* @__PURE__ */ new Set([redirectUrl, ...options.additionalRedirectUrls ?? []])]
|
|
65358
|
+
}
|
|
65359
|
+
};
|
|
65360
|
+
}
|
|
65361
|
+
/** The PLATFORM (distributed) app's OAuth callback path (preset-agents.md §5.3),
|
|
65362
|
+
* public `/v1` form for the same rewrite/alias reasons as the sibling above. It
|
|
65363
|
+
* must be registered as a redirect URL on the platform Slack app. */
|
|
65364
|
+
const SLACK_PLATFORM_OAUTH_CALLBACK_PATH = "/v1/integrations/slack/platform/callback";
|
|
65365
|
+
/** `<publicCpUrl>/v1/integrations/slack/platform/callback`, trimming a trailing slash. */
|
|
65366
|
+
function slackPlatformOAuthRedirectUri(publicCpUrl) {
|
|
65367
|
+
return `${publicCpUrl.replace(/\/$/, "")}${SLACK_PLATFORM_OAUTH_CALLBACK_PATH}`;
|
|
65368
|
+
}
|
|
65369
|
+
//#endregion
|
|
65370
|
+
//#region src/slack-app.ts
|
|
65371
|
+
function appendPath$1(base, path) {
|
|
65372
|
+
return `${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
65373
|
+
}
|
|
65374
|
+
function requireProviderAppEndpoints(config) {
|
|
65375
|
+
const entries = [
|
|
65376
|
+
["Web", config.services.web],
|
|
65377
|
+
["Control Plane", config.services.controlPlane],
|
|
65378
|
+
["Relay", config.services.relay]
|
|
65379
|
+
];
|
|
65380
|
+
for (const [label, value] of entries) if (!value || new URL(value).protocol !== "https:") throw new Error(`app creation requires a saved HTTPS ${label} public URL`);
|
|
65381
|
+
}
|
|
65382
|
+
/** Keep this deployment manifest aligned with the Control Plane's install manifest. */
|
|
65383
|
+
function buildSlackDeploymentManifest(config, name, login) {
|
|
65384
|
+
requireProviderAppEndpoints(config);
|
|
65385
|
+
const redirectUrl = slackPlatformOAuthRedirectUri(config.services.controlPlane);
|
|
65386
|
+
const loginRedirectUrl = login ? appendPath$1(login.logtoEndpoint, `/callback/${login.connectorId}`) : void 0;
|
|
65387
|
+
const socialLinkRedirectUrl = login ? appendPath$1(config.services.web, "/auth/social/callback") : void 0;
|
|
65388
|
+
if (loginRedirectUrl && new URL(loginRedirectUrl).protocol !== "https:") throw new Error("Slack sign-in requires an HTTPS Logto endpoint");
|
|
65389
|
+
return buildInstallManifest(name, redirectUrl, {
|
|
65390
|
+
httpRelayBase: config.services.relay,
|
|
65391
|
+
...loginRedirectUrl && socialLinkRedirectUrl ? { additionalRedirectUrls: [loginRedirectUrl, socialLinkRedirectUrl] } : {}
|
|
65392
|
+
});
|
|
65393
|
+
}
|
|
65394
|
+
function slackConfiguredUrls(manifest) {
|
|
65395
|
+
const oauth = asRecord$1(manifest.oauth_config);
|
|
65396
|
+
const settings = asRecord$1(manifest.settings);
|
|
65397
|
+
const events = asRecord$1(settings.event_subscriptions);
|
|
65398
|
+
const interactivity = asRecord$1(settings.interactivity);
|
|
65399
|
+
const redirects = asStrings(oauth.redirect_urls);
|
|
65400
|
+
if (redirects.length !== 1 && redirects.length !== 3 || typeof events.request_url !== "string" || typeof interactivity.request_url !== "string") throw new Error("Slack App manifest is missing managed URLs");
|
|
65401
|
+
return {
|
|
65402
|
+
oauthRedirectUrl: redirects[0],
|
|
65403
|
+
eventsUrl: events.request_url,
|
|
65404
|
+
interactionsUrl: interactivity.request_url,
|
|
65405
|
+
...redirects[1] ? { loginRedirectUrl: redirects[1] } : {},
|
|
65406
|
+
...redirects[2] ? { socialLinkRedirectUrl: redirects[2] } : {}
|
|
65407
|
+
};
|
|
65408
|
+
}
|
|
65409
|
+
function asRecord$1(value) {
|
|
65410
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
65411
|
+
}
|
|
65412
|
+
function asStrings(value) {
|
|
65413
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
65414
|
+
}
|
|
65415
|
+
function sameStrings$1(left, right) {
|
|
65416
|
+
return JSON.stringify([...asStrings(left)].sort()) === JSON.stringify([...asStrings(right)].sort());
|
|
65417
|
+
}
|
|
65418
|
+
/** Returns only public App settings; secrets never appear in this diff. */
|
|
65419
|
+
function diffSlackManifest(actual, expected) {
|
|
65420
|
+
const diff = [];
|
|
65421
|
+
const actualOauth = asRecord$1(actual.oauth_config);
|
|
65422
|
+
const expectedOauth = asRecord$1(expected.oauth_config);
|
|
65423
|
+
const actualScopes = asRecord$1(actualOauth.scopes);
|
|
65424
|
+
const expectedScopes = asRecord$1(expectedOauth.scopes);
|
|
65425
|
+
const actualSettings = asRecord$1(actual.settings);
|
|
65426
|
+
const expectedSettings = asRecord$1(expected.settings);
|
|
65427
|
+
const actualEvents = asRecord$1(actualSettings.event_subscriptions);
|
|
65428
|
+
const expectedEvents = asRecord$1(expectedSettings.event_subscriptions);
|
|
65429
|
+
const actualInteractivity = asRecord$1(actualSettings.interactivity);
|
|
65430
|
+
const expectedInteractivity = asRecord$1(expectedSettings.interactivity);
|
|
65431
|
+
for (const scope of asStrings(asRecord$1(expectedScopes).bot)) if (!asStrings(asRecord$1(actualScopes).bot).includes(scope)) diff.push({
|
|
65432
|
+
id: `scope:${scope}`,
|
|
65433
|
+
field: `Bot scope: ${scope}`,
|
|
65434
|
+
current: "Missing",
|
|
65435
|
+
expected: "Required"
|
|
65436
|
+
});
|
|
65437
|
+
for (const event of asStrings(expectedEvents.bot_events)) if (!asStrings(actualEvents.bot_events).includes(event)) diff.push({
|
|
65438
|
+
id: `event:${event}`,
|
|
65439
|
+
field: `Bot event: ${event}`,
|
|
65440
|
+
current: "Missing",
|
|
65441
|
+
expected: "Required"
|
|
65442
|
+
});
|
|
65443
|
+
for (const [id, field, expectedValue] of [
|
|
65444
|
+
[
|
|
65445
|
+
"oauth_config.redirect_urls",
|
|
65446
|
+
"OAuth redirect URLs",
|
|
65447
|
+
expectedOauth.redirect_urls
|
|
65448
|
+
],
|
|
65449
|
+
[
|
|
65450
|
+
"settings.event_subscriptions.request_url",
|
|
65451
|
+
"Events request URL",
|
|
65452
|
+
expectedEvents.request_url
|
|
65453
|
+
],
|
|
65454
|
+
[
|
|
65455
|
+
"settings.interactivity.request_url",
|
|
65456
|
+
"Interactivity request URL",
|
|
65457
|
+
expectedInteractivity.request_url
|
|
65458
|
+
],
|
|
65459
|
+
[
|
|
65460
|
+
"settings.interactivity.message_menu_options_url",
|
|
65461
|
+
"Message menu options URL",
|
|
65462
|
+
expectedInteractivity.message_menu_options_url
|
|
65463
|
+
],
|
|
65464
|
+
[
|
|
65465
|
+
"settings.socket_mode_enabled",
|
|
65466
|
+
"Socket Mode enabled",
|
|
65467
|
+
expectedSettings.socket_mode_enabled
|
|
65468
|
+
]
|
|
65469
|
+
]) {
|
|
65470
|
+
const actualValue = id === "oauth_config.redirect_urls" ? actualOauth.redirect_urls : id === "settings.event_subscriptions.request_url" ? actualEvents.request_url : id === "settings.interactivity.request_url" ? actualInteractivity.request_url : id === "settings.interactivity.message_menu_options_url" ? actualInteractivity.message_menu_options_url : actualSettings.socket_mode_enabled;
|
|
65471
|
+
if (!(id === "oauth_config.redirect_urls" ? sameStrings$1(actualValue, expectedValue) : JSON.stringify(actualValue) === JSON.stringify(expectedValue))) diff.push({
|
|
65472
|
+
id,
|
|
65473
|
+
field,
|
|
65474
|
+
current: actualValue ?? null,
|
|
65475
|
+
expected: expectedValue ?? null
|
|
65476
|
+
});
|
|
65477
|
+
}
|
|
65478
|
+
return diff;
|
|
65479
|
+
}
|
|
65480
|
+
/** `actual` with every field `diffSlackManifest` inspects brought to `expected`, everything else kept, so reconcile-then-diff is empty. */
|
|
65481
|
+
function reconcileSlackManifest(actual, expected) {
|
|
65482
|
+
const actualOauth = asRecord$1(actual.oauth_config);
|
|
65483
|
+
const expectedOauth = asRecord$1(expected.oauth_config);
|
|
65484
|
+
const actualScopes = asRecord$1(actualOauth.scopes);
|
|
65485
|
+
const expectedScopes = asRecord$1(expectedOauth.scopes);
|
|
65486
|
+
const actualSettings = asRecord$1(actual.settings);
|
|
65487
|
+
const expectedSettings = asRecord$1(expected.settings);
|
|
65488
|
+
const actualEvents = asRecord$1(actualSettings.event_subscriptions);
|
|
65489
|
+
const expectedEvents = asRecord$1(expectedSettings.event_subscriptions);
|
|
65490
|
+
const actualInteractivity = asRecord$1(actualSettings.interactivity);
|
|
65491
|
+
const expectedInteractivity = asRecord$1(expectedSettings.interactivity);
|
|
65492
|
+
const union = (current, required) => [.../* @__PURE__ */ new Set([...asStrings(current), ...asStrings(required)])];
|
|
65493
|
+
const urlOf = (value, key) => typeof value === "string" ? { [key]: value } : {};
|
|
65494
|
+
return {
|
|
65495
|
+
...actual,
|
|
65496
|
+
oauth_config: {
|
|
65497
|
+
...actualOauth,
|
|
65498
|
+
redirect_urls: asStrings(expectedOauth.redirect_urls),
|
|
65499
|
+
scopes: {
|
|
65500
|
+
...actualScopes,
|
|
65501
|
+
bot: union(actualScopes.bot, expectedScopes.bot)
|
|
65502
|
+
}
|
|
65503
|
+
},
|
|
65504
|
+
settings: {
|
|
65505
|
+
...actualSettings,
|
|
65506
|
+
event_subscriptions: {
|
|
65507
|
+
...actualEvents,
|
|
65508
|
+
bot_events: union(actualEvents.bot_events, expectedEvents.bot_events),
|
|
65509
|
+
...urlOf(expectedEvents.request_url, "request_url")
|
|
65510
|
+
},
|
|
65511
|
+
interactivity: {
|
|
65512
|
+
...actualInteractivity,
|
|
65513
|
+
...urlOf(expectedInteractivity.request_url, "request_url"),
|
|
65514
|
+
...urlOf(expectedInteractivity.message_menu_options_url, "message_menu_options_url")
|
|
65515
|
+
},
|
|
65516
|
+
socket_mode_enabled: expectedSettings.socket_mode_enabled
|
|
65517
|
+
}
|
|
65518
|
+
};
|
|
65519
|
+
}
|
|
65520
|
+
/** Returns stable field names only; it never includes provider response values. */
|
|
65521
|
+
function auditSlackManifest(actual, expected) {
|
|
65522
|
+
return diffSlackManifest(actual, expected).map((item) => item.id);
|
|
65523
|
+
}
|
|
65170
65524
|
const ADMIN_ROLE = "ADMIN";
|
|
65171
65525
|
var SetupAuthError = class extends Error {
|
|
65172
65526
|
statusCode;
|
|
@@ -65325,7 +65679,7 @@ function _taggedTemplateLiteral(e, t) {
|
|
|
65325
65679
|
//#region src/server/html.ts
|
|
65326
65680
|
var _templateObject;
|
|
65327
65681
|
/** One deliberately small, dependency-free deployment administration page. */
|
|
65328
|
-
const SETUP_HTML = String.raw(_templateObject || (_templateObject = _taggedTemplateLiteral(["<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. Forgejo and Codeberg are not supported.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"feishu-section\" class=\"panel setup-section\" aria-labelledby=\"feishu-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"feishu-heading\">Feishu</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Feishu, OAuth callbacks, and published App setup.</p></div>\n <span id=\"feishu-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"feishu-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"feishu\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"feishu-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"feishu.loginAppSecret\" data-secret-display=\"feishu-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"feishu-login-status\" class=\"muted\"></p>\n <div id=\"feishu-drift\" class=\"notice\" hidden></div>\n <div id=\"feishu-config-controls\" class=\"subsection\">\n <label id=\"feishu-create-name-field\" class=\"field\">New App name<input id=\"feishu-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"feishu-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"feishu-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-feishu-login-app\">Create Feishu App</button>\n <button id=\"save-feishu-login-app\">Save existing App</button>\n <button id=\"cancel-feishu-configuration\" hidden>Cancel</button>\n <button id=\"check-feishu-login-app\" hidden>Check setup</button>\n <button id=\"clear-feishu\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"feishu-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Feishu settings</a>\n </div>\n </section>\n\n <section id=\"lark-section\" class=\"panel setup-section\" aria-labelledby=\"lark-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"lark-heading\">Lark</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Lark, OAuth callbacks, and published App setup.</p></div>\n <span id=\"lark-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"lark-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"lark\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"lark-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"lark.loginAppSecret\" data-secret-display=\"lark-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"lark-login-status\" class=\"muted\"></p>\n <div id=\"lark-drift\" class=\"notice\" hidden></div>\n <div id=\"lark-config-controls\" class=\"subsection\">\n <label id=\"lark-create-name-field\" class=\"field\">New App name<input id=\"lark-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"lark-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"lark-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-lark-login-app\">Create Lark App</button>\n <button id=\"save-lark-login-app\">Save existing App</button>\n <button id=\"cancel-lark-configuration\" hidden>Cancel</button>\n <button id=\"check-lark-login-app\" hidden>Check setup</button>\n <button id=\"clear-lark\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"lark-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Lark settings</a>\n </div>\n </section>\n </div>\n\n <section id=\"options-section\" class=\"setup-section\">\n <h2>Deployment options</h2>\n <div class=\"panel\">\n <label class=\"field\"><span><input id=\"preset-agents-enabled\" type=\"checkbox\"> Enable preset Agents</span></label>\n <label class=\"field\">Max organizations created per non-ADMIN user<input id=\"max-orgs-per-non-admin-user\" type=\"number\" min=\"0\" step=\"1\" value=\"1\"></label>\n <div class=\"row\"><button id=\"save-options\">Save options</button></div>\n </div>\n </section>\n </section>\n </div>\n </div>\n </main>\n\n <script>\n const api = '/api/v1';\n const tokenKey = 'agentconnect.setup.token';\n const verifierKey = 'agentconnect.setup.pkce';\n const stateKey = 'agentconnect.setup.state';\n let currentRevision = 0;\n let currentStatus = null;\n let bootstrapInfo = null;\n const el = (id) => document.getElementById(id);\n const message = (text, error = false) => {\n const target = el('setup').hidden ? el('access-message') : el('message');\n target.textContent = text;\n target.className = error ? 'error' : 'ok';\n };\n const bearer = () => {\n const token = sessionStorage.getItem(tokenKey);\n return token ? { authorization: 'Bearer ' + token } : {};\n };\n const json = async (response) => {\n const body = await response.json().catch(() => ({}));\n if (!response.ok) throw Object.assign(new Error(body.message || ('HTTP ' + response.status)), { status: response.status, code: body.code });\n return body;\n };\n const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/+/g, '-').replace(///g, '_').replace(/=+$/, '');\n const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));\n const configured = (byKey, key) => Boolean(byKey.get(key) && byKey.get(key).configured);\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n next = { ...values, gitlab: null };\n secrets = { 'gitlab.clientSecret': null };\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"], ["<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. Forgejo and Codeberg are not supported.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"feishu-section\" class=\"panel setup-section\" aria-labelledby=\"feishu-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"feishu-heading\">Feishu</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Feishu, OAuth callbacks, and published App setup.</p></div>\n <span id=\"feishu-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"feishu-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"feishu\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"feishu-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"feishu.loginAppSecret\" data-secret-display=\"feishu-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"feishu-login-status\" class=\"muted\"></p>\n <div id=\"feishu-drift\" class=\"notice\" hidden></div>\n <div id=\"feishu-config-controls\" class=\"subsection\">\n <label id=\"feishu-create-name-field\" class=\"field\">New App name<input id=\"feishu-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"feishu-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"feishu-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-feishu-login-app\">Create Feishu App</button>\n <button id=\"save-feishu-login-app\">Save existing App</button>\n <button id=\"cancel-feishu-configuration\" hidden>Cancel</button>\n <button id=\"check-feishu-login-app\" hidden>Check setup</button>\n <button id=\"clear-feishu\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"feishu-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Feishu settings</a>\n </div>\n </section>\n\n <section id=\"lark-section\" class=\"panel setup-section\" aria-labelledby=\"lark-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"lark-heading\">Lark</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Lark, OAuth callbacks, and published App setup.</p></div>\n <span id=\"lark-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"lark-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"lark\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"lark-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"lark.loginAppSecret\" data-secret-display=\"lark-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"lark-login-status\" class=\"muted\"></p>\n <div id=\"lark-drift\" class=\"notice\" hidden></div>\n <div id=\"lark-config-controls\" class=\"subsection\">\n <label id=\"lark-create-name-field\" class=\"field\">New App name<input id=\"lark-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"lark-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"lark-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-lark-login-app\">Create Lark App</button>\n <button id=\"save-lark-login-app\">Save existing App</button>\n <button id=\"cancel-lark-configuration\" hidden>Cancel</button>\n <button id=\"check-lark-login-app\" hidden>Check setup</button>\n <button id=\"clear-lark\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"lark-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Lark settings</a>\n </div>\n </section>\n </div>\n\n <section id=\"options-section\" class=\"setup-section\">\n <h2>Deployment options</h2>\n <div class=\"panel\">\n <label class=\"field\"><span><input id=\"preset-agents-enabled\" type=\"checkbox\"> Enable preset Agents</span></label>\n <label class=\"field\">Max organizations created per non-ADMIN user<input id=\"max-orgs-per-non-admin-user\" type=\"number\" min=\"0\" step=\"1\" value=\"1\"></label>\n <div class=\"row\"><button id=\"save-options\">Save options</button></div>\n </div>\n </section>\n </section>\n </div>\n </div>\n </main>\n\n <script>\n const api = '/api/v1';\n const tokenKey = 'agentconnect.setup.token';\n const verifierKey = 'agentconnect.setup.pkce';\n const stateKey = 'agentconnect.setup.state';\n let currentRevision = 0;\n let currentStatus = null;\n let bootstrapInfo = null;\n const el = (id) => document.getElementById(id);\n const message = (text, error = false) => {\n const target = el('setup').hidden ? el('access-message') : el('message');\n target.textContent = text;\n target.className = error ? 'error' : 'ok';\n };\n const bearer = () => {\n const token = sessionStorage.getItem(tokenKey);\n return token ? { authorization: 'Bearer ' + token } : {};\n };\n const json = async (response) => {\n const body = await response.json().catch(() => ({}));\n if (!response.ok) throw Object.assign(new Error(body.message || ('HTTP ' + response.status)), { status: response.status, code: body.code });\n return body;\n };\n const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));\n const configured = (byKey, key) => Boolean(byKey.get(key) && byKey.get(key).configured);\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n next = { ...values, gitlab: null };\n secrets = { 'gitlab.clientSecret': null };\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto\\./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"])));
|
|
65682
|
+
const SETUP_HTML = String.raw(_templateObject || (_templateObject = _taggedTemplateLiteral(["<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#googlechat-section\">Google Chat</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. Forgejo and Codeberg are not supported.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"googlechat-section\" class=\"panel setup-section\" aria-labelledby=\"googlechat-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"googlechat-heading\">Google Chat</h3><p class=\"muted\">One Chat app for the preset AgentConnect agent.</p></div>\n <span id=\"googlechat-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Project ID</dt><dd class=\"value-line\"><code id=\"googlechat-project-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Project number</dt><dd class=\"value-line\"><code id=\"googlechat-project-number\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Service-account key</dt><dd class=\"value-line\"><span id=\"googlechat-key-display\" class=\"muted\">Not configured</span><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n </dl>\n <p id=\"googlechat-status\" class=\"muted\"></p>\n <div id=\"googlechat-drift\" class=\"notice\" hidden></div>\n <p>HTTP endpoint URL:</p><ul id=\"googlechat-callbacks\" class=\"uris\"></ul>\n <p>Authentication audience:</p><ul id=\"googlechat-audience\" class=\"uris\"></ul>\n <ol class=\"muted\">\n <li>Use a Google Workspace account that may create a Cloud project and service-account keys. An organization policy can block key creation.</li>\n <li>Create a Google Cloud project for this app and enable the Google Chat API in it.</li>\n <li>On the Chat API Configuration page, set the HTTP endpoint URL and the Project Number audience above, allow 1:1 messages and joining spaces, and choose who can use the app.</li>\n <li>In the same project, create a service account and a JSON key for it, then save the project ID, project number, and key here.</li>\n <li>Add the app in Google Chat, then send it a direct message or mention it in a space to test.</li>\n </ol>\n <div class=\"row\"><a id=\"googlechat-configuration\" class=\"button\" href=\"https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat\" target=\"_blank\" rel=\"noopener\">Open Chat API configuration</a></div>\n <div id=\"googlechat-config-controls\" class=\"subsection\">\n <h3 id=\"googlechat-edit-heading\" hidden>Edit Google Chat App identity</h3>\n <label class=\"field\">Project ID<input id=\"googlechat-project-id-input\" autocomplete=\"off\"></label>\n <label class=\"field\">Project number<input id=\"googlechat-project-number-input\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">Service-account key JSON<input id=\"googlechat-key\" type=\"password\" autocomplete=\"off\" placeholder=\"Required when the project ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-googlechat\">Save Google Chat app</button><button id=\"cancel-googlechat-configuration\" hidden>Cancel</button><button id=\"clear-googlechat\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"feishu-section\" class=\"panel setup-section\" aria-labelledby=\"feishu-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"feishu-heading\">Feishu</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Feishu, OAuth callbacks, and published App setup.</p></div>\n <span id=\"feishu-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"feishu-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"feishu\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"feishu-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"feishu.loginAppSecret\" data-secret-display=\"feishu-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"feishu-login-status\" class=\"muted\"></p>\n <div id=\"feishu-drift\" class=\"notice\" hidden></div>\n <div id=\"feishu-config-controls\" class=\"subsection\">\n <label id=\"feishu-create-name-field\" class=\"field\">New App name<input id=\"feishu-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"feishu-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"feishu-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-feishu-login-app\">Create Feishu App</button>\n <button id=\"save-feishu-login-app\">Save existing App</button>\n <button id=\"cancel-feishu-configuration\" hidden>Cancel</button>\n <button id=\"check-feishu-login-app\" hidden>Check setup</button>\n <button id=\"clear-feishu\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"feishu-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Feishu settings</a>\n </div>\n </section>\n\n <section id=\"lark-section\" class=\"panel setup-section\" aria-labelledby=\"lark-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"lark-heading\">Lark</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Lark, OAuth callbacks, and published App setup.</p></div>\n <span id=\"lark-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"lark-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"lark\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"lark-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"lark.loginAppSecret\" data-secret-display=\"lark-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"lark-login-status\" class=\"muted\"></p>\n <div id=\"lark-drift\" class=\"notice\" hidden></div>\n <div id=\"lark-config-controls\" class=\"subsection\">\n <label id=\"lark-create-name-field\" class=\"field\">New App name<input id=\"lark-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"lark-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"lark-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-lark-login-app\">Create Lark App</button>\n <button id=\"save-lark-login-app\">Save existing App</button>\n <button id=\"cancel-lark-configuration\" hidden>Cancel</button>\n <button id=\"check-lark-login-app\" hidden>Check setup</button>\n <button id=\"clear-lark\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"lark-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Lark settings</a>\n </div>\n </section>\n </div>\n\n <section id=\"options-section\" class=\"setup-section\">\n <h2>Deployment options</h2>\n <div class=\"panel\">\n <label class=\"field\"><span><input id=\"preset-agents-enabled\" type=\"checkbox\"> Enable preset Agents</span></label>\n <label class=\"field\">Max organizations created per non-ADMIN user<input id=\"max-orgs-per-non-admin-user\" type=\"number\" min=\"0\" step=\"1\" value=\"1\"></label>\n <div class=\"row\"><button id=\"save-options\">Save options</button></div>\n </div>\n </section>\n </section>\n </div>\n </div>\n </main>\n\n <script>\n const api = '/api/v1';\n const tokenKey = 'agentconnect.setup.token';\n const verifierKey = 'agentconnect.setup.pkce';\n const stateKey = 'agentconnect.setup.state';\n let currentRevision = 0;\n let currentStatus = null;\n let bootstrapInfo = null;\n const el = (id) => document.getElementById(id);\n const message = (text, error = false) => {\n const target = el('setup').hidden ? el('access-message') : el('message');\n target.textContent = text;\n target.className = error ? 'error' : 'ok';\n };\n const bearer = () => {\n const token = sessionStorage.getItem(tokenKey);\n return token ? { authorization: 'Bearer ' + token } : {};\n };\n const json = async (response) => {\n const body = await response.json().catch(() => ({}));\n if (!response.ok) throw Object.assign(new Error(body.message || ('HTTP ' + response.status)), { status: response.status, code: body.code });\n return body;\n };\n const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/+/g, '-').replace(///g, '_').replace(/=+$/, '');\n const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));\n const configured = (byKey, key) => Boolean(byKey.get(key) && byKey.get(key).configured);\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, googleChat: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const googleChat = values.googleChat;\n const googleChatKey = configured(byKey, 'googleChat.serviceAccountKey');\n const expectedGoogleChat = expected.googleChat;\n renderUriList('googlechat-callbacks', expectedGoogleChat ? [expectedGoogleChat.callbackUrl] : []);\n renderUriList('googlechat-audience', expectedGoogleChat\n ? [expectedGoogleChat.audienceSetting + (googleChat ? ' ' + googleChat.projectNumber : '')]\n : []);\n text('googlechat-project-id', googleChat && googleChat.projectId);\n text('googlechat-project-number', googleChat && googleChat.projectNumber);\n el('googlechat-key-display').textContent = googleChatKey ? '***' : 'Not configured';\n el('googlechat-key-display').className = googleChatKey ? 'redacted' : 'muted';\n showIdentityEditors('googlechat', Boolean(googleChat));\n el('googlechat-project-id-input').value = googleChat ? googleChat.projectId : '';\n el('googlechat-project-number-input').value = googleChat ? googleChat.projectNumber : '';\n el('googlechat-key').value = '';\n if (expectedGoogleChat) el('googlechat-configuration').href = expectedGoogleChat.configurationUrl;\n el('googlechat-status').textContent = googleChat\n ? googleChat.projectId + ' is configured.'\n : expectedGoogleChat\n ? 'Configure the Chat app in Google Cloud Console, then save its project and service-account key here.'\n : 'Publishing the HTTP endpoint URL needs an HTTPS ingress public URL.';\n showDiff('googlechat-drift', googleChat && !expectedGoogleChat\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS ingress URL' }]\n : []);\n match('googlechat-match', googleChat && googleChatKey ? 'pass' : googleChat ? 'warn' : '', !googleChat ? 'Not configured' : !googleChatKey ? 'Missing key' : 'Checked on save');\n el('googlechat-edit-heading').hidden = true;\n el('googlechat-config-controls').hidden = Boolean(googleChat);\n el('save-googlechat').hidden = Boolean(googleChat);\n el('cancel-googlechat-configuration').hidden = true;\n el('clear-googlechat').hidden = !googleChat;\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'googlechat') {\n if (!values.googleChat) return;\n el('googlechat-project-id-input').value = values.googleChat.projectId;\n el('googlechat-project-number-input').value = values.googleChat.projectNumber;\n el('googlechat-key').value = '';\n el('googlechat-edit-heading').hidden = false;\n el('googlechat-config-controls').hidden = false;\n el('save-googlechat').hidden = false;\n el('cancel-googlechat-configuration').hidden = false;\n el('clear-googlechat').hidden = true;\n el('googlechat-project-id-input').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'googlechat' ? 'Google Chat' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n next = { ...values, gitlab: null };\n secrets = { 'gitlab.clientSecret': null };\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'googlechat') {\n next = { ...values, googleChat: null };\n secrets = { 'googleChat.serviceAccountKey': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogleChat() {\n const application = {\n projectId: requiredInput('googlechat-project-id-input', 'the Google Cloud project ID'),\n projectNumber: requiredInput('googlechat-project-number-input', 'the Google Cloud project number')\n };\n const serviceAccountKey = el('googlechat-key').value.trim();\n let saved;\n try {\n saved = await json(await fetch(api + '/configure/google-chat', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: { ...application, ...(serviceAccountKey ? { serviceAccountKey } : {}) } })\n }));\n } finally {\n // The key never stays in the page once it has been submitted, whatever the outcome.\n el('googlechat-key').value = '';\n }\n await load();\n message('Google Chat app saved. ' + saved.probe.message + '. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-googlechat').onclick = () => saveGoogleChat().catch((error) => message(error.message, true));\n el('cancel-googlechat-configuration').onclick = cancelConfigurationEdit;\n el('clear-googlechat').onclick = () => clearProvider('googlechat').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"], ["<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#googlechat-section\">Google Chat</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. Forgejo and Codeberg are not supported.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"googlechat-section\" class=\"panel setup-section\" aria-labelledby=\"googlechat-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"googlechat-heading\">Google Chat</h3><p class=\"muted\">One Chat app for the preset AgentConnect agent.</p></div>\n <span id=\"googlechat-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Project ID</dt><dd class=\"value-line\"><code id=\"googlechat-project-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Project number</dt><dd class=\"value-line\"><code id=\"googlechat-project-number\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Service-account key</dt><dd class=\"value-line\"><span id=\"googlechat-key-display\" class=\"muted\">Not configured</span><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n </dl>\n <p id=\"googlechat-status\" class=\"muted\"></p>\n <div id=\"googlechat-drift\" class=\"notice\" hidden></div>\n <p>HTTP endpoint URL:</p><ul id=\"googlechat-callbacks\" class=\"uris\"></ul>\n <p>Authentication audience:</p><ul id=\"googlechat-audience\" class=\"uris\"></ul>\n <ol class=\"muted\">\n <li>Use a Google Workspace account that may create a Cloud project and service-account keys. An organization policy can block key creation.</li>\n <li>Create a Google Cloud project for this app and enable the Google Chat API in it.</li>\n <li>On the Chat API Configuration page, set the HTTP endpoint URL and the Project Number audience above, allow 1:1 messages and joining spaces, and choose who can use the app.</li>\n <li>In the same project, create a service account and a JSON key for it, then save the project ID, project number, and key here.</li>\n <li>Add the app in Google Chat, then send it a direct message or mention it in a space to test.</li>\n </ol>\n <div class=\"row\"><a id=\"googlechat-configuration\" class=\"button\" href=\"https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat\" target=\"_blank\" rel=\"noopener\">Open Chat API configuration</a></div>\n <div id=\"googlechat-config-controls\" class=\"subsection\">\n <h3 id=\"googlechat-edit-heading\" hidden>Edit Google Chat App identity</h3>\n <label class=\"field\">Project ID<input id=\"googlechat-project-id-input\" autocomplete=\"off\"></label>\n <label class=\"field\">Project number<input id=\"googlechat-project-number-input\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">Service-account key JSON<input id=\"googlechat-key\" type=\"password\" autocomplete=\"off\" placeholder=\"Required when the project ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-googlechat\">Save Google Chat app</button><button id=\"cancel-googlechat-configuration\" hidden>Cancel</button><button id=\"clear-googlechat\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"feishu-section\" class=\"panel setup-section\" aria-labelledby=\"feishu-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"feishu-heading\">Feishu</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Feishu, OAuth callbacks, and published App setup.</p></div>\n <span id=\"feishu-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"feishu-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"feishu\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"feishu-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"feishu.loginAppSecret\" data-secret-display=\"feishu-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"feishu-login-status\" class=\"muted\"></p>\n <div id=\"feishu-drift\" class=\"notice\" hidden></div>\n <div id=\"feishu-config-controls\" class=\"subsection\">\n <label id=\"feishu-create-name-field\" class=\"field\">New App name<input id=\"feishu-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"feishu-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"feishu-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-feishu-login-app\">Create Feishu App</button>\n <button id=\"save-feishu-login-app\">Save existing App</button>\n <button id=\"cancel-feishu-configuration\" hidden>Cancel</button>\n <button id=\"check-feishu-login-app\" hidden>Check setup</button>\n <button id=\"clear-feishu\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"feishu-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Feishu settings</a>\n </div>\n </section>\n\n <section id=\"lark-section\" class=\"panel setup-section\" aria-labelledby=\"lark-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"lark-heading\">Lark</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Lark, OAuth callbacks, and published App setup.</p></div>\n <span id=\"lark-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"lark-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"lark\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"lark-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"lark.loginAppSecret\" data-secret-display=\"lark-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"lark-login-status\" class=\"muted\"></p>\n <div id=\"lark-drift\" class=\"notice\" hidden></div>\n <div id=\"lark-config-controls\" class=\"subsection\">\n <label id=\"lark-create-name-field\" class=\"field\">New App name<input id=\"lark-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"lark-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"lark-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-lark-login-app\">Create Lark App</button>\n <button id=\"save-lark-login-app\">Save existing App</button>\n <button id=\"cancel-lark-configuration\" hidden>Cancel</button>\n <button id=\"check-lark-login-app\" hidden>Check setup</button>\n <button id=\"clear-lark\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"lark-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Lark settings</a>\n </div>\n </section>\n </div>\n\n <section id=\"options-section\" class=\"setup-section\">\n <h2>Deployment options</h2>\n <div class=\"panel\">\n <label class=\"field\"><span><input id=\"preset-agents-enabled\" type=\"checkbox\"> Enable preset Agents</span></label>\n <label class=\"field\">Max organizations created per non-ADMIN user<input id=\"max-orgs-per-non-admin-user\" type=\"number\" min=\"0\" step=\"1\" value=\"1\"></label>\n <div class=\"row\"><button id=\"save-options\">Save options</button></div>\n </div>\n </section>\n </section>\n </div>\n </div>\n </main>\n\n <script>\n const api = '/api/v1';\n const tokenKey = 'agentconnect.setup.token';\n const verifierKey = 'agentconnect.setup.pkce';\n const stateKey = 'agentconnect.setup.state';\n let currentRevision = 0;\n let currentStatus = null;\n let bootstrapInfo = null;\n const el = (id) => document.getElementById(id);\n const message = (text, error = false) => {\n const target = el('setup').hidden ? el('access-message') : el('message');\n target.textContent = text;\n target.className = error ? 'error' : 'ok';\n };\n const bearer = () => {\n const token = sessionStorage.getItem(tokenKey);\n return token ? { authorization: 'Bearer ' + token } : {};\n };\n const json = async (response) => {\n const body = await response.json().catch(() => ({}));\n if (!response.ok) throw Object.assign(new Error(body.message || ('HTTP ' + response.status)), { status: response.status, code: body.code });\n return body;\n };\n const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));\n const configured = (byKey, key) => Boolean(byKey.get(key) && byKey.get(key).configured);\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, googleChat: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const googleChat = values.googleChat;\n const googleChatKey = configured(byKey, 'googleChat.serviceAccountKey');\n const expectedGoogleChat = expected.googleChat;\n renderUriList('googlechat-callbacks', expectedGoogleChat ? [expectedGoogleChat.callbackUrl] : []);\n renderUriList('googlechat-audience', expectedGoogleChat\n ? [expectedGoogleChat.audienceSetting + (googleChat ? ' ' + googleChat.projectNumber : '')]\n : []);\n text('googlechat-project-id', googleChat && googleChat.projectId);\n text('googlechat-project-number', googleChat && googleChat.projectNumber);\n el('googlechat-key-display').textContent = googleChatKey ? '***' : 'Not configured';\n el('googlechat-key-display').className = googleChatKey ? 'redacted' : 'muted';\n showIdentityEditors('googlechat', Boolean(googleChat));\n el('googlechat-project-id-input').value = googleChat ? googleChat.projectId : '';\n el('googlechat-project-number-input').value = googleChat ? googleChat.projectNumber : '';\n el('googlechat-key').value = '';\n if (expectedGoogleChat) el('googlechat-configuration').href = expectedGoogleChat.configurationUrl;\n el('googlechat-status').textContent = googleChat\n ? googleChat.projectId + ' is configured.'\n : expectedGoogleChat\n ? 'Configure the Chat app in Google Cloud Console, then save its project and service-account key here.'\n : 'Publishing the HTTP endpoint URL needs an HTTPS ingress public URL.';\n showDiff('googlechat-drift', googleChat && !expectedGoogleChat\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS ingress URL' }]\n : []);\n match('googlechat-match', googleChat && googleChatKey ? 'pass' : googleChat ? 'warn' : '', !googleChat ? 'Not configured' : !googleChatKey ? 'Missing key' : 'Checked on save');\n el('googlechat-edit-heading').hidden = true;\n el('googlechat-config-controls').hidden = Boolean(googleChat);\n el('save-googlechat').hidden = Boolean(googleChat);\n el('cancel-googlechat-configuration').hidden = true;\n el('clear-googlechat').hidden = !googleChat;\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'googlechat') {\n if (!values.googleChat) return;\n el('googlechat-project-id-input').value = values.googleChat.projectId;\n el('googlechat-project-number-input').value = values.googleChat.projectNumber;\n el('googlechat-key').value = '';\n el('googlechat-edit-heading').hidden = false;\n el('googlechat-config-controls').hidden = false;\n el('save-googlechat').hidden = false;\n el('cancel-googlechat-configuration').hidden = false;\n el('clear-googlechat').hidden = true;\n el('googlechat-project-id-input').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'googlechat' ? 'Google Chat' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n next = { ...values, gitlab: null };\n secrets = { 'gitlab.clientSecret': null };\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'googlechat') {\n next = { ...values, googleChat: null };\n secrets = { 'googleChat.serviceAccountKey': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogleChat() {\n const application = {\n projectId: requiredInput('googlechat-project-id-input', 'the Google Cloud project ID'),\n projectNumber: requiredInput('googlechat-project-number-input', 'the Google Cloud project number')\n };\n const serviceAccountKey = el('googlechat-key').value.trim();\n let saved;\n try {\n saved = await json(await fetch(api + '/configure/google-chat', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: { ...application, ...(serviceAccountKey ? { serviceAccountKey } : {}) } })\n }));\n } finally {\n // The key never stays in the page once it has been submitted, whatever the outcome.\n el('googlechat-key').value = '';\n }\n await load();\n message('Google Chat app saved. ' + saved.probe.message + '. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto\\./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-googlechat').onclick = () => saveGoogleChat().catch((error) => message(error.message, true));\n el('cancel-googlechat-configuration').onclick = cancelConfigurationEdit;\n el('clear-googlechat').onclick = () => clearProvider('googlechat').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"])));
|
|
65329
65683
|
//#endregion
|
|
65330
65684
|
//#region src/server/logto-management.ts
|
|
65331
65685
|
/** Minimal Logto Management API client for setup reconciliation and ADMIN claim. */
|
|
@@ -65961,6 +66315,12 @@ const ConfigureLinearBody = strictObject({ application: strictObject({
|
|
|
65961
66315
|
clientSecret: string().min(1).max(1e4).optional(),
|
|
65962
66316
|
signingSecret: string().min(1).max(1e4).optional()
|
|
65963
66317
|
}).nullable() });
|
|
66318
|
+
const ConfigureGoogleChatBody = strictObject({ application: strictObject({
|
|
66319
|
+
projectId: string().trim().min(1).max(100),
|
|
66320
|
+
projectNumber: string().trim().min(1).max(40),
|
|
66321
|
+
/** The downloaded key file's JSON; omitted re-validates the stored key of the same project. */
|
|
66322
|
+
serviceAccountKey: string().trim().min(1).max(2e4).optional()
|
|
66323
|
+
}).nullable() });
|
|
65964
66324
|
const ConfigureGoogleBody = strictObject({
|
|
65965
66325
|
clientId: string().trim().min(1).max(500),
|
|
65966
66326
|
clientSecret: string().min(1).max(1e4).optional()
|
|
@@ -66205,11 +66565,16 @@ function buildSetupServer(deps, options = {}) {
|
|
|
66205
66565
|
try {
|
|
66206
66566
|
linear = linearConfiguredUrls(providerAppConfig(localAuthBootstrap.services));
|
|
66207
66567
|
} catch {}
|
|
66568
|
+
let googleChat = null;
|
|
66569
|
+
try {
|
|
66570
|
+
googleChat = googleChatConfiguredUrls(providerAppConfig(localAuthBootstrap.services));
|
|
66571
|
+
} catch {}
|
|
66208
66572
|
return {
|
|
66209
66573
|
github,
|
|
66210
66574
|
gitlab,
|
|
66211
66575
|
slack,
|
|
66212
66576
|
linear,
|
|
66577
|
+
googleChat,
|
|
66213
66578
|
google: values.logto?.browser ? {
|
|
66214
66579
|
origins: [logtoEndpoint],
|
|
66215
66580
|
redirects: socialRedirectUris(logtoEndpoint, connectorIds.google ?? "agentconnect-google", localAuthBootstrap.services.web)
|
|
@@ -66563,6 +66928,71 @@ function buildSetupServer(deps, options = {}) {
|
|
|
66563
66928
|
};
|
|
66564
66929
|
});
|
|
66565
66930
|
});
|
|
66931
|
+
app.post("/api/v1/configure/google-chat", { preHandler: requireConfigurationAccess }, async (request, reply) => {
|
|
66932
|
+
const parsed = ConfigureGoogleChatBody.safeParse(request.body);
|
|
66933
|
+
if (!parsed.success) return problem(reply, 400, "a Google Cloud project ID, project number, and service-account key are required");
|
|
66934
|
+
const application = parsed.data.application;
|
|
66935
|
+
if (!application) return serializeMutation(async () => {
|
|
66936
|
+
const current = await deps.store.getAdmin();
|
|
66937
|
+
if (!current) return problem(reply, 409, "save deployment settings before configuring Google Chat");
|
|
66938
|
+
return {
|
|
66939
|
+
revision: (await deps.store.replace({
|
|
66940
|
+
expectedRevision: current.revision,
|
|
66941
|
+
...googleChatDeploymentPut(current, null)
|
|
66942
|
+
})).revision,
|
|
66943
|
+
restartRequired: true
|
|
66944
|
+
};
|
|
66945
|
+
});
|
|
66946
|
+
const { projectId, projectNumber } = application;
|
|
66947
|
+
if (!/^[1-9]\d{0,19}$/.test(projectNumber)) return problem(reply, 400, "the project number must be the numeric Project number from the Google Cloud dashboard");
|
|
66948
|
+
let urls;
|
|
66949
|
+
try {
|
|
66950
|
+
urls = googleChatConfiguredUrls(providerAppConfig(localAuthBootstrap.services));
|
|
66951
|
+
} catch (error) {
|
|
66952
|
+
return problem(reply, 409, error instanceof Error ? error.message : "the Google Chat HTTP endpoint is unavailable");
|
|
66953
|
+
}
|
|
66954
|
+
let rawKey = application.serviceAccountKey;
|
|
66955
|
+
if (!rawKey) {
|
|
66956
|
+
const runtime = await deps.store.getRuntime(["googleChat.serviceAccountKey"]);
|
|
66957
|
+
const stored = runtime?.secrets["googleChat.serviceAccountKey"];
|
|
66958
|
+
if (!stored || runtime?.values.googleChat?.projectId !== projectId) return problem(reply, 400, "paste the service-account key JSON for this project");
|
|
66959
|
+
rawKey = stored;
|
|
66960
|
+
}
|
|
66961
|
+
const checked = checkServiceAccountKey(rawKey, projectId);
|
|
66962
|
+
if (checked.status !== "ok") return problem(reply, 400, checked.message, checked.status);
|
|
66963
|
+
const probe = await probeGoogleChatCredential(checked.key, fetchImpl, deps.now);
|
|
66964
|
+
if (probe.status !== "ok") return problem(reply, probeFailureIsConnectivity(probe.status) ? 502 : 400, probe.message, probe.status);
|
|
66965
|
+
return serializeMutation(async () => {
|
|
66966
|
+
const current = await deps.store.getAdmin();
|
|
66967
|
+
if (!current) return problem(reply, 409, "save deployment settings before configuring Google Chat");
|
|
66968
|
+
let put;
|
|
66969
|
+
try {
|
|
66970
|
+
put = googleChatDeploymentPut(current, {
|
|
66971
|
+
projectId,
|
|
66972
|
+
projectNumber,
|
|
66973
|
+
...application.serviceAccountKey ? { serviceAccountKey: checked.key.json } : {}
|
|
66974
|
+
});
|
|
66975
|
+
} catch (error) {
|
|
66976
|
+
return problem(reply, 400, (error instanceof ZodError ? "the project ID is not a Google Cloud project ID" : void 0) ?? (error instanceof Error ? error.message : "invalid Google Chat app"));
|
|
66977
|
+
}
|
|
66978
|
+
return {
|
|
66979
|
+
revision: (await deps.store.replace({
|
|
66980
|
+
expectedRevision: current.revision,
|
|
66981
|
+
...put
|
|
66982
|
+
})).revision,
|
|
66983
|
+
restartRequired: true,
|
|
66984
|
+
callbackUrl: urls.callbackUrl,
|
|
66985
|
+
audience: {
|
|
66986
|
+
setting: urls.audienceSetting,
|
|
66987
|
+
value: projectNumber
|
|
66988
|
+
},
|
|
66989
|
+
probe: {
|
|
66990
|
+
status: probe.status,
|
|
66991
|
+
message: probe.message
|
|
66992
|
+
}
|
|
66993
|
+
};
|
|
66994
|
+
});
|
|
66995
|
+
});
|
|
66566
66996
|
app.post("/api/v1/configure/google", { preHandler: requireConfigurationAccess }, async (request, reply) => {
|
|
66567
66997
|
const parsed = ConfigureGoogleBody.safeParse(request.body);
|
|
66568
66998
|
if (!parsed.success) return problem(reply, 400, "Google OAuth client id is required");
|