Secrets manager (#4131) * Secret manager (now for real) * Refactor secret manager dialog * Add error handling to secrets migration * Adjust default value * Add secret-id slash command * Add secret management slash commands * Improve type definitions * Improve compatibility of UUID generator * Add copy buttons to manager view * Improve compatibility with Vertex AI service account - Changed to input since textarea can't be used with datalist - Unblock regular key placeholder - Save email as a key label - Interrupt validation if the input is a UUID (autocompleted) * Add optional label input for secret values in key manager dialog * Update masking rules * /secret-id: make the arg "required" (it's not)

8d2b9d2dab3ad2d459cd68c271e27e1706cecc2a

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
16 files changed, +1600 -219Showing whitespace changes
public/css/secrets.css+102 -0
@@ -0,0 +1,102 @@
1+.secretKeyManager {
2+ display: flex;
3+ flex-direction: column;
4+ gap: 5px;
5+ height: 100%;
6+}
7+
8+.secretKeyManagerHeader {
9+ display: flex;
10+ flex-direction: column;
11+ justify-content: center;
12+ align-items: center;
13+ gap: 5px;
14+}
15+
16+.secretKeyManagerSubtitle {
17+ display: flex;
18+ flex-direction: row;
19+ justify-content: space-between;
20+ align-items: center;
21+ width: 100%;
22+}
23+
24+.secretKeyManagerInfo {
25+ display: flex;
26+ flex-direction: column;
27+ align-items: baseline;
28+ flex: 1;
29+ font-size: 0.95em;
30+}
31+
32+.secretKeyManagerList {
33+ display: flex;
34+ flex-direction: column;
35+ text-align: left;
36+ gap: 5px;
37+ flex: 1;
38+ overflow-y: auto;
39+ height: 100%;
40+}
41+
42+.secretKeyManagerItem {
43+ border: 1px solid var(--SmartThemeBorderColor);
44+ padding: 5px 10px;
45+ border-radius: 10px;
46+ background-color: var(--black30a);
47+ display: flex;
48+ flex-direction: row;
49+ align-items: center;
50+}
51+
52+.secretKeyManagerItem.active {
53+ background-color: var(--cobalt30a);
54+}
55+
56+.secretKeyManagerItemInfo {
57+ display: flex;
58+ flex-direction: column;
59+ flex: 1;
60+ gap: 5px;
61+}
62+
63+.secretKeyManagerItemSubtitle,
64+.secretKeyManagerItemHeader {
65+ word-break: break-all;
66+}
67+
68+.secretKeyManagerItemId {
69+ text-decoration: underline;
70+ text-decoration-style: dotted;
71+ cursor: pointer;
72+}
73+
74+.secretKeyManagerItemActions {
75+ display: flex;
76+ flex-direction: column;
77+ gap: 5px;
78+}
79+
80+.secretKeyManagerItemActionsRow {
81+ display: flex;
82+ flex-direction: row;
83+ gap: 5px;
84+}
85+
86+.secretKeyManagerItemActionsRow>button {
87+ margin: 0;
88+}
89+
90+.secretKeyManagerList:empty {
91+ display: none;
92+}
93+
94+.secretKeyManagerListEmpty {
95+ display: flex;
96+ justify-content: center;
97+ align-items: center;
98+ width: 100%;
99+ opacity: 0.8;
100+ font-weight: bold;
101+ font-size: 1.05em;
102+}
public/index.html+111 -113
@@ -2242,10 +2242,11 @@
22422242 </small>
22432243 <div class="flex-container">
22442244 <input id="horde_api_key" name="horde_api_key" class="text_pole flex1" type="text" placeholder="0000000000" autocomplete="off">
22452245 <div title="Clear yourSave APIand keyconnect" data-i18n="[title]Clear yourSave APIand keyconnect" class="menu_button fa-solid fa-circle-xmarkplug clear-apifa-keyfw" data-keyid="api_key_hordehorde_api_key_button"></div>
2246+ <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_horde"></div>
22462247 </div>
22472248 <div data-for="horde_api_key" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
22482249 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
22492250 </div>
22502251 <h4 class="horde_model_title">
22512252 <span data-i18n="Models">Models </span>
@@ -2297,11 +2298,11 @@
22972298 </span>
22982299 <div class="flex-container">
22992300 <input id="api_key_novel" name="api_key_novel" class="text_pole flex1 wide100p" type="text" autocomplete="off">
23002301 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_novel">
23012302 </div>
23022303 </div>
23032304 <div data-for="api_key_novel" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
23042305 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
23052306 </div>
23062307 <h4><span data-i18n="Novel AI Model">Novel AI Model</span>
23072308 <a href="https://docs.sillytavern.app/usage/api-connections/novelai/#models" class="notes-link" target="_blank">
@@ -2349,10 +2350,10 @@
23492350 <h4 data-i18n="TogetherAI API Key">TogetherAI API Key</h4>
23502351 <div class="flex-container">
23512352 <input id="api_key_togetherai" name="api_key_togetherai" class="text_pole flex1" value="" type="text" autocomplete="off">
23522353 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_togetherai"></div>
23532354 </div>
23542355 <div data-for="api_key_togetherai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
23552356 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
23562357 </div>
23572358 <div>
23582359 <h4 data-i18n="TogetherAI Model">TogetherAI Model</h4>
@@ -2373,10 +2374,10 @@
23732374 </div>
23742375 <div class="flex-container">
23752376 <input id="api_key_openrouter-tg" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">
23762377 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_openrouter"></div>
23772378 </div>
23782379 <div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
23792380 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
23802381 </div>
23812382 <div>
23822383 <h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>
@@ -2400,10 +2401,10 @@
24002401 <h4 data-i18n="InfermaticAI API Key">InfermaticAI API Key</h4>
24012402 <div class="flex-container">
24022403 <input id="api_key_infermaticai" name="api_key_infermaticai" class="text_pole flex1" value="" type="text" autocomplete="off">
24032404 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_infermaticai"></div>
24042405 </div>
24052406 <div data-for="api_key_infermaticai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
24062407 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
24072408 </div>
24082409 <div>
24092410 <h4 data-i18n="InfermaticAI Model">InfermaticAI Model</h4>
@@ -2423,10 +2424,10 @@
24232424 </h4>
24242425 <div class="flex-container">
24252426 <input id="api_key_dreamgen" name="api_key_dreamgen" class="text_pole flex1" value="" type="text" autocomplete="off">
24262427 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_dreamgen"></div>
24272428 </div>
24282429 <div data-for="api_key_dreamgen" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
24292430 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
24302431 </div>
24312432 <div>
24322433 <h4 data-i18n="DreamGen Model">DreamGen Model</h4>
@@ -2448,11 +2449,11 @@
24482449 </h4>
24492450 <div class="flex-container">
24502451 <input id="api_key_mancer" name="api_key_mancer" class="text_pole flex1 wide100p" type="text" autocomplete="off">
24512452 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_mancer">
24522453 </div>
24532454 </div>
24542455 <div data-for="api_key_mancer" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
24552456 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
24562457 </div>
24572458 <div class="flex1">
24582459 <h4 data-i18n="Mancer Model">Mancer Model</h4>
@@ -2467,11 +2468,11 @@
24672468 <h4 data-i18n="API key (optional)">API key (optional)</h4>
24682469 <div class="flex-container">
24692470 <input id="api_key_generic" name="api_key_generic" class="text_pole flex1 wide100p" type="text" autocomplete="off">
24702471 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_generic">
24712472 </div>
24722473 </div>
24732474 <div data-for="api_key_generic" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
24742475 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
24752476 </div>
24762477 <div class="flex1">
24772478 <h4 data-i18n="Server url">Server URL</h4>
@@ -2496,11 +2497,11 @@
24962497 <h4 data-i18n="API key (optional)">API key (optional)</h4>
24972498 <div class="flex-container">
24982499 <input id="api_key_ooba" name="api_key_ooba" class="text_pole flex1 wide100p" type="text" autocomplete="off">
24992500 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_ooba">
25002501 </div>
25012502 </div>
25022503 <div data-for="api_key_ooba" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
25032504 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
25042505 </div>
25052506 <div class="flex1">
25062507 <h4 data-i18n="Server url">Server URL</h4>
@@ -2518,11 +2519,11 @@
25182519 <h4 data-i18n="API key (optional)">API key</h4>
25192520 <div class="flex-container">
25202521 <input id="api_key_featherless" name="api_key_featherless" class="text_pole flex1 wide100p" type="text" autocomplete="off">
25212522 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_featherless">
25222523 </div>
25232524 </div>
25242525 <div data-for="api_key_featherless" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
25252526 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
25262527 </div>
25272528 <hr>
25282529 <h4 data-i18n="Featherless Model Selection">Featherless Model Selection</h4>
@@ -2569,11 +2570,11 @@
25692570 <h4 data-i18n="vLLM API key">vLLM API key</h4>
25702571 <div class="flex-container">
25712572 <input id="api_key_vllm" name="api_key_vllm" class="text_pole flex1 wide100p" type="text" autocomplete="off">
25722573 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_vllm">
25732574 </div>
25742575 </div>
25752576 <div data-for="api_key_vllm" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
25762577 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
25772578 </div>
25782579 <div class="flex1">
25792580 <h4 data-i18n="API url">API URL</h4>
@@ -2593,11 +2594,11 @@
25932594 <h4 data-i18n="HuggingFace Token">HuggingFace Token</h4>
25942595 <div class="flex-container">
25952596 <input id="api_key_huggingface" name="api_key_huggingface" class="text_pole flex1 wide100p" type="text" autocomplete="off">
25962597 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_huggingface">
25972598 </div>
25982599 </div>
25992600 <div data-for="api_key_huggingface" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
26002601 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
26012602 </div>
26022603 <div class="flex1">
26032604 <h4 data-i18n="Endpoint URL">Endpoint URL</h4>
@@ -2615,11 +2616,11 @@
26152616 <h4 data-i18n="Aphrodite API key">Aphrodite API key</h4>
26162617 <div class="flex-container">
26172618 <input id="api_key_aphrodite" name="api_key_aphrodite" class="text_pole flex1 wide100p" type="text" autocomplete="off">
26182619 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_aphrodite">
26192620 </div>
26202621 </div>
26212622 <div data-for="api_key_aphrodite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
26222623 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
26232624 </div>
26242625 <div class="flex1">
26252626 <h4 data-i18n="API url">API URL</h4>
@@ -2644,11 +2645,11 @@
26442645 <h4 data-i18n="API key (optional)">API key (optional)</h4>
26452646 <div class="flex-container">
26462647 <input id="api_key_llamacpp" name="api_key_llamacpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">
26472648 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_llamacpp">
26482649 </div>
26492650 </div>
26502651 <div data-for="api_key_llamacpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
26512652 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
26522653 </div>
26532654 <div class="flex1">
26542655 <h4 data-i18n="API url">API URL</h4>
@@ -2691,11 +2692,11 @@
26912692 <h4 data-i18n="Tabby API key">Tabby API key</h4>
26922693 <div class="flex-container">
26932694 <input id="api_key_tabby" name="api_key_tabby" class="text_pole flex1 wide100p" type="text" autocomplete="off">
26942695 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_tabby">
26952696 </div>
26962697 </div>
26972698 <div data-for="api_key_tabby" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
26982699 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
26992700 </div>
27002701 <div class="flex1">
27012702 <h4 data-i18n="API url">API URL</h4>
@@ -2743,11 +2744,11 @@
27432744 <h4 data-i18n="koboldcpp API key (optional)">koboldcpp API key (optional)</h4>
27442745 <div class="flex-container">
27452746 <input id="api_key_koboldcpp" name="api_key_koboldcpp" class="text_pole flex1 wide100p" type="text" autocomplete="off">
27462747 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_koboldcpp">
27472748 </div>
27482749 </div>
27492750 <div data-for="api_key_koboldcpp" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
27502751 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
27512752 </div>
27522753 <div class="flex1">
27532754 <h4 data-i18n="API url">API URL</h4>
@@ -2905,15 +2906,15 @@
29052906 </span>
29062907 <div class="flex-container">
29072908 <input id="api_key_openai" name="api_key_openai" class="text_pole flex1" value="" type="text" autocomplete="off">
29082909 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_openai"></div>
29092910 </div>
29102911 <div id="ReverseProxyWarningMessage2" class="reverse_proxy_warning">
29112912 <b data-i18n="Use Proxy password field instead. This input will be ignored.">
29122913 Use "Proxy password" field instead. This input will be ignored.
29132914 </b>
29142915 </div>
29152916 <div data-for="api_key_openai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
29162917 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
29172918 </div>
29182919 <div>
29192920 <h4 data-i18n="OpenAI Model">OpenAI Model</h4>
@@ -2999,10 +3000,10 @@
29993000 </div>
30003001 <div class="flex-container">
30013002 <input id="api_key_claude" name="api_key_claude" class="text_pole flex1" value="" type="text" autocomplete="off">
30023003 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_claude"></div>
30033004 </div>
30043005 <div data-for="api_key_claude" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
30053006 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
30063007 </div>
30073008 <div>
30083009 <h4 data-i18n="Claude Model">Claude Model</h4>
@@ -3058,10 +3059,10 @@
30583059 </div>
30593060 <div class="flex-container">
30603061 <input id="api_key_openrouter" name="api_key_openrouter" class="text_pole flex1 api_key_openrouter" value="" type="text" autocomplete="off">
30613062 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_openrouter"></div>
30623063 </div>
30633064 <div data-for="api_key_openrouter" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
30643065 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
30653066 </div>
30663067 <div>
30673068 <h4 data-i18n="OpenRouter Model">OpenRouter Model</h4>
@@ -3122,10 +3123,10 @@
31223123 <h4 data-i18n="Scale API Key">Scale API Key</h4>
31233124 <div class="flex-container">
31243125 <input id="api_key_scale" name="api_key_scale" class="text_pole flex1" value="" autocomplete="off">
31253126 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_scale"></div>
31263127 </div>
31273128 <div data-for="api_key_scale" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
31283129 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
31293130 </div>
31303131 <h4>Scale API URL</h4>
31313132 <input id="api_url_scale" name="api_url_scale" class="text_pole" value="" autocomplete="off" placeholder="https://dashboard.scale.com/spellbook/api/v2/deploy/xxxxxxx">
@@ -3134,10 +3135,10 @@
31343135 <h4>Scale Cookie (_jwt)</h4>
31353136 <div class="flex-container">
31363137 <input id="scale_cookie" name="scale_cookie" class="text_pole flex1" value="" autocomplete="off">
31373138 <div title="Clear your cookie" data-i18n="[title]Clear your cookie" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="scale_cookie"></div>
31383139 </div>
31393140 <div data-for="scale_cookie" class="neutral_warning">
31403141 For privacy reasons, your cookie will be hidden after you reload theclick page'Connect'.
31413142 </div>
31423143 </div>
31433144 <!-- Its only purpose is to trigger max context size check -->
@@ -3151,10 +3152,10 @@
31513152 <h4 data-i18n="AI21 API Key">AI21 API Key</h4>
31523153 <div class="flex-container">
31533154 <input id="api_key_ai21" name="api_key_ai21" class="text_pole flex1" value="" type="text" autocomplete="off">
31543155 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_ai21"></div>
31553156 </div>
31563157 <div data-for="api_key_ai21" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
31573158 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
31583159 </div>
31593160 <div>
31603161 <h4 data-i18n="AI21 Model">AI21 Model</h4>
@@ -3181,10 +3182,10 @@
31813182 <h4 data-i18n="Google AI Studio API Key">Google AI Studio API Key</h4>
31823183 <div class="flex-container">
31833184 <input id="api_key_makersuite" name="api_key_makersuite" class="text_pole flex1" value="" type="text" autocomplete="off">
31843185 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_makersuite"></div>
31853186 </div>
31863187 <div data-for="api_key_makersuite" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
31873188 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
31883189 </div>
31893190 <div>
31903191 <h4 data-i18n="Google Model">Google Model</h4>
@@ -3264,10 +3265,10 @@
32643265 </h4>
32653266 <div class="flex-container">
32663267 <input id="api_key_vertexai" name="api_key_vertexai" class="text_pole flex1" value="" type="text" autocomplete="off">
32673268 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_vertexai"></div>
32683269 </div>
32693270 <div data-for="api_key_vertexai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
32703271 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
32713272 </div>
32723273 </div>
32733274
@@ -3301,23 +3302,15 @@
33013302 <div id="vertexai_service_account_status" class="info-block marginTopBot5" style="display: none;">
33023303 <span id="vertexai_service_account_info"></span>
33033304 </div>
3304- <textarea id="vertexai_service_account_json" class="text_pole textarea_compact" rows="4" placeholder='Paste your Service Account JSON content here, e.g.:
3305+ <div class="flex-container">
3305- {
3306+ <input id="vertexai_service_account_json" name="vertexai_service_account_json" class="text_pole flex1" value="" type="text" autocomplete="off">
3306- "type": "service_account",
3307+ <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="vertexai_service_account_json"></div>
3307- "project_id": "your-project-id",
3308+ </div>
3308- "private_key_id": "...",
3309+ <div data-for="vertexai_service_account_json" class="neutral_warning" data-i18n="For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.">
3309- "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
3310+ For privacy reasons, your Service Account JSON content will be hidden after you click 'Validate JSON'.
3310- "client_email": "...",
3311- "client_id": "...",
3312- "auth_uri": "https://accounts.google.com/o/oauth2/auth",
3313- "token_uri": "https://oauth2.googleapis.com/token"
3314- }'></textarea>
3315- <div data-for="vertexai_service_account_json" class="neutral_warning" data-i18n="For privacy reasons, your Service Account JSON content will be hidden after you reload the page.">
3316- For privacy reasons, your Service Account JSON content will be hidden after you reload the page.
33173311 </div>
33183312 <div class="flex-container">
33193313 <button type="button" id="vertexai_validate_service_account" class="menu_button menu_button_icon" data-i18n="Validate JSON">Validate JSON</button>
3320- <button type="button" id="vertexai_clear_service_account" class="menu_button menu_button_icon" data-i18n="Clear">Clear</button>
33213314 </div>
33223315 </div>
33233316 </div>
@@ -3359,10 +3352,10 @@
33593352 <h4 data-i18n="MistralAI API Key">MistralAI API Key</h4>
33603353 <div class="flex-container">
33613354 <input id="api_key_mistralai" name="api_key_mistralai" class="text_pole flex1" value="" type="text" autocomplete="off">
33623355 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_mistralai"></div>
33633356 </div>
33643357 <div data-for="api_key_mistralai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
33653358 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
33663359 </div>
33673360 <div>
33683361 <h4 data-i18n="MistralAI Model">MistralAI Model</h4>
@@ -3423,10 +3416,10 @@
34233416 <h4 data-i18n="Groq API Key">Groq API Key</h4>
34243417 <div class="flex-container">
34253418 <input id="api_key_groq" name="api_key_groq" class="text_pole flex1" value="" type="text" autocomplete="off">
34263419 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_groq"></div>
34273420 </div>
34283421 <div data-for="api_key_groq" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
34293422 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
34303423 </div>
34313424 <h4 data-i18n="Groq Model">Groq Model</h4>
34323425 <select id="model_groq_select">
@@ -3465,10 +3458,10 @@
34653458 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
34663459 <div class="flex-container">
34673460 <input id="api_key_nanogpt" name="api_key_nanogpt" class="text_pole flex1" value="" type="text" autocomplete="off">
34683461 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_nanogpt"></div>
34693462 </div>
34703463 <div data-for="api_key_nanogpt" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
34713464 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
34723465 </div>
34733466 <div>
34743467 <h4 data-i18n="NanoGPT Model">NanoGPT Model</h4>
@@ -3481,10 +3474,10 @@
34813474 <h4 data-i18n="DeepSeek API Key">DeepSeek API Key</h4>
34823475 <div class="flex-container">
34833476 <input id="api_key_deepseek" name="api_key_deepseek" class="text_pole flex1" value="" type="text" autocomplete="off">
34843477 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_deepseek"></div>
34853478 </div>
34863479 <div data-for="api_key_deepseek" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
34873480 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
34883481 </div>
34893482 <div>
34903483 <h4 data-i18n="DeepSeek Model">DeepSeek Model</h4>
@@ -3499,10 +3492,10 @@
34993492 <h4 data-i18n="Perplexity API Key">Perplexity API Key</h4>
35003493 <div class="flex-container">
35013494 <input id="api_key_perplexity" name="api_key_perplexity" class="text_pole flex1" value="" type="text" autocomplete="off">
35023495 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_perplexity"></div>
35033496 </div>
35043497 <div data-for="api_key_perplexity" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
35053498 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
35063499 </div>
35073500 <h4 data-i18n="Perplexity Model">Perplexity Model</h4>
35083501 <select id="model_perplexity_select">
@@ -3530,10 +3523,10 @@
35303523 <h4 data-i18n="Cohere API Key">Cohere API Key</h4>
35313524 <div class="flex-container">
35323525 <input id="api_key_cohere" name="api_key_cohere" class="text_pole flex1" value="" type="text" autocomplete="off">
35333526 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_cohere"></div>
35343527 </div>
35353528 <div data-for="api_key_cohere" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
35363529 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
35373530 </div>
35383531 <div>
35393532 <h4 data-i18n="Cohere Model">Cohere Model</h4>
@@ -3578,10 +3571,10 @@
35783571 </h4>
35793572 <div class="flex-container">
35803573 <input id="api_key_custom" name="api_key_custom" class="text_pole flex1" value="" type="text" autocomplete="off">
35813574 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_custom"></div>
35823575 </div>
35833576 <div data-for="api_key_custom" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
35843577 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
35853578 </div>
35863579 <h4 data-i18n="Enter a Model ID">Enter a Model ID</h4>
35873580 <div class="flex-container">
@@ -3601,10 +3594,10 @@
36013594 </h4>
36023595 <div class="flex-container">
36033596 <input id="api_key_01ai" name="api_key_01ai" class="text_pole flex1" value="" type="text" autocomplete="off">
36043597 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_01ai"></div>
36053598 </div>
36063599 <div data-for="api_key_01ai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
36073600 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
36083601 </div>
36093602 <h4 data-i18n="01.AI Model">01.AI Model</h4>
36103603 <select id="model_01ai_select">
@@ -3618,10 +3611,10 @@
36183611 </h4>
36193612 <div class="flex-container">
36203613 <input id="api_key_xai" name="api_key_xai" class="text_pole flex1" value="" type="text" autocomplete="off">
36213614 <div title="Clear yourManage API keykeys" data-i18n="[title]Clear yourManage API keykeys" class="menu_button fa-solid fa-circlekey fa-xmarkfw clearmanage-api-keykeys" data-key="api_key_xai"></div>
36223615 </div>
36233616 <div data-for="api_key_xai" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.">
36243617 For privacy reasons, your API key will be hidden after you reload theclick page'Connect'.
36253618 </div>
36263619 <h4 data-i18n="xAI Model">xAI Model</h4>
36273620 <select id="model_xai_select">
@@ -3687,7 +3680,9 @@
36873680 <label for="auto-connect-checkbox" class="checkbox_label"><input id="auto-connect-checkbox" type="checkbox" />
36883681 <span data-i18n="Auto-connect to Last Server">Auto-connect to Last Server</span>
36893682 </label>
36903683 <a id="viewSecrets" href="javascript:void(0);" data-i18n="[missing_key_text]Missing key;[key_saved_text]Key saved" missing_key_text="❌ Missing key" key_saved_text="✔️ Key saved"><span data-i18n="View hidden API keys">View hidden API keys</span></a>
3684+ <span data-i18n="View hidden API keys">View hidden API keys</span>
3685+ </a>
36913686 </div>
36923687 </div>
36933688 </div>
@@ -7471,6 +7466,9 @@
74717466 </div>
74727467 </div>
74737468
7469+ <!-- Area for hidden data lists for secrets autosuggests -->
7470+ <div id="secrets_datalists" class="displayNone"></div>
7471+
74747472 <!-- Script includes -->
74757473 <script src="lib/polyfill.js"></script>
74767474 <script src="lib/jquery-3.5.1.min.js"></script>
public/script.js+2 -0
@@ -208,6 +208,7 @@ import {
208208} from './scripts/tags.js';
209209import {
210210 SECRET_KEYS,
211+ initSecrets,
211212 readSecretState,
212213 secret_state,
213214 writeSecret,
@@ -975,6 +976,7 @@ async function firstLoadInit() {
975976 reloadMarkdownProcessor();
976977 applyBrowserFixes();
977978 await getClientVersion();
979+ await initSecrets();
978980 await readSecretState();
979981 await initLocales();
980982 initChatUtilities();
public/scripts/extensions/connection-manager/index.js+13 -0
@@ -13,6 +13,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
1313import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
1414import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4 } from '../../utils.js';
1515import { t } from '../../i18n.js';
16+import { getSecretLabelById } from '../../secrets.js';
1617
1718const MODULE_NAME = 'connection-manager';
1819const NONE = '<None>';
@@ -41,6 +42,7 @@ const CC_COMMANDS = [
4142 'start-reply-with',
4243 'reasoning-template',
4344 'prompt-post-processing',
45+ 'secret-id',
4446];
4547
4648const TC_COMMANDS = [
@@ -57,6 +59,7 @@ const TC_COMMANDS = [
5759 'stop-strings',
5860 'start-reply-with',
5961 'reasoning-template',
62+ 'secret-id',
6063];
6164
6265const FANCY_NAMES = {
@@ -75,6 +78,7 @@ const FANCY_NAMES = {
7578 'start-reply-with': 'Start Reply With',
7679 'reasoning-template': 'Reasoning Template',
7780 'prompt-post-processing': 'Prompt Post-Processing',
81+ 'secret-id': 'Secret',
7882};
7983
8084/**
@@ -344,6 +348,15 @@ function makeFancyProfile(profile) {
344348 return acc;
345349 }
346350
351+ // UUID is not very useful in the UI, so we replace it with a label (if available)
352+ if (key === 'secret-id') {
353+ const label = getSecretLabelById(profile[key]);
354+ if (label) {
355+ acc[value] = label;
356+ return acc;
357+ }
358+ }
359+
347360 acc[value] = profile[key];
348361 return acc;
349362 }, {});
public/scripts/horde.js+8 -3
@@ -397,7 +397,8 @@ function getHordeModelTemplate(option) {
397397export function initHorde() {
398398 $('#horde_model').on('mousedown change', async function (e) {
399399 console.log('Horde model change', e);
400400 horde_settings.modelsconst modelValue = $('#horde_model').val();
401+ horde_settings.models = Array.isArray(modelValue) ? modelValue : [];
401402 console.log('Updated Horde models', horde_settings.models);
402403
403404 // Try select instruct preset
@@ -429,8 +430,12 @@ export function initHorde () {
429430 saveSettingsDebounced();
430431 });
431432
432433 $('#horde_api_keyhorde_api_key_button').on('inputclick', async function () {
433434 const key = String($(this'#horde_api_key').val()).trim();
435+ if (!key) {
436+ toastr.warning(t`Please enter your Horde API key`);
437+ return;
438+ }
434439 await writeSecret(SECRET_KEYS.HORDE, key);
435440 });
436441
public/scripts/openai.js+8 -1
@@ -56,6 +56,7 @@ import {
5656 getSortableDelay,
5757 getStringHash,
5858 isDataURL,
59+ isUuid,
5960 isValidUrl,
6061 parseJsonFile,
6162 resetScrollHeight,
@@ -5670,7 +5671,8 @@ async function onVertexAIValidateServiceAccount() {
56705671 }
56715672
56725673 // Save to backend secret storage
5673- await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent);
5674+ const keyLabel = serviceAccount['client_email'] || '';
5675+ await writeSecret(SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT, jsonContent, keyLabel);
56745676
56755677 // Show success status
56765678 updateVertexAIServiceAccountStatus(true, `Project: ${serviceAccount.project_id}, Email: ${serviceAccount.client_email}`);
@@ -5704,6 +5706,11 @@ async function onVertexAIClearServiceAccount() {
57045706function onVertexAIServiceAccountJsonChange() {
57055707 const jsonContent = String($(this).val()).trim();
57065708
5709+ // Autocomplete has been triggered, don't validate if the input is a UUID
5710+ if (isUuid(jsonContent)) {
5711+ return;
5712+ }
5713+
57075714 if (jsonContent) {
57085715 // Auto-validate when content is pasted
57095716 try {
public/scripts/popup.js+9 -1
@@ -53,6 +53,7 @@ export const POPUP_RESULT = {
5353 * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
5454 * @property {(popup: Popup) => Promise<boolean?>|boolean?} [onClosing=null] - Handler called before the popup closes, return `false` to cancel the close
5555 * @property {(popup: Popup) => Promise<void?>|void?} [onClose=null] - Handler called after the popup closes, but before the DOM is cleaned up
56+ * @property {(popup: Popup) => Promise<void?>|void?} [onOpen=null] - Handler called after the popup opens
5657 * @property {number?} [cropAspect=null] - Aspect ratio for the crop popup
5758 * @property {string?} [cropImage=null] - Image URL to display in the crop popup
5859 */
@@ -155,6 +156,7 @@ export class Popup {
155156
156157 /** @type {(popup: Popup) => Promise<boolean?>|boolean?} */ onClosing;
157158 /** @type {(popup: Popup) => Promise<void?>|void?} */ onClose;
159+ /** @type {(popup: Popup) => Promise<void?>|void?} */ onOpen;
158160
159161 /** @type {POPUP_RESULT|number} */ result;
160162 /** @type {any} */ value;
@@ -175,7 +177,7 @@ export class Popup {
175177 * @param {string} [inputValue=''] - The initial value of the input field
176178 * @param {PopupOptions} [options={}] - Additional options for the popup
177179 */
178180 constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, onOpen = null, cropAspect = null, cropImage = null } = {}) {
179181 Popup.util.popups.push(this);
180182
181183 // Make this popup uniquely identifiable
@@ -185,6 +187,7 @@ export class Popup {
185187 // Utilize event handlers being passed in
186188 this.onClosing = onClosing;
187189 this.onClose = onClose;
190+ this.onOpen = onOpen;
188191
189192 /**@type {HTMLTemplateElement}*/
190193 const template = document.querySelector('#popup_template');
@@ -478,6 +481,11 @@ export class Popup {
478481
479482 runAfterAnimation(this.dlg, () => {
480483 this.dlg.removeAttribute('opening');
484+
485+ // If we have an onOpen handler, we run it now
486+ if (this.onOpen) {
487+ this.onOpen(this);
488+ }
481489 });
482490
483491 this.#promise = new Promise((resolve) => {
public/scripts/secrets.js+807 -34
@@ -1,7 +1,18 @@
11import { DOMPurify, moment } from '../lib.js';
22import { getRequestHeaders } from '../script.js';
33import { t } from './i18n.js';
4+import { chat_completion_sources } from './openai.js';
45import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
6+import { SlashCommand } from './slash-commands/SlashCommand.js';
7+import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
8+import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
9+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
10+import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js';
11+import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
12+import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
13+import { renderTemplateAsync } from './templates.js';
14+import { textgen_types } from './textgen-settings.js';
15+import { copyText, isTrueBoolean } from './utils.js';
516
617export const SECRET_KEYS = {
718 HORDE: 'api_key_horde',
@@ -48,6 +59,51 @@ export const SECRET_KEYS = {
4859 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
4960};
5061
62+const FRIENDLY_NAMES = {
63+ [SECRET_KEYS.HORDE]: 'AI Horde',
64+ [SECRET_KEYS.MANCER]: 'Mancer',
65+ [SECRET_KEYS.OPENAI]: 'OpenAI',
66+ [SECRET_KEYS.NOVEL]: 'NovelAI',
67+ [SECRET_KEYS.CLAUDE]: 'Claude',
68+ [SECRET_KEYS.OPENROUTER]: 'OpenRouter',
69+ [SECRET_KEYS.SCALE]: 'Scale',
70+ [SECRET_KEYS.AI21]: 'AI21',
71+ [SECRET_KEYS.SCALE_COOKIE]: 'Scale (Cookie)',
72+ [SECRET_KEYS.MAKERSUITE]: 'Google AI Studio',
73+ [SECRET_KEYS.VERTEXAI]: 'Google Vertex AI (Express Mode)',
74+ [SECRET_KEYS.VLLM]: 'vLLM',
75+ [SECRET_KEYS.APHRODITE]: 'Aphrodite',
76+ [SECRET_KEYS.TABBY]: 'TabbyAPI',
77+ [SECRET_KEYS.MISTRALAI]: 'MistralAI',
78+ [SECRET_KEYS.CUSTOM]: 'Custom (OpenAI-compatible)',
79+ [SECRET_KEYS.TOGETHERAI]: 'TogetherAI',
80+ [SECRET_KEYS.OOBA]: 'Text Generation WebUI',
81+ [SECRET_KEYS.INFERMATICAI]: 'InfermaticAI',
82+ [SECRET_KEYS.DREAMGEN]: 'DreamGen',
83+ [SECRET_KEYS.NOMICAI]: 'NomicAI',
84+ [SECRET_KEYS.KOBOLDCPP]: 'KoboldCpp',
85+ [SECRET_KEYS.LLAMACPP]: 'llama.cpp',
86+ [SECRET_KEYS.COHERE]: 'Cohere',
87+ [SECRET_KEYS.PERPLEXITY]: 'Perplexity',
88+ [SECRET_KEYS.GROQ]: 'Groq',
89+ [SECRET_KEYS.FEATHERLESS]: 'Featherless',
90+ [SECRET_KEYS.ZEROONEAI]: '01.AI',
91+ [SECRET_KEYS.HUGGINGFACE]: 'HuggingFace',
92+ [SECRET_KEYS.NANOGPT]: 'NanoGPT',
93+ [SECRET_KEYS.GENERIC]: 'Generic (OpenAI-compatible)',
94+ [SECRET_KEYS.DEEPSEEK]: 'DeepSeek',
95+ [SECRET_KEYS.XAI]: 'xAI (Grok)',
96+ [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: 'Google Vertex AI (Service Account)',
97+ [SECRET_KEYS.STABILITY]: 'Stability AI',
98+ [SECRET_KEYS.CUSTOM_OPENAI_TTS]: 'Custom OpenAI TTS',
99+ [SECRET_KEYS.TAVILY]: 'Tavily',
100+ [SECRET_KEYS.BFL]: 'Black Forest Labs',
101+ [SECRET_KEYS.SERPAPI]: 'SerpApi',
102+ [SECRET_KEYS.SERPER]: 'Serper',
103+ [SECRET_KEYS.FALAI]: 'FAL.AI',
104+ [SECRET_KEYS.AZURE_TTS]: 'Azure TTS',
105+};
106+
51107const INPUT_MAP = {
52108 [SECRET_KEYS.HORDE]: '#horde_api_key',
53109 [SECRET_KEYS.MANCER]: '#api_key_mancer',
@@ -85,29 +141,101 @@ const INPUT_MAP = {
85141 [SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT]: '#vertexai_service_account_json',
86142};
87143
88-const STATIC_PLACEHOLDER_KEYS = [
144+const getLabel = () => moment().format('L LT');
89- SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT,
90-];
91145
92-async function clearSecret() {
146+/**
93- const key = $(this).data('key');
147+ * Resolves the secret key based on the selected API, chat completion source, and text completion type.
94- await writeSecret(key, '');
148+ * @returns {string|null} The secret key corresponding to the selected API, or null if no key is found.
95- secret_state[key] = false;
149+ */
96- updateSecretDisplay();
150+function resolveSecretKey() {
97- $(INPUT_MAP[key]).val('').trigger('input');
151+ const { mainApi, chatCompletionSettings, textCompletionSettings } = SillyTavern.getContext();
98- $('#main_api').trigger('change');
152+ const chatCompletionSource = chatCompletionSettings.chat_completion_source;
153+ const textCompletionType = textCompletionSettings.type;
154+
155+ if (mainApi === 'koboldhorde') {
156+ return SECRET_KEYS.HORDE;
99157 }
100158
101-export function updateSecretDisplay() {
159+ if (mainApi === 'novel') {
102- for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
160+ return SECRET_KEYS.NOVEL;
103- if (STATIC_PLACEHOLDER_KEYS.includes(secret_key)) {
161+ }
162+
163+ if (mainApi === 'textgenerationwebui') {
164+ const [key] = Object.entries(textgen_types).find(([, value]) => value === textCompletionType) ?? [null];
165+ if (key && SECRET_KEYS[key]) {
166+ return SECRET_KEYS[key];
167+ }
168+ }
169+
170+ if (mainApi === 'openai') {
171+ if (chatCompletionSource === chat_completion_sources.VERTEXAI) {
172+ switch (chatCompletionSettings.vertexai_auth_mode) {
173+ case 'express':
174+ return SECRET_KEYS.VERTEXAI;
175+ case 'full':
176+ return SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT;
177+ }
178+ }
179+
180+ if (chatCompletionSource === chat_completion_sources.SCALE) {
181+ return chatCompletionSettings.use_alt_scale
182+ ? SECRET_KEYS.SCALE_COOKIE
183+ : SECRET_KEYS.SCALE;
184+ }
185+
186+ const [key] = Object.entries(chat_completion_sources).find(([, value]) => value === chatCompletionSource) ?? [null];
187+ if (key && SECRET_KEYS[key]) {
188+ return SECRET_KEYS[key];
189+ }
190+ }
191+
192+ return null;
193+}
194+
195+/**
196+ * Gets the label of a secret by its ID.
197+ * @param {string} id The ID of the secret to find.
198+ * @returns {string} The label of the secret with the given ID, or an empty string if not found.
199+ */
200+export function getSecretLabelById(id) {
201+ for (const key of Object.values(SECRET_KEYS)) {
202+ const secrets = secret_state[key];
203+ if (!Array.isArray(secrets)) {
104204 continue;
105205 }
106- const validSecret = !!secret_state[secret_key];
206+ const secret = secrets.find(s => s.id === id);
207+ if (secret) {
208+ return `${secret.label} (${secret.value})`;
209+ }
210+ }
211+ return '';
212+}
107213
214+export function updateSecretDisplay() {
215+ for (const [secret_key, input_selector] of Object.entries(INPUT_MAP)) {
216+ const validSecret = !!secret_state[secret_key];
108217 const placeholder = $('#viewSecrets').attr(validSecret ? 'key_saved_text' : 'missing_key_text');
109- $(input_selector).attr('placeholder', placeholder);
218+ const label = getActiveSecretLabel(secret_key);
219+ const placeholderWithLabel = label ? `${placeholder} (${label})` : placeholder;
220+ $(input_selector).attr('placeholder', placeholderWithLabel);
221+ }
222+}
223+
224+/**
225+ * Gets the active secret label for a given key.
226+ * @param {string} key Gets the active secret label for a given key.
227+ * @returns {string} The label of the active secret, or '[No label]' if none is active.
228+ */
229+function getActiveSecretLabel(key) {
230+ const selectedSecret = secret_state[key];
231+ if (Array.isArray(selectedSecret)) {
232+ const activeSecret = selectedSecret.find(x => x.active);
233+ if (!activeSecret) {
234+ return '';
235+ }
236+ return activeSecret.label || activeSecret.value || t`[No label]`;
110237 }
238+ return '';
111239}
112240
113241async function viewSecrets() {
@@ -125,7 +253,6 @@ async function viewSecrets() {
125253 return;
126254 }
127255
128- $('#dialogue_popup').addClass('wide_dialogue_popup');
129256 const data = await response.json();
130257 const table = document.createElement('table');
131258 table.classList.add('responsiveTable');
@@ -138,29 +265,78 @@ async function viewSecrets() {
138265 await callGenericPopup(table.outerHTML, POPUP_TYPE.TEXT, '', { wide: true, large: true, allowVerticalScrolling: true });
139266}
140267
268+/**
269+ * @type {import('../../src/endpoints/secrets.js').SecretStateMap}
270+ */
141271export let secret_state = {};
142272
143-export async function writeSecret(key, value) {
273+/**
274+ * Write a secret value to the server.
275+ * @param {string} key Secret key
276+ * @param {string} value Secret value to write
277+ * @param {string} [label] (Optional) Label for the key. If not provided, generated automatically.
278+ * @return {Promise<string?>} The ID of the newly created secret key, or null if no value is provided.
279+ */
280+export async function writeSecret(key, value, label) {
144281 try {
282+ if (!value) {
283+ console.warn(`No value provided for ${key} in writeSecret, redirecting to deleteSecret`);
284+ await deleteSecret(key);
285+ return null;
286+ }
287+
288+ if (!label) {
289+ label = getLabel();
290+ }
291+
145292 const response = await fetch('/api/secrets/write', {
146293 method: 'POST',
147294 headers: getRequestHeaders(),
148295 body: JSON.stringify({ key, value, label }),
149296 });
150297
151298 if (!response.ok) {
152- const text = await response.text();
299+ return null;
300+ }
153301
154- if (text == 'ok') {
302+ const { id } = await response.json();
155- secret_state[key] = !!value;
303+ // Clear the input field
156- updateSecretDisplay();
304+ $(INPUT_MAP[key]).val('').trigger('input');
305+ await readSecretState();
306+ return id;
307+ } catch (error) {
308+ console.error(`Could not write secret value: ${key}`, error);
309+ return null;
157310 }
158311}
159- } catch {
312+
160- console.error('Could not write secret value: ', key);
313+/**
314+ * Deletes a secret value from the server.
315+ * @param {string} key Secret key
316+ * @param {string} [id] (Optional) ID of the secret key to delete. If not provided, deletes an active key.
317+ */
318+export async function deleteSecret(key, id) {
319+ try {
320+ const response = await fetch('/api/secrets/delete', {
321+ method: 'POST',
322+ headers: getRequestHeaders(),
323+ body: JSON.stringify({ key, id }),
324+ });
325+
326+ if (response.ok) {
327+ await readSecretState();
328+ // Force reconnection to the API with the new key
329+ $('#main_api').trigger('change');
330+ }
331+ } catch (error) {
332+ console.error(`Could not delete secret value: ${key}`, error);
161333 }
162334}
163335
336+/**
337+ * Reads the current state of secrets from the server.
338+ * @returns {Promise<void>}
339+ */
164340export async function readSecretState() {
165341 try {
166342 const response = await fetch('/api/secrets/read', {
@@ -171,6 +347,7 @@ export async function readSecretState() {
171347 if (response.ok) {
172348 secret_state = await response.json();
173349 updateSecretDisplay();
350+ updateInputDataLists();
174351 await checkOpenRouterAuth();
175352 }
176353 } catch {
@@ -181,31 +358,87 @@ export async function readSecretState() {
181358/**
182359 * Finds a secret value by key.
183360 * @param {string} key Secret key
184- * @returns {Promise<string | undefined>} Secret value, or undefined if keys are not exposed
361+ * @param {string} [id] ID of the secret to find. If not provided, will return the active secret.
362+ * @returns {Promise<string?>} Secret value, or null if keys are not exposed
185363 */
186364export async function findSecret(key, id) {
187365 try {
188366 const response = await fetch('/api/secrets/find', {
189367 method: 'POST',
190368 headers: getRequestHeaders(),
191369 body: JSON.stringify({ key, id }),
192370 });
193371
194372 if (!response.ok) {
373+ return null;
374+ }
375+
195376 const data = await response.json();
196377 return data.value;
197- }
198378 } catch {
199379 console.error('Could not find secret value: ', key);
380+ return null;
381+ }
382+}
383+
384+/**
385+ * Changes the active value for a given secret key.
386+ * @param {string} key Secret key to rotate
387+ * @param {string} id ID of the secret to rotate
388+ */
389+export async function rotateSecret(key, id) {
390+ try {
391+ const response = await fetch('/api/secrets/rotate', {
392+ method: 'POST',
393+ headers: getRequestHeaders(),
394+ body: JSON.stringify({ key, id }),
395+ });
396+
397+ if (response.ok) {
398+ await readSecretState();
399+ // Force reconnection to the API with the new key
400+ $('#main_api').trigger('change');
401+ }
402+ } catch (error) {
403+ console.error(`Could not rotate secret value: ${key}`, error);
404+ }
405+}
406+
407+/**
408+ * Renames a secret value on the server.
409+ * @param {string} key Secret key to rename
410+ * @param {string} id ID of the secret to rename
411+ * @param {string} label Label to rename the secret to
412+ */
413+export async function renameSecret(key, id, label) {
414+ try {
415+ const response = await fetch('/api/secrets/rename', {
416+ method: 'POST',
417+ headers: getRequestHeaders(),
418+ body: JSON.stringify({ key, id, label }),
419+ });
420+
421+ if (response.ok) {
422+ await readSecretState();
423+ }
424+ } catch (error) {
425+ console.error(`Could not rename secret value: ${key}`, error);
200426 }
201427}
202428
429+/**
430+ * Redirects the user to authorize OpenRouter.
431+ */
203432function authorizeOpenRouter() {
204433 const redirectUrl = new URL('/callback/openrouter', window.location.origin);
205434 const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}`;
206435 location.href = openRouterUrl;
207436}
208437
438+/**
439+ * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.
440+ * @returns {Promise<void>}
441+ */
209442async function checkOpenRouterAuth() {
210443 const params = new URLSearchParams(location.search);
211444 const source = params.get('source');
@@ -245,14 +478,554 @@ async function checkOpenRouterAuth() {
245478 }
246479}
247480
248-jQuery(async () => {
481+/**
482+ * Updates the input data lists for secret keys for autocomplete functionality.
483+ */
484+function updateInputDataLists() {
485+ let container = document.getElementById('secrets_datalists');
486+ if (!container) {
487+ container = document.createElement('div');
488+ container.id = 'secrets_datalists';
489+ container.style.display = 'none';
490+ document.body.appendChild(container);
491+ }
492+
493+ for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
494+ const inputElements = document.querySelectorAll(inputSelector);
495+ if (inputElements.length === 0) {
496+ console.warn(`No input elements found for key: ${key}`);
497+ continue;
498+ }
499+
500+ const dataListId = `${key}_datalist`;
501+ let dataList = document.getElementById(dataListId);
502+ if (!dataList) {
503+ dataList = document.createElement('datalist');
504+ dataList.id = dataListId;
505+ container.appendChild(dataList);
506+ }
507+
508+ // Clear existing options
509+ dataList.innerHTML = '';
510+
511+ const secrets = secret_state[key];
512+ if (!Array.isArray(secrets)) {
513+ continue;
514+ }
515+
516+ for (const secret of secrets) {
517+ const option = document.createElement('option');
518+ option.value = secret.id;
519+ option.textContent = `${secret.label} (${secret.value})`;
520+ dataList.appendChild(option);
521+ }
522+
523+ // Set the input element to use the datalist
524+ inputElements.forEach(element => {
525+ element.setAttribute('list', dataListId);
526+ });
527+ }
528+}
529+
530+/**
531+ * Opens the key manager dialog for a specific key.
532+ * @param {string} key Key for which to open the key manager dialog.
533+ */
534+async function openKeyManagerDialog(key) {
535+ const name = FRIENDLY_NAMES[key] || key;
536+ const template = $(await renderTemplateAsync('secretKeyManager', { name, key }));
537+ template.find('button[data-action="add-secret"]').on('click', async function () {
538+ let label = '';
539+ const value = await Popup.show.input(t`Add Secret`, t`Enter the secret value:`, '', {
540+ customInputs: [{
541+ id: 'newSecretLabel',
542+ type: 'text',
543+ label: t`Enter a label for the secret (optional):`,
544+ }],
545+ onClose: popup => {
546+ if (popup.result) {
547+ label = popup.inputResults.get('newSecretLabel').toString().trim();
548+ }
549+ },
550+ });
551+ if (!value) {
552+ return;
553+ }
554+ await writeSecret(key, value, label);
555+ await renderSecretsList();
556+ });
557+
558+ await renderSecretsList();
559+ await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: true, large: true, onOpen: scrollToActive });
560+
561+ async function renderSecretsList() {
562+ const secrets = secret_state[key] ?? [];
563+ const list = template.find('.secretKeyManagerList');
564+ const previousScrollTop = list.scrollTop();
565+
566+ const emptyMessage = template.find('.secretKeyManagerListEmpty');
567+ emptyMessage.toggle(secrets.length === 0);
568+
569+ const itemBlocks = [];
570+ for (const secret of secrets) {
571+ const itemTemplate = $(await renderTemplateAsync('secretKeyManagerListItem', secret));
572+ itemTemplate.find('[data-action="copy-id"]').on('click', async function () {
573+ await copyText(secret.id);
574+ toastr.info(t`Secret ID copied to clipboard.`);
575+ });
576+ itemTemplate.find('button[data-action="rotate-secret"]').on('click', async function () {
577+ await rotateSecret(key, secret.id);
578+ await renderSecretsList();
579+ });
580+ itemTemplate.find('button[data-action="copy-secret"]').on('click', async function () {
581+ const secretValue = await findSecret(key, secret.id);
582+ if (secretValue === null) {
583+ toastr.error(t`The key exposure might be disabled by the server config.`, t`Failed to copy secret value`);
584+ return;
585+ }
586+ await copyText(secretValue);
587+ toastr.info(t`Secret value copied to clipboard.`);
588+ });
589+ itemTemplate.find('button[data-action="rename-secret"]').on('click', async function () {
590+ const label = await Popup.show.input(t`Rename Secret`, t`Enter new label for the secret:`, secret?.label || getLabel());
591+ if (!label) {
592+ return;
593+ }
594+ await renameSecret(key, secret.id, label);
595+ await renderSecretsList();
596+ });
597+ itemTemplate.find('button[data-action="delete-secret"]').on('click', async function () {
598+ const confirm = await Popup.show.confirm(t`Delete Secret: ${secret?.label}`, t`Are you sure you want to delete this secret? This action cannot be undone.`);
599+ if (!confirm) {
600+ return;
601+ }
602+ await deleteSecret(key, secret.id);
603+ await renderSecretsList();
604+ });
605+ itemBlocks.push(itemTemplate);
606+ }
607+
608+ list.empty().append(itemBlocks).scrollTop(previousScrollTop);
609+ }
610+
611+ function scrollToActive() {
612+ const list = template.find('.secretKeyManagerList');
613+ const activeKey = list.find('.active');
614+ if (activeKey.length > 0) {
615+ const activeKeyScrollTop = activeKey.position().top + list.scrollTop() - list.height() / 2;
616+ list.scrollTop(activeKeyScrollTop);
617+ }
618+ }
619+}
620+
621+function registerSecretSlashCommands() {
622+ const secretKeyEnumProvider = () => Object.values(SECRET_KEYS).map(key => new SlashCommandEnumValue(key, FRIENDLY_NAMES[key] || key, enumTypes.name, enumIcons.key));
623+ const secretIdEnumProvider = (/** @type {SlashCommandExecutor} */ executor, /** @type {SlashCommandScope} */ _scope) => {
624+ const key = executor?.namedArgumentList?.find(x => x.name === 'key')?.value?.toString() || resolveSecretKey();
625+ if (!key || !secret_state[key] || !Array.isArray(secret_state[key]) || secret_state[key].length === 0) {
626+ return [];
627+ }
628+
629+ return secret_state[key].map(secret => {
630+ return new SlashCommandEnumValue(secret.id, `${secret.label} (${secret.value})`, enumTypes.name, enumIcons.key);
631+ });
632+ };
633+
634+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
635+ name: 'secret-id',
636+ aliases: ['secret-rotate'],
637+ helpString: t`Sets the ID of a currently active secret key. Gets the ID of the secret key if no value is provided.`,
638+ returns: t`The ID of the secret key that is now active.`,
639+ namedArgumentList: [
640+ SlashCommandNamedArgument.fromProps({
641+ name: 'quiet',
642+ description: t`Suppress toast message notifications.`,
643+ isRequired: false,
644+ defaultValue: String(false),
645+ typeList: [ARGUMENT_TYPE.BOOLEAN],
646+ }),
647+ SlashCommandNamedArgument.fromProps({
648+ name: 'key',
649+ description: t`The key to get the secret ID for. If not provided, will use the currently active API secrets.`,
650+ isRequired: false,
651+ typeList: [ARGUMENT_TYPE.STRING],
652+ enumProvider: secretKeyEnumProvider,
653+ }),
654+ ],
655+ unnamedArgumentList: [
656+ SlashCommandArgument.fromProps({
657+ description: t`The ID or a label of the secret key to set as active. If not provided, will return the currently active secret ID.`,
658+ isRequired: true,
659+ typeList: [ARGUMENT_TYPE.STRING],
660+ enumProvider: secretIdEnumProvider,
661+ }),
662+ ],
663+ callback: async (args, value) => {
664+ const quiet = isTrueBoolean(args?.quiet?.toString());
665+ const id = value?.toString()?.trim();
666+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
667+
668+ if (!key) {
669+ if (!quiet) {
670+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
671+ }
672+ return '';
673+ }
674+
675+ const secrets = secret_state[key];
676+ if (!Array.isArray(secrets) || secrets.length === 0) {
677+ if (!quiet) {
678+ toastr.error(t`No saved secrets found for the key: ${key}`);
679+ }
680+ return '';
681+ }
682+
683+ if (!id) {
684+ const activeSecret = secrets.find(s => s.active);
685+ if (!activeSecret) {
686+ if (!quiet) {
687+ toastr.error(t`No active secret found for the key: ${key}`);
688+ }
689+ return '';
690+ }
691+ return activeSecret.id;
692+ }
693+
694+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id);
695+ if (!savedSecret) {
696+ if (!quiet) {
697+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
698+ }
699+ return '';
700+ }
701+
702+ // Set the secret as active
703+ await rotateSecret(key, savedSecret.id);
704+ if (!quiet) {
705+ toastr.success(t`Secret with ID: ${id} is now active for the key: ${key}`);
706+ }
707+
708+ return savedSecret.id;
709+ },
710+ }));
711+
712+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
713+ name: 'secret-delete',
714+ helpString: t`Deletes a secret key by ID.`,
715+ namedArgumentList: [
716+ SlashCommandNamedArgument.fromProps({
717+ name: 'quiet',
718+ description: t`Suppress toast message notifications.`,
719+ isRequired: false,
720+ defaultValue: String(false),
721+ typeList: [ARGUMENT_TYPE.BOOLEAN],
722+ }),
723+ SlashCommandNamedArgument.fromProps({
724+ name: 'key',
725+ description: t`The key to delete the secret from. If not provided, will use the currently active API secrets.`,
726+ isRequired: false,
727+ typeList: [ARGUMENT_TYPE.STRING],
728+ enumProvider: secretKeyEnumProvider,
729+ }),
730+ ],
731+ unnamedArgumentList: [
732+ SlashCommandArgument.fromProps({
733+ description: t`The ID or a label of the secret key to delete. If not provided, will delete the active secret.`,
734+ isRequired: true,
735+ typeList: [ARGUMENT_TYPE.STRING],
736+ enumProvider: secretIdEnumProvider,
737+ }),
738+ ],
739+ callback: async (args, value) => {
740+ const quiet = isTrueBoolean(args?.quiet?.toString());
741+ const id = value?.toString()?.trim();
742+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
743+
744+ if (!key) {
745+ if (!quiet) {
746+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
747+ }
748+ return '';
749+ }
750+
751+ const secrets = secret_state[key];
752+ if (!Array.isArray(secrets) || secrets.length === 0) {
753+ if (!quiet) {
754+ toastr.error(t`No saved secrets found for the key: ${key}`);
755+ }
756+ return '';
757+ }
758+
759+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
760+ if (!savedSecret) {
761+ if (!quiet) {
762+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
763+ }
764+ return '';
765+ }
766+
767+ // Delete the secret
768+ await deleteSecret(key, savedSecret.id);
769+ if (!quiet) {
770+ toastr.success(t`Secret with ID: ${id} has been deleted for the key: ${key}`);
771+ }
772+
773+ return savedSecret.id;
774+ },
775+ }));
776+
777+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
778+ name: 'secret-write',
779+ helpString: t`Writes a secret key with a value and an optional label.`,
780+ returns: t`The ID of the newly created secret key.`,
781+ namedArgumentList: [
782+ SlashCommandNamedArgument.fromProps({
783+ name: 'quiet',
784+ description: t`Suppress toast message notifications.`,
785+ isRequired: false,
786+ defaultValue: String(false),
787+ typeList: [ARGUMENT_TYPE.BOOLEAN],
788+ }),
789+ SlashCommandNamedArgument.fromProps({
790+ name: 'key',
791+ description: t`The key to write the secret to. If not provided, will use the currently active API secrets.`,
792+ isRequired: false,
793+ typeList: [ARGUMENT_TYPE.STRING],
794+ enumProvider: secretKeyEnumProvider,
795+ }),
796+ SlashCommandNamedArgument.fromProps({
797+ name: 'label',
798+ description: t`The label for the secret key. If not provided, will use the current date and time.`,
799+ isRequired: false,
800+ typeList: [ARGUMENT_TYPE.STRING],
801+ }),
802+ ],
803+ unnamedArgumentList: [
804+ SlashCommandArgument.fromProps({
805+ description: t`The value of the secret key to write.`,
806+ isRequired: true,
807+ typeList: [ARGUMENT_TYPE.STRING],
808+ }),
809+ ],
810+ callback: async (args, value) => {
811+ const quiet = isTrueBoolean(args?.quiet?.toString());
812+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
813+
814+ if (!key) {
815+ if (!quiet) {
816+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
817+ }
818+ return '';
819+ }
820+
821+ const secrets = secret_state[key];
822+ if (!Array.isArray(secrets) || secrets.length === 0) {
823+ if (!quiet) {
824+ toastr.error(t`No saved secrets found for the key: ${key}`);
825+ }
826+ return '';
827+ }
828+
829+ const valueStr = value?.toString()?.trim();
830+ if (!valueStr) {
831+ if (!quiet) {
832+ toastr.error(t`No value provided for the secret key: ${key}`);
833+ }
834+ return '';
835+ }
836+
837+ const label = args?.label?.toString()?.trim() || getLabel();
838+ const id = await writeSecret(key, valueStr, label);
839+
840+ if (!quiet) {
841+ toastr.success(t`Secret has been written for the key: ${key}`);
842+ }
843+
844+ return id || '';
845+ },
846+ }));
847+
848+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
849+ name: 'secret-rename',
850+ helpString: t`Renames a secret key by ID.`,
851+ namedArgumentList: [
852+ SlashCommandNamedArgument.fromProps({
853+ name: 'quiet',
854+ description: t`Suppress toast message notifications.`,
855+ isRequired: false,
856+ defaultValue: String(false),
857+ typeList: [ARGUMENT_TYPE.BOOLEAN],
858+ }),
859+ SlashCommandNamedArgument.fromProps({
860+ name: 'key',
861+ description: t`The key to rename the secret in. If not provided, will use the currently active API secrets.`,
862+ isRequired: false,
863+ typeList: [ARGUMENT_TYPE.STRING],
864+ enumProvider: secretKeyEnumProvider,
865+ }),
866+ SlashCommandNamedArgument.fromProps({
867+ name: 'id',
868+ description: t`The ID of the secret to rename. If not provided, will rename the active secret.`,
869+ isRequired: true,
870+ typeList: [ARGUMENT_TYPE.STRING],
871+ }),
872+ ],
873+ unnamedArgumentList: [
874+ SlashCommandArgument.fromProps({
875+ description: t`The new label for the secret key.`,
876+ isRequired: true,
877+ typeList: [ARGUMENT_TYPE.STRING],
878+ }),
879+ ],
880+ callback: async (args, value) => {
881+ const quiet = isTrueBoolean(args?.quiet?.toString());
882+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
883+ const id = args?.id?.toString()?.trim();
884+
885+ if (!key) {
886+ if (!quiet) {
887+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
888+ }
889+ return '';
890+ }
891+
892+ const secrets = secret_state[key];
893+ if (!Array.isArray(secrets) || secrets.length === 0) {
894+ if (!quiet) {
895+ toastr.error(t`No saved secrets found for the key: ${key}`);
896+ }
897+ return '';
898+ }
899+
900+ const newLabel = value?.toString()?.trim();
901+ if (!newLabel) {
902+ if (!quiet) {
903+ toastr.error(t`No new label provided for the secret key: ${key}`);
904+ }
905+ return '';
906+ }
907+
908+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
909+ if (!savedSecret) {
910+ if (!quiet) {
911+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
912+ }
913+ return '';
914+ }
915+
916+ // Rename the secret
917+ await renameSecret(key, savedSecret.id, newLabel);
918+ if (!quiet) {
919+ toastr.success(t`Secret with ID: ${id} has been renamed to "${newLabel}" for the key: ${key}`);
920+ }
921+
922+ return savedSecret.id;
923+ },
924+ }));
925+
926+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
927+ name: 'secret-read',
928+ aliases: ['secret-find', 'secret-get'],
929+ helpString: t`Reads a secret key by ID. If key exposure is disabled, this command will not work!`,
930+ returns: t`The value of the secret key.`,
931+ namedArgumentList: [
932+ SlashCommandNamedArgument.fromProps({
933+ name: 'quiet',
934+ description: t`Suppress toast message notifications.`,
935+ isRequired: false,
936+ defaultValue: String(false),
937+ typeList: [ARGUMENT_TYPE.BOOLEAN],
938+ }),
939+ SlashCommandNamedArgument.fromProps({
940+ name: 'key',
941+ description: t`The key to read the secret from. If not provided, will use the currently active API secrets.`,
942+ isRequired: false,
943+ typeList: [ARGUMENT_TYPE.STRING],
944+ enumProvider: secretKeyEnumProvider,
945+ }),
946+ ],
947+ unnamedArgumentList: [
948+ SlashCommandArgument.fromProps({
949+ description: t`The ID or a label of the secret key to read. If not provided, will return the currently active secret value.`,
950+ isRequired: true,
951+ typeList: [ARGUMENT_TYPE.STRING],
952+ enumProvider: secretIdEnumProvider,
953+ }),
954+ ],
955+ callback: async (args, value) => {
956+ const quiet = isTrueBoolean(args?.quiet?.toString());
957+ const key = args?.key?.toString()?.trim() || resolveSecretKey();
958+ const id = value?.toString()?.trim();
959+
960+ if (!key) {
961+ if (!quiet) {
962+ toastr.error(t`No secret key provided, and the key can't be resolved for the currently selected API type.`);
963+ }
964+ return '';
965+ }
966+
967+ const secrets = secret_state[key];
968+ if (!Array.isArray(secrets) || secrets.length === 0) {
969+ if (!quiet) {
970+ toastr.error(t`No saved secrets found for the key: ${key}`);
971+ }
972+ return '';
973+ }
974+
975+ const savedSecret = secrets.find(s => s.id === id) ?? secrets.find(s => s.label === id) ?? secrets.find(s => s.active);
976+ if (!savedSecret) {
977+ if (!quiet) {
978+ toastr.error(t`No secret found with ID: ${id} for the key: ${key}`);
979+ }
980+ return '';
981+ }
982+
983+ const secretValue = await findSecret(key, savedSecret.id);
984+ if (secretValue === null) {
985+ if (!quiet) {
986+ toastr.error(t`Could not retrieve the secret value for key: ${key}. Key exposure might be disabled.`);
987+ }
988+ return '';
989+ }
990+
991+ return secretValue;
992+ },
993+ }));
994+}
995+
996+export async function initSecrets() {
249997 $('#viewSecrets').on('click', viewSecrets);
250998 $(document).on('click', '.clearmanage-api-keykeys', clearSecretasync function (); {
999+ const key = $(this).data('key');
1000+ if (!key || !Object.values(SECRET_KEYS).includes(key)) {
1001+ console.error('Invalid key for manage-api-keys:', key);
1002+ return;
1003+ }
1004+ await openKeyManagerDialog(key);
1005+ });
2511006 $(document).on('input', Object.values(INPUT_MAP).join(','), function () {
2521007 const id = $(this).attr('id');
2531008 const value = $(this).val();
1009+
1010+ // Find the key based on the entered value
1011+ for (const [key, inputSelector] of Object.entries(INPUT_MAP)) {
1012+ if (!value || !this.matches(inputSelector)) {
1013+ continue;
1014+ }
1015+ const secrets = secret_state[key];
1016+ if (!Array.isArray(secrets)) {
1017+ continue;
1018+ }
1019+ const secretMatch = secrets.find(secret => secret.id === value);
1020+ if (secretMatch) {
1021+ $(this).val('');
1022+ return rotateSecret(key, secretMatch.id);
1023+ }
1024+ }
1025+
2541026 const warningElement = $(`[data-for="${id}"]`);
2551027 warningElement.toggle(value.length > 0);
2561028 });
2571029 $('.openrouter_authorize').on('click', authorizeOpenRouter);
2581030} registerSecretSlashCommands();
1031+}
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+1 -0
@@ -40,6 +40,7 @@ export const enumIcons = {
4040 server: '🖥️',
4141 popup: '🗔',
4242 image: '🖼️',
43+ key: '🔑',
4344
4445 true: '✔️',
4546 false: '❌',
public/scripts/templates/secretKeyManager.html+27 -0
@@ -0,0 +1,27 @@
1+<div class="secretKeyManager">
2+ <div class="secretKeyManagerHeader">
3+ <div class="secretKeyManagerSubtitle">
4+ <div class="secretKeyManagerInfo">
5+ <div class="flex-container">
6+ <div data-i18n="API:">API:</div>
7+ <span>{{name}}</span>
8+ </div>
9+ <div class="flex-container">
10+ <div data-i18n="Key:">Key:</div>
11+ <code>{{key}}</code>
12+ </div>
13+ </div>
14+ <div class="secretKeyManagerActions">
15+ <button class="menu_button menu_button_icon" data-action="add-secret">
16+ <i class="fa-solid fa-plus"></i>
17+ <span data-i18n="Add Secret">Add Secret</span>
18+ </button>
19+ </div>
20+ </div>
21+ </div>
22+ <hr>
23+ <div class="secretKeyManagerList"></div>
24+ <div class="secretKeyManagerListEmpty">
25+ <span data-i18n="No secrets saved.">No secrets saved.</span>
26+ </div>
27+</div>
public/scripts/templates/secretKeyManagerListItem.html+30 -0
@@ -0,0 +1,30 @@
1+<div class="secretKeyManagerItem {{#if active}}active{{/if}}">
2+ <div class="secretKeyManagerItemInfo">
3+ <div class="secretKeyManagerItemHeader">
4+ <strong>{{label}}</strong>
5+ <small>{{value}}</small>
6+ </div>
7+ <div class="secretKeyManagerItemSubtitle">
8+ <strong>ID:</strong>
9+ <span class="secretKeyManagerItemId" data-action="copy-id" title="Copy ID" data-i18n="[title]Copy ID">{{id}}</span>
10+ </div>
11+ </div>
12+ <div class="secretKeyManagerItemActions">
13+ <div class="secretKeyManagerItemActionsRow">
14+ <button class="menu_button menu_button_icon {{#if active}}disabled{{/if}}" data-action="rotate-secret" data-id="{{id}}" title="Select" data-i18n="[title]Select">
15+ <i class="fa-fw fa-solid fa-check"></i>
16+ </button>
17+ <button class="menu_button menu_button_icon" data-action="copy-secret" data-id="{{id}}" title="Copy" data-i18n="[title]Copy">
18+ <i class="fa-fw fa-solid fa-copy"></i>
19+ </button>
20+ </div>
21+ <div class="secretKeyManagerItemActionsRow">
22+ <button class="menu_button menu_button_icon" data-action="rename-secret" data-id="{{id}}" title="Rename" data-i18n="[title]Rename">
23+ <i class="fa-fw fa-solid fa-pen-to-square"></i>
24+ </button>
25+ <button class="menu_button menu_button_icon" data-action="delete-secret" data-id="{{id}}" title="Delete" data-i18n="[title]Delete">
26+ <i class="fa-fw fa-solid fa-trash"></i>
27+ </button>
28+ </div>
29+ </div>
30+</div>
public/scripts/utils.js+11 -0
@@ -126,6 +126,17 @@ export function isValidUrl(value) {
126126}
127127
128128/**
129+ * Checks if a string is a valid UUID (version 1-5).
130+ * @param {string} value String to check
131+ * @returns {boolean} True if the string is a valid UUID, false otherwise.
132+ */
133+export function isUuid(value) {
134+ // Regular expression to match UUIDs
135+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
136+ return uuidRegex.test(value);
137+}
138+
139+/**
129140 * Converts string to a value of a given type. Includes pythonista-friendly aliases.
130141 * @param {string|SlashCommandClosure} value String value
131142 * @param {string} type Type to convert to
public/style.css+1 -0
@@ -12,6 +12,7 @@
1212@import url(css/scrollable-button.css);
1313@import url(css/welcome.css);
1414@import url(css/data-maid.css);
15+@import url(css/secrets.css);
1516
1617:root {
1718 interpolate-size: allow-keywords;
src/endpoints/secrets.js+461 -67
@@ -3,10 +3,11 @@ import path from 'node:path';
33
44import express from 'express';
55import { sync as writeFileAtomicSync } from 'write-file-atomic';
66import { color, getConfigValue, uuidv4 } from '../util.js';
77
88export const SECRETS_FILE = 'secrets.json';
99export const SECRET_KEYS = {
10+ _MIGRATED: '_migrated',
1011 HORDE: 'api_key_horde',
1112 MANCER: 'api_key_mancer',
1213 VLLM: 'api_key_vllm',
@@ -57,6 +58,31 @@ export const SECRET_KEYS = {
5758 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
5859};
5960
61+/**
62+ * @typedef {object} SecretValue
63+ * @property {string} id The unique identifier for the secret
64+ * @property {string} value The secret value
65+ * @property {string} label The label for the secret
66+ * @property {boolean} active Whether the secret is currently active
67+ */
68+
69+/**
70+ * @typedef {object} SecretState
71+ * @property {string} id The unique identifier for the secret
72+ * @property {string} value The secret value, masked for security
73+ * @property {string} label The label for the secret
74+ * @property {boolean} active Whether the secret is currently active
75+ */
76+
77+/**
78+ * @typedef {Record<string, SecretState[]|null>} SecretStateMap
79+ */
80+
81+/**
82+ * @typedef {{[key: string]: SecretValue[]}} SecretKeys
83+ * @typedef {{[key: string]: string}} FlatSecretKeys
84+ */
85+
6086// These are the keys that are safe to expose, even if allowKeysExposure is false
6187const EXPORTABLE_KEYS = [
6288 SECRET_KEYS.LIBRE_URL,
@@ -65,133 +91,443 @@ const EXPORTABLE_KEYS = [
6591 SECRET_KEYS.DEEPLX_URL,
6692];
6793
94+const allowKeysExposure = !!getConfigValue('allowKeysExposure', false, 'boolean');
95+
96+/**
97+ * SecretManager class to handle all secret operations
98+ */
99+export class SecretManager {
100+ /**
101+ * @param {import('../users.js').UserDirectoryList} directories
102+ */
103+ constructor(directories) {
104+ this.directories = directories;
105+ this.filePath = path.join(directories.root, SECRETS_FILE);
106+ this.defaultSecrets = {};
107+ }
108+
109+ /**
110+ * Ensures the secrets file exists, creating an empty one if necessary
111+ * @private
112+ */
113+ _ensureSecretsFile() {
114+ if (!fs.existsSync(this.filePath)) {
115+ writeFileAtomicSync(this.filePath, JSON.stringify(this.defaultSecrets), 'utf-8');
116+ }
117+ }
118+
119+ /**
120+ * Reads and parses the secrets file
121+ * @private
122+ * @returns {SecretKeys}
123+ */
124+ _readSecretsFile() {
125+ this._ensureSecretsFile();
126+ const fileContents = fs.readFileSync(this.filePath, 'utf-8');
127+ return /** @type {SecretKeys} */ (JSON.parse(fileContents));
128+ }
129+
130+ /**
131+ * Writes secrets to the file atomically
132+ * @private
133+ * @param {SecretKeys} secrets
134+ */
135+ _writeSecretsFile(secrets) {
136+ writeFileAtomicSync(this.filePath, JSON.stringify(secrets, null, 4), 'utf-8');
137+ }
138+
139+ /**
140+ * Deactivates all secrets for a given key
141+ * @private
142+ * @param {SecretValue[]} secretArray
143+ */
144+ _deactivateAllSecrets(secretArray) {
145+ secretArray.forEach(secret => {
146+ secret.active = false;
147+ });
148+ }
149+
150+ /**
151+ * Validates that the secret key exists and has valid structure
152+ * @private
153+ * @param {SecretKeys} secrets
154+ * @param {string} key
155+ * @returns {boolean}
156+ */
157+ _validateSecretKey(secrets, key) {
158+ return Object.hasOwn(secrets, key) && Array.isArray(secrets[key]);
159+ }
160+
161+ /**
162+ * Masks a secret value with asterisks in the middle
163+ * @param {string} value The secret value to mask
164+ * @returns {string} A masked version of the value for peeking
165+ */
166+ getMaskedValue(value) {
167+ // No masking if exposure is allowed
168+ if (allowKeysExposure) {
169+ return value;
170+ }
171+ const threshold = 10;
172+ const exposedChars = 3;
173+ const placeholder = '*';
174+ if (value.length <= threshold) {
175+ return placeholder.repeat(threshold);
176+ }
177+ const visibleEnd = value.slice(-exposedChars);
178+ const maskedMiddle = placeholder.repeat(threshold - exposedChars);
179+ return `${maskedMiddle}${visibleEnd}`;
180+ }
181+
68182 /**
69183 * Writes a secret to the secrets file
70- * @param {import('../users.js').UserDirectoryList} directories User directories
71184 * @param {string} key Secret key
72185 * @param {string} value Secret value
186+ * @param {string} label Label for the secret
187+ * @returns {string} The ID of the newly created secret
73188 */
74189export function writeSecret(directories, key, value, label = 'Unlabeled') {
75190 const filePathsecrets = paththis.join_readSecretsFile(directories.root, SECRETS_FILE);
76191
77192 if (!fsArray.existsSyncisArray(filePathsecrets[key])) {
78- const emptyFile = JSON.stringify({});
193+ secrets[key] = [];
79- writeFileAtomicSync(filePath, emptyFile, 'utf-8');
80194 }
81195
82- const fileContents = fs.readFileSync(filePath, 'utf-8');
196+ this._deactivateAllSecrets(secrets[key]);
83- const secrets = JSON.parse(fileContents);
197+
84- secrets[key] = value;
198+ const secret = {
85- writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
199+ id: uuidv4(),
200+ value: value,
201+ label: label,
202+ active: true,
203+ };
204+ secrets[key].push(secret);
205+
206+ this._writeSecretsFile(secrets);
207+ return secret.id;
86208 }
87209
88210 /**
89211 * Deletes a secret from the secrets file by its ID
90- * @param {import('../users.js').UserDirectoryList} directories User directories
91212 * @param {string} key Secret key
92- * @returns
213+ * @param {string?} id Secret ID to delete
93214 */
94215export function deleteSecret(directorieskey, keyid) {
95- const filePath = path.join(directories.root, SECRETS_FILE);
216+ if (!fs.existsSync(this.filePath)) {
217+ return;
218+ }
219+
220+ const secrets = this._readSecretsFile();
96221
97222 if (!fsthis.existsSync_validateSecretKey(filePathsecrets, key)) {
98223 return;
99224 }
100225
101- const fileContents = fs.readFileSync(filePath, 'utf-8');
226+ const secretArray = secrets[key];
102- const secrets = JSON.parse(fileContents);
227+ const targetIndex = secretArray.findIndex(s => id ? s.id === id : s.active);
228+
229+ // Delete the secret if found
230+ if (targetIndex !== -1) {
231+ secretArray.splice(targetIndex, 1);
232+ }
233+
234+ // Reactivate the first secret if none are active
235+ if (secretArray.length && !secretArray.some(s => s.active)) {
236+ secretArray[0].active = true;
237+ }
238+
239+ // Remove the key if no secrets left
240+ if (secretArray.length === 0) {
103241 delete secrets[key];
104- writeFileAtomicSync(filePath, JSON.stringify(secrets, null, 4), 'utf-8');
242+ }
243+
244+ this._writeSecretsFile(secrets);
105245 }
106246
107247 /**
108248 * Reads athe active secret fromvalue thefor secretsa filegiven key
109- * @param {import('../users.js').UserDirectoryList} directories User directories
110249 * @param {string} key Secret key
111- * @returns {string} Secret value
250+ * @param {string?} id ID of the secret to read (optional)
251+ * @returns {string} Secret value or empty string if not found
112252 */
113253export function readSecret(directorieskey, keyid) {
114- const filePath = path.join(directories.root, SECRETS_FILE);
254+ if (!fs.existsSync(this.filePath)) {
255+ return '';
256+ }
257+
258+ const secrets = this._readSecretsFile();
259+ const secretArray = secrets[key];
260+
261+ if (Array.isArray(secretArray) && secretArray.length > 0) {
262+ const activeSecret = secretArray.find(s => id ? s.id === id : s.active);
263+ return activeSecret?.value || '';
264+ }
115265
116- if (!fs.existsSync(filePath)) {
117266 return '';
118267 }
119268
120- const fileContents = fs.readFileSync(filePath, 'utf-8');
269+ /**
121- const secrets = JSON.parse(fileContents);
270+ * Activates a specific secret by ID for a given key
122- return secrets[key];
271+ * @param {string} key Secret key to rotate
272+ * @param {string} id ID of the secret to activate
273+ */
274+ rotateSecret(key, id) {
275+ if (!fs.existsSync(this.filePath)) {
276+ return;
277+ }
278+
279+ const secrets = this._readSecretsFile();
280+
281+ if (!this._validateSecretKey(secrets, key)) {
282+ return;
283+ }
284+
285+ const secretArray = secrets[key];
286+ const targetIndex = secretArray.findIndex(s => s.id === id);
287+
288+ if (targetIndex === -1) {
289+ console.warn(`Secret with ID ${id} not found for key ${key}`);
290+ return;
291+ }
292+
293+ this._deactivateAllSecrets(secretArray);
294+ secretArray[targetIndex].active = true;
295+
296+ this._writeSecretsFile(secrets);
123297 }
124298
125299 /**
126300 * ReadsRenames thea secret state from theby secretsits fileID
127- * @param {import('../users.js').UserDirectoryList} directories User directories
301+ * @param {string} key Secret key to rename
128- * @returns {object} Secret state
302+ * @param {string} id ID of the secret to rename
303+ * @param {string} label New label for the secret
129304 */
130-export function readSecretState(directories) {
305+ renameSecret(key, id, label) {
131306 const filePathsecrets = paththis.join_readSecretsFile(directories.root, SECRETS_FILE);
307+
308+ if (!this._validateSecretKey(secrets, key)) {
309+ return;
310+ }
132311
133- if (!fs.existsSync(filePath)) {
312+ const secretArray = secrets[key];
134- return {};
313+ const targetIndex = secretArray.findIndex(s => s.id === id);
314+
315+ if (targetIndex === -1) {
316+ console.warn(`Secret with ID ${id} not found for key ${key}`);
317+ return;
318+ }
319+
320+ secretArray[targetIndex].label = label;
321+ this._writeSecretsFile(secrets);
135322 }
136323
137- const fileContents = fs.readFileSync(filePath, 'utf8');
324+ /**
138- const secrets = JSON.parse(fileContents);
325+ * Gets the state of all secrets (whether they exist or not)
326+ * @returns {SecretStateMap} Secret state
327+ */
328+ getSecretState() {
329+ const secrets = this._readSecretsFile();
330+ /** @type {SecretStateMap} */
139331 const state = {};
140332
141333 for (const key of Object.values(SECRET_KEYS)) {
142- state[key] = !!secrets[key]; // convert to boolean
334+ // Skip migration marker
335+ if (key === SECRET_KEYS._MIGRATED) {
336+ continue;
337+ }
338+ const value = secrets[key];
339+ if (value && Array.isArray(value) && value.length > 0) {
340+ state[key] = value.map(secret => ({
341+ id: secret.id,
342+ value: this.getMaskedValue(secret.value),
343+ label: secret.label,
344+ active: secret.active,
345+ }));
346+ } else {
347+ // No secrets for this key
348+ state[key] = null;
349+ }
143350 }
144351
145352 return state;
146353 }
147354
148355 /**
356+ * Gets all secrets (for admin viewing)
357+ * @returns {SecretKeys} All secrets
358+ */
359+ getAllSecrets() {
360+ return this._readSecretsFile();
361+ }
362+
363+ /**
364+ * Migrates legacy flat secrets format to new format
365+ */
366+ migrateFlatSecrets() {
367+ if (!fs.existsSync(this.filePath)) {
368+ return;
369+ }
370+
371+ const fileContents = fs.readFileSync(this.filePath, 'utf8');
372+ const secrets = /** @type {FlatSecretKeys} */ (JSON.parse(fileContents));
373+ const values = Object.values(secrets);
374+
375+ // Check if already migrated
376+ if (secrets[SECRET_KEYS._MIGRATED] || values.length === 0 || values.some(v => Array.isArray(v))) {
377+ return;
378+ }
379+
380+ /** @type {SecretKeys} */
381+ const migratedSecrets = {};
382+
383+ for (const [key, value] of Object.entries(secrets)) {
384+ if (typeof value === 'string' && value.trim()) {
385+ migratedSecrets[key] = [{
386+ id: uuidv4(),
387+ value: value,
388+ label: key,
389+ active: true,
390+ }];
391+ }
392+ }
393+
394+ // Mark as migrated
395+ migratedSecrets[SECRET_KEYS._MIGRATED] = [];
396+
397+ // Save backup of the old secrets file
398+ const backupFilePath = path.join(this.directories.backups, `secrets_migration_${Date.now()}.json`);
399+ fs.cpSync(this.filePath, backupFilePath);
400+
401+ this._writeSecretsFile(migratedSecrets);
402+ console.info(color.green('Secrets migrated successfully, old secrets backed up to:'), backupFilePath);
403+ }
404+}
405+
406+//#region Backwards compatibility
407+/**
408+ * Writes a secret to the secrets file
409+ * @param {import('../users.js').UserDirectoryList} directories User directories
410+ * @param {string} key Secret key
411+ * @param {string} value Secret value
412+ */
413+export function writeSecret(directories, key, value) {
414+ return new SecretManager(directories).writeSecret(key, value);
415+}
416+
417+/**
418+ * Deletes a secret from the secrets file
419+ * @param {import('../users.js').UserDirectoryList} directories User directories
420+ * @param {string} key Secret key
421+ */
422+export function deleteSecret(directories, key) {
423+ return new SecretManager(directories).deleteSecret(key, null);
424+}
425+
426+/**
427+ * Reads a secret from the secrets file
428+ * @param {import('../users.js').UserDirectoryList} directories User directories
429+ * @param {string} key Secret key
430+ * @returns {string} Secret value
431+ */
432+export function readSecret(directories, key) {
433+ return new SecretManager(directories).readSecret(key, null);
434+}
435+
436+/**
437+ * Reads the secret state from the secrets file
438+ * @param {import('../users.js').UserDirectoryList} directories User directories
439+ * @returns {Record<string, boolean>} Secret state
440+ */
441+export function readSecretState(directories) {
442+ const state = new SecretManager(directories).getSecretState();
443+ const result = /** @type {Record<string, boolean>} */ ({});
444+ for (const key of Object.values(SECRET_KEYS)) {
445+ // Skip migration marker
446+ if (key === SECRET_KEYS._MIGRATED) {
447+ continue;
448+ }
449+ result[key] = Array.isArray(state[key]) && state[key].length > 0;
450+ }
451+ return result;
452+}
453+
454+/**
149455 * Reads all secrets from the secrets file
150456 * @param {import('../users.js').UserDirectoryList} directories User directories
151457 * @returns {Record<string, string> | undefined} Secrets
152458 */
153459export function getAllSecrets(directories) {
154460 const filePathsecrets = path.joinnew SecretManager(directories).root, SECRETS_FILEgetAllSecrets();
155-
461+ const result = /** @type {Record<string, string>} */ ({});
156- if (!fs.existsSync(filePath)) {
462+ for (const [key, values] of Object.entries(secrets)) {
157- console.error('Secrets file does not exist');
463+ // Skip migration marker
158- return undefined;
464+ if (key === SECRET_KEYS._MIGRATED) {
465+ continue;
466+ }
467+ if (Array.isArray(values) && values.length > 0) {
468+ const activeSecret = values.find(secret => secret.active);
469+ if (activeSecret) {
470+ result[key] = activeSecret.value;
159471 }
472+ }
473+ }
474+ return result;
475+}
476+//#endregion
160477
161- const fileContents = fs.readFileSync(filePath, 'utf8');
478+/**
162- const secrets = JSON.parse(fileContents);
479+ * Migrates legacy flat secrets format to the new format for all user directories
163- return secrets;
480+ * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
481+ */
482+export function migrateFlatSecrets(directoriesList) {
483+ for (const directories of directoriesList) {
484+ try {
485+ const manager = new SecretManager(directories);
486+ manager.migrateFlatSecrets();
487+ } catch (error) {
488+ console.warn(color.red(`Failed to migrate secrets for ${directories.root}:`), error);
489+ }
490+ }
164491}
165492
166493export const router = express.Router();
167494
168495router.post('/write', (request, response) => {
169- const key = request.body.key;
496+ try {
170497 const { key, value, label } = request.body.value;
171498
172- writeSecret(request.user.directories, key, value);
499+ if (!key || typeof value !== 'string') {
173500 return response.status(400).send('okInvalid key or value');
501+ }
502+
503+ const manager = new SecretManager(request.user.directories);
504+ const id = manager.writeSecret(key, value, label);
505+
506+ return response.send({ id });
507+ } catch (error) {
508+ console.error('Error writing secret:', error);
509+ return response.sendStatus(500);
510+ }
174511});
175512
176513router.post('/read', (request, response) => {
177514 try {
178515 const statemanager = readSecretStatenew SecretManager(request.user.directories);
516+ const state = manager.getSecretState();
179517 return response.send(state);
180518 } catch (error) {
181519 console.error('Error reading secret state:', error);
182520 return response.send({});
183521 }
184522});
185523
186524router.post('/view', async (request, response) => {
187- const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
525+ try {
188-
189526 if (!allowKeysExposure) {
190527 console.error('secrets.json could not be viewed unless the value of allowKeysExposure in config.yaml is set to true');
191528 return response.sendStatus(403);
192529 }
193530
194- try {
195531 const secrets = getAllSecrets(request.user.directories);
196532
197533 if (!secrets) {
@@ -200,30 +536,88 @@ router.post('/view', async (request, response) => {
200536
201537 return response.send(secrets);
202538 } catch (error) {
203539 console.error('Error viewing secrets:', error);
204540 return response.sendStatus(500);
205541 }
206542});
207543
208544router.post('/find', (request, response) => {
209- const allowKeysExposure = getConfigValue('allowKeysExposure', false, 'boolean');
545+ try {
210546 const { key, id } = request.body.key;
547+
548+ if (!key) {
549+ return response.status(400).send('Key is required');
550+ }
211551
212552 if (!allowKeysExposure && !EXPORTABLE_KEYS.includes(key)) {
213553 console.error('Cannot fetch secrets unless allowKeysExposure in config.yaml is set to true');
214554 return response.sendStatus(403);
215555 }
216556
217- try {
557+ const manager = new SecretManager(request.user.directories);
218558 const secretsecretValue = manager.readSecret(request.user.directorieskey, keyid);
219559
220560 if (!secretsecretValue) {
221561 return response.sendStatus(404);
222562 }
223563
224564 return response.send({ value: secretsecretValue });
565+ } catch (error) {
566+ console.error('Error finding secret:', error);
567+ return response.sendStatus(500);
568+ }
569+});
570+
571+router.post('/delete', (request, response) => {
572+ try {
573+ const { key, id } = request.body;
574+
575+ if (!key) {
576+ return response.status(400).send('Key and ID are required');
577+ }
578+
579+ const manager = new SecretManager(request.user.directories);
580+ manager.deleteSecret(key, id);
581+
582+ return response.sendStatus(204);
583+ } catch (error) {
584+ console.error('Error deleting secret:', error);
585+ return response.sendStatus(500);
586+ }
587+});
588+
589+router.post('/rotate', (request, response) => {
590+ try {
591+ const { key, id } = request.body;
592+
593+ if (!key || !id) {
594+ return response.status(400).send('Key and ID are required');
595+ }
596+
597+ const manager = new SecretManager(request.user.directories);
598+ manager.rotateSecret(key, id);
599+
600+ return response.sendStatus(204);
601+ } catch (error) {
602+ console.error('Error rotating secret:', error);
603+ return response.sendStatus(500);
604+ }
605+});
606+
607+router.post('/rename', (request, response) => {
608+ try {
609+ const { key, id, label } = request.body;
610+
611+ if (!key || !id || !label) {
612+ return response.status(400).send('Key, ID, and label are required');
613+ }
614+
615+ const manager = new SecretManager(request.user.directories);
616+ manager.renameSecret(key, id, label);
617+
618+ return response.sendStatus(204);
225619 } catch (error) {
226620 console.error('Error renaming secret:', error);
227621 return response.sendStatus(500);
228622 }
229623});
src/server-main.js+2 -0
@@ -76,6 +76,7 @@ import { checkForNewContent } from './endpoints/content-manager.js';
7676import { init as settingsInit } from './endpoints/settings.js';
7777import { redirectDeprecatedEndpoints, ServerStartup, setupPrivateEndpoints } from './server-startup.js';
7878import { diskCache } from './endpoints/characters.js';
79+import { migrateFlatSecrets } from './endpoints/secrets.js';
7980
8081// Unrestrict console logs display limit
8182util.inspect.defaultOptions.maxArrayLength = null;
@@ -275,6 +276,7 @@ async function preSetupTasks() {
275276 await checkForNewContent(directories);
276277 await ensureThumbnailCache(directories);
277278 await diskCache.verify(directories);
279+ migrateFlatSecrets(directories);
278280 cleanUploads();
279281 migrateAccessLog();
280282
src/util.js+7 -0
@@ -7,6 +7,7 @@ import { createRequire } from 'node:module';
77import { Buffer } from 'node:buffer';
88import { promises as dnsPromise } from 'node:dns';
99import os from 'node:os';
10+import crypto from 'node:crypto';
1011
1112import yaml from 'yaml';
1213import { sync as commandExistsSync } from 'command-exists';
@@ -371,9 +372,15 @@ export const color = chalk;
371372 * @returns {string} A UUIDv4 string
372373 */
373374export function uuidv4() {
375+ // Node v16.7.0+
374376 if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) {
375377 return globalThis.crypto.randomUUID();
376378 }
379+ // Node v14.17.0+
380+ if ('randomUUID' in crypto) {
381+ return crypto.randomUUID();
382+ }
383+ // Very insecure UUID generator, but it's better than nothing.
377384 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
378385 const r = Math.random() * 16 | 0;
379386 const v = c === 'x' ? r : (r & 0x3 | 0x8);