kempo 4.2.38 → 4.2.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/settings/[owner]/[name]/index.page.html +52 -1
- package/dist/admin/settings/index.page.html +12 -1
- package/dist/kempo/components/PermissionTarget.js +2 -0
- package/dist/kempo/components/PermissionTarget.js.map +1 -0
- package/dist/kempo/icons/create_new_folder.svg +1 -0
- package/dist/kempo/icons/upload.svg +1 -0
- package/package.json +1 -1
- package/server/utils/settings/getPublicSettings.js +4 -2
- package/server/utils/settings/getSettingWithMetadata.js +11 -4
- package/server/utils/settings/helpers.js +8 -2
- package/server/utils/settings/listSettings.js +9 -1
- package/server/utils/settings/secretCrypto.js +41 -0
- package/server/utils/settings/setSetting.js +31 -10
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
<option value="number">Number</option>
|
|
46
46
|
<option value="boolean">Boolean</option>
|
|
47
47
|
<option value="json">JSON</option>
|
|
48
|
+
<option value="secret">Secret</option>
|
|
48
49
|
</select>
|
|
49
50
|
</div>
|
|
50
51
|
<div class="mb d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
@@ -122,6 +123,17 @@
|
|
|
122
123
|
await customElements.whenDefined('k-toggle');
|
|
123
124
|
$publicToggle.value = settingData.isPublic;
|
|
124
125
|
|
|
126
|
+
// Secrets are never public — force the toggle off and lock it whenever the type is (or becomes) 'secret'.
|
|
127
|
+
const syncPublicToggleForType = type => {
|
|
128
|
+
if(type === 'secret'){
|
|
129
|
+
$publicToggle.value = false;
|
|
130
|
+
$publicToggle.setAttribute('disabled', '');
|
|
131
|
+
} else if(canEditMetadata){
|
|
132
|
+
$publicToggle.removeAttribute('disabled');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
syncPublicToggleForType(settingData.type);
|
|
136
|
+
|
|
125
137
|
/*
|
|
126
138
|
Value editors
|
|
127
139
|
*/
|
|
@@ -141,6 +153,13 @@
|
|
|
141
153
|
if(monacoEl) return JSON.parse(monacoEl.value);
|
|
142
154
|
return settingData.value;
|
|
143
155
|
}
|
|
156
|
+
case 'secret': {
|
|
157
|
+
// Blank on an existing secret means "unchanged" — round-trip the masked sentinel so the server keeps it.
|
|
158
|
+
// Blank while switching some other value's type to secret has nothing to preserve, so it clears instead.
|
|
159
|
+
const typed = $editorContainer.querySelector('input[type="password"]').value;
|
|
160
|
+
if(typed !== '') return typed;
|
|
161
|
+
return settingData.type === 'secret' ? settingData.value : null;
|
|
162
|
+
}
|
|
144
163
|
}
|
|
145
164
|
};
|
|
146
165
|
|
|
@@ -195,6 +214,23 @@
|
|
|
195
214
|
$editorContainer.appendChild(editor);
|
|
196
215
|
};
|
|
197
216
|
|
|
217
|
+
const renderSecretEditor = value => {
|
|
218
|
+
$editorContainer.innerHTML = '';
|
|
219
|
+
const input = document.createElement('input');
|
|
220
|
+
input.type = 'password';
|
|
221
|
+
input.className = 'full';
|
|
222
|
+
input.autocomplete = 'new-password';
|
|
223
|
+
input.placeholder = value ? 'Leave blank to keep the current secret' : 'Enter a secret value';
|
|
224
|
+
if(!canEditValue) input.readOnly = true;
|
|
225
|
+
$editorContainer.appendChild(input);
|
|
226
|
+
const note = document.createElement('p');
|
|
227
|
+
note.className = 'tc-muted mb0';
|
|
228
|
+
note.textContent = value
|
|
229
|
+
? 'The stored value is encrypted and cannot be displayed. Type a new value to replace it.'
|
|
230
|
+
: 'This secret has not been set yet.';
|
|
231
|
+
$editorContainer.appendChild(note);
|
|
232
|
+
};
|
|
233
|
+
|
|
198
234
|
const renderEditor = async (type, value) => {
|
|
199
235
|
currentType = type;
|
|
200
236
|
switch(type){
|
|
@@ -202,6 +238,7 @@
|
|
|
202
238
|
case 'number': renderNumberEditor(value); break;
|
|
203
239
|
case 'boolean': await renderBooleanEditor(value); break;
|
|
204
240
|
case 'json': renderJsonEditor(value); break;
|
|
241
|
+
case 'secret': renderSecretEditor(value); break;
|
|
205
242
|
default: renderStringEditor(String(value ?? ''));
|
|
206
243
|
}
|
|
207
244
|
};
|
|
@@ -218,6 +255,9 @@
|
|
|
218
255
|
try { converted = getValueFromEditor(); }
|
|
219
256
|
catch(e){ converted = settingData.value; }
|
|
220
257
|
|
|
258
|
+
// A secret editor left blank round-trips as the masked sentinel — treat that as empty rather than carrying it into the new type as if it were real plaintext.
|
|
259
|
+
if(currentType === 'secret' && converted === settingData.value) converted = '';
|
|
260
|
+
|
|
221
261
|
switch(newType){
|
|
222
262
|
case 'string':
|
|
223
263
|
converted = typeof converted === 'object'
|
|
@@ -236,8 +276,13 @@
|
|
|
236
276
|
catch(e){ /* keep as string */ }
|
|
237
277
|
}
|
|
238
278
|
break;
|
|
279
|
+
case 'secret':
|
|
280
|
+
// Never carry a value across into a secret field silently — require the user to type it.
|
|
281
|
+
converted = null;
|
|
282
|
+
break;
|
|
239
283
|
}
|
|
240
284
|
currentType = newType;
|
|
285
|
+
syncPublicToggleForType(newType);
|
|
241
286
|
await renderEditor(newType, converted);
|
|
242
287
|
setTimeout(checkDirty, 0);
|
|
243
288
|
});
|
|
@@ -321,11 +366,17 @@
|
|
|
321
366
|
return;
|
|
322
367
|
}
|
|
323
368
|
|
|
324
|
-
|
|
369
|
+
// Don't hold the plaintext secret in page state any longer than the request needs it — mirror what a reload would show.
|
|
370
|
+
settingData.value = $typeSelect.value === 'secret'
|
|
371
|
+
? (value === null ? null : '••••••••')
|
|
372
|
+
: value;
|
|
325
373
|
settingData.type = $typeSelect.value;
|
|
326
374
|
settingData.isPublic = $publicToggle.value;
|
|
327
375
|
settingData.description = $descriptionInput.value.trim();
|
|
328
376
|
|
|
377
|
+
// Clear the typed plaintext out of the input now that it's saved, rather than leaving it sitting in the DOM.
|
|
378
|
+
if(settingData.type === 'secret') await renderEditor(settingData.type, settingData.value);
|
|
379
|
+
|
|
329
380
|
Toast.success('Setting saved');
|
|
330
381
|
document.getElementById('updatedAt').textContent = new Date().toLocaleString();
|
|
331
382
|
checkDirty();
|
|
@@ -241,9 +241,10 @@
|
|
|
241
241
|
<option value="number">Number</option>
|
|
242
242
|
<option value="boolean">Boolean</option>
|
|
243
243
|
<option value="json">JSON</option>
|
|
244
|
+
<option value="secret">Secret</option>
|
|
244
245
|
</select>
|
|
245
246
|
</div>
|
|
246
|
-
<div class="mb d-f" style="align-items: center; gap: var(--spacer-sm);">
|
|
247
|
+
<div class="mb d-f" style="align-items: center; gap: var(--spacer-sm);" id="dlg-isPublic-row">
|
|
247
248
|
<input type="checkbox" id="dlg-isPublic">
|
|
248
249
|
<label for="dlg-isPublic">Public (accessible without authentication)</label>
|
|
249
250
|
</div>
|
|
@@ -280,6 +281,16 @@
|
|
|
280
281
|
},
|
|
281
282
|
cancelText: 'Cancel'
|
|
282
283
|
});
|
|
284
|
+
|
|
285
|
+
const $dlgValue = $dialog.querySelector('#dlg-value');
|
|
286
|
+
const $dlgIsPublic = $dialog.querySelector('#dlg-isPublic');
|
|
287
|
+
const $dlgIsPublicRow = $dialog.querySelector('#dlg-isPublic-row');
|
|
288
|
+
$dialog.querySelector('#dlg-type').addEventListener('change', e => {
|
|
289
|
+
const isSecret = e.target.value === 'secret';
|
|
290
|
+
$dlgValue.type = isSecret ? 'password' : 'text';
|
|
291
|
+
$dlgIsPublicRow.style.display = isSecret ? 'none' : '';
|
|
292
|
+
if(isSecret) $dlgIsPublic.checked = false;
|
|
293
|
+
});
|
|
283
294
|
});
|
|
284
295
|
}
|
|
285
296
|
</script>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import s from"/kempo-ui/components/LightComponent.js";import{currentUserHasPermission as t,currentUserHasAllPermissions as i,currentUserHasSomePermissions as e}from"../sdk.js";export default class a extends s{static properties={has:{type:String},all:{type:String},some:{type:String},target:{type:String}};constructor(){super(),this.has="",this.all="",this.some="",this.target=""}async connectedCallback(){super.connectedCallback(),this.style.display="none",await this.applyPermission()}async applyPermission(){let s=!1;if(1===[this.has,this.all,this.some].filter(Boolean).length)try{if(this.has){const[i,e]=await t({permission:this.has});s=!i&&e.hasPermission}else if(this.all){const t=this.all.split(",").map(s=>s.trim()),[e,a]=await i({permissions:t});s=!e&&a.hasPermission}else{const t=this.some.split(",").map(s=>s.trim()),[i,a]=await e({permissions:t});s=!i&&a.hasPermission}}catch(t){s=!1}this.setTargetsVisible(s)}setTargetsVisible(s){if(this.target)for(const t of document.querySelectorAll(this.target))t.style.display=s?"":"none"}}window.customElements.define("k-permission-target",a);
|
|
2
|
+
//# sourceMappingURL=C:\Users\dusti\dev\kempo\dist\kempo\components\PermissionTarget.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"src\\kempo\\components\\PermissionTarget.js","names":["LightComponent","currentUserHasPermission","currentUserHasAllPermissions","currentUserHasSomePermissions","PermissionTarget","static","has","type","String","all","some","target","constructor","super","this","connectedCallback","style","display","applyPermission","hasPermission","filter","Boolean","length","error","data","permission","permissions","split","map","p","trim","setTargetsVisible","visible","element","document","querySelectorAll","window","customElements","define"],"sources":["0"],"sourcesContent":["import LightComponent from '/kempo-ui/components/LightComponent.js';\nimport { currentUserHasPermission, currentUserHasAllPermissions, currentUserHasSomePermissions } from '../sdk.js';\n\n/*\n\tSame permission check as k-permission, aimed at other elements instead of its own children.\n\n\tk-permission works by wrapping content and toggling its own display — which only works when\n\twrapping is harmless. Some elements are load-bearing about their position in the DOM: kempo-ui's\n\t<k-tabs> finds its <k-tab>/<k-tab-content> children with a direct-child query, so introducing a\n\twrapper between them breaks tab selection entirely, not just the permission check. This targets\n\tarbitrary elements elsewhere in the document by selector, so the gated elements can stay exactly\n\twhere their own component expects them.\n\n\tRenders nothing itself and never appears in the layout — it is pure wiring, placed wherever is\n\tconvenient (its target does not need to be a descendant, or even nearby).\n*/\nexport default class PermissionTarget extends LightComponent {\n\tstatic properties = {\n\t\thas: { type: String },\n\t\tall: { type: String },\n\t\tsome: { type: String },\n\t\ttarget: { type: String }\n\t};\n\n\tconstructor() {\n\t\tsuper();\n\t\tthis.has = '';\n\t\tthis.all = '';\n\t\tthis.some = '';\n\t\tthis.target = '';\n\t}\n\n\tasync connectedCallback() {\n\t\tsuper.connectedCallback();\n\t\tthis.style.display = 'none';\n\t\tawait this.applyPermission();\n\t}\n\n\tasync applyPermission() {\n\t\tconst attributeCount = [this.has, this.all, this.some].filter(Boolean).length;\n\n\t\tlet hasPermission = false;\n\t\tif(attributeCount === 1) {\n\t\t\ttry {\n\t\t\t\tif(this.has) {\n\t\t\t\t\tconst [error, data] = await currentUserHasPermission({ permission: this.has });\n\t\t\t\t\thasPermission = error ? false : data.hasPermission;\n\t\t\t\t} else if(this.all) {\n\t\t\t\t\tconst permissions = this.all.split(',').map(p => p.trim());\n\t\t\t\t\tconst [error, data] = await currentUserHasAllPermissions({ permissions });\n\t\t\t\t\thasPermission = error ? false : data.hasPermission;\n\t\t\t\t} else {\n\t\t\t\t\tconst permissions = this.some.split(',').map(p => p.trim());\n\t\t\t\t\tconst [error, data] = await currentUserHasSomePermissions({ permissions });\n\t\t\t\t\thasPermission = error ? false : data.hasPermission;\n\t\t\t\t}\n\t\t\t} catch(error) {\n\t\t\t\thasPermission = false;\n\t\t\t}\n\t\t}\n\n\t\tthis.setTargetsVisible(hasPermission);\n\t}\n\n\tsetTargetsVisible(visible) {\n\t\tif(!this.target) return;\n\t\tfor(const element of document.querySelectorAll(this.target)) {\n\t\t\telement.style.display = visible ? '' : 'none';\n\t\t}\n\t}\n}\n\nwindow.customElements.define('k-permission-target', PermissionTarget);\n"],"mappings":"OAAOA,MAAoB,4EAClBC,kCAA0BC,mCAA8BC,MAAqC,2BAevF,MAAMC,UAAyBJ,EAC7CK,kBAAoB,CACnBC,IAAK,CAAEC,KAAMC,QACbC,IAAK,CAAEF,KAAMC,QACbE,KAAM,CAAEH,KAAMC,QACdG,OAAQ,CAAEJ,KAAMC,SAGjB,WAAAI,GACCC,QACAC,KAAKR,IAAM,GACXQ,KAAKL,IAAM,GACXK,KAAKJ,KAAO,GACZI,KAAKH,OAAS,EACf,CAEA,uBAAMI,GACLF,MAAME,oBACND,KAAKE,MAAMC,QAAU,aACfH,KAAKI,iBACZ,CAEA,qBAAMA,GAGL,IAAIC,GAAgB,EACpB,GAAsB,IAHC,CAACL,KAAKR,IAAKQ,KAAKL,IAAKK,KAAKJ,MAAMU,OAAOC,SAASC,OAItE,IACC,GAAGR,KAAKR,IAAK,CACZ,MAAOiB,EAAOC,SAAcvB,EAAyB,CAAEwB,WAAYX,KAAKR,MACxEa,GAAgBI,GAAgBC,EAAKL,aACtC,MAAO,GAAGL,KAAKL,IAAK,CACnB,MAAMiB,EAAcZ,KAAKL,IAAIkB,MAAM,KAAKC,IAAIC,GAAKA,EAAEC,SAC5CP,EAAOC,SAActB,EAA6B,CAAEwB,gBAC3DP,GAAgBI,GAAgBC,EAAKL,aACtC,KAAO,CACN,MAAMO,EAAcZ,KAAKJ,KAAKiB,MAAM,KAAKC,IAAIC,GAAKA,EAAEC,SAC7CP,EAAOC,SAAcrB,EAA8B,CAAEuB,gBAC5DP,GAAgBI,GAAgBC,EAAKL,aACtC,CACD,CAAE,MAAMI,GACPJ,GAAgB,CACjB,CAGDL,KAAKiB,kBAAkBZ,EACxB,CAEA,iBAAAY,CAAkBC,GACjB,GAAIlB,KAAKH,OACT,IAAI,MAAMsB,KAAWC,SAASC,iBAAiBrB,KAAKH,QACnDsB,EAAQjB,MAAMC,QAAUe,EAAU,GAAK,MAEzC,EAGDI,OAAOC,eAAeC,OAAO,sBAAuBlC","ignoreList":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M560-320h80v-80h80v-80h-80v-80h-80v80h-80v80h80v80ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h240l80 80h320q33 0 56.5 23.5T880-640v400q0 33-23.5 56.5T800-160H160Zm0-80h640v-400H447l-80-80H160v480Zm0 0v-480 480Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="currentColor" d="M440-320v-326L336-542l-56-58 200-200 200 200-56 58-104-104v326h-80ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/></svg>
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import db from '../../db/index.js';
|
|
2
2
|
import { setting } from '../../db/schema.js';
|
|
3
|
-
import { eq } from 'drizzle-orm';
|
|
3
|
+
import { eq, and, ne } from 'drizzle-orm';
|
|
4
4
|
import { convertValue } from './helpers.js';
|
|
5
5
|
|
|
6
6
|
export default async () => {
|
|
7
7
|
try {
|
|
8
|
+
// isPublic should never be true on a secret (setSetting forces it false), but this is the
|
|
9
|
+
// one unauthenticated endpoint in the settings system, so exclude 'secret' defensively too.
|
|
8
10
|
const results = await db
|
|
9
11
|
.select()
|
|
10
12
|
.from(setting)
|
|
11
|
-
.where(eq(setting.isPublic, true));
|
|
13
|
+
.where(and(eq(setting.isPublic, true), ne(setting.type, 'secret')));
|
|
12
14
|
|
|
13
15
|
const settings = {};
|
|
14
16
|
for(const s of results){
|
|
@@ -2,12 +2,13 @@ import db from '../../db/index.js';
|
|
|
2
2
|
import { setting } from '../../db/schema.js';
|
|
3
3
|
import { eq } from 'drizzle-orm';
|
|
4
4
|
import { convertValue } from './helpers.js';
|
|
5
|
+
import { SECRET_MASK } from './secretCrypto.js';
|
|
5
6
|
|
|
6
7
|
export default async (owner, name) => {
|
|
7
8
|
if(!owner || !name){
|
|
8
9
|
return [{ code: 400, msg: 'Both owner and name are required' }, null];
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
|
|
11
12
|
try {
|
|
12
13
|
const fullName = `${owner}:${name}`;
|
|
13
14
|
const [result] = await db
|
|
@@ -15,12 +16,18 @@ export default async (owner, name) => {
|
|
|
15
16
|
.from(setting)
|
|
16
17
|
.where(eq(setting.name, fullName))
|
|
17
18
|
.limit(1);
|
|
18
|
-
|
|
19
|
+
|
|
19
20
|
if(!result) return [{ code: 404, msg: 'Setting not found' }, null];
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
This feeds the HTTP API (GET /kempo/api/settings/[owner]/[name]) — the decrypted value must
|
|
24
|
+
never travel over the wire, so a secret is masked here rather than passed through convertValue.
|
|
25
|
+
*/
|
|
21
26
|
return [null, {
|
|
22
27
|
name: result.name,
|
|
23
|
-
value:
|
|
28
|
+
value: result.type === 'secret'
|
|
29
|
+
? (result.value === null ? null : SECRET_MASK)
|
|
30
|
+
: convertValue(result.value, result.type),
|
|
24
31
|
type: result.type,
|
|
25
32
|
isPublic: result.isPublic,
|
|
26
33
|
description: result.description,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { encryptSecret, decryptSecret } from './secretCrypto.js';
|
|
2
|
+
|
|
1
3
|
export const convertValue = (value, type) => {
|
|
2
4
|
if(value === null) return null;
|
|
3
|
-
|
|
5
|
+
|
|
4
6
|
switch(type){
|
|
5
7
|
case 'number':
|
|
6
8
|
return Number(value);
|
|
@@ -8,6 +10,8 @@ export const convertValue = (value, type) => {
|
|
|
8
10
|
return value === 'true';
|
|
9
11
|
case 'json':
|
|
10
12
|
return JSON.parse(value);
|
|
13
|
+
case 'secret':
|
|
14
|
+
return decryptSecret(value);
|
|
11
15
|
case 'string':
|
|
12
16
|
default:
|
|
13
17
|
return value;
|
|
@@ -16,10 +20,12 @@ export const convertValue = (value, type) => {
|
|
|
16
20
|
|
|
17
21
|
export const serializeValue = (value, type) => {
|
|
18
22
|
if(value === null) return null;
|
|
19
|
-
|
|
23
|
+
|
|
20
24
|
switch(type){
|
|
21
25
|
case 'json':
|
|
22
26
|
return JSON.stringify(value);
|
|
27
|
+
case 'secret':
|
|
28
|
+
return encryptSecret(value);
|
|
23
29
|
case 'number':
|
|
24
30
|
case 'boolean':
|
|
25
31
|
case 'string':
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import db from '../../db/index.js';
|
|
2
2
|
import { setting } from '../../db/schema.js';
|
|
3
3
|
import { like, not, and, sql } from 'drizzle-orm';
|
|
4
|
+
import { SECRET_MASK } from './secretCrypto.js';
|
|
4
5
|
|
|
5
6
|
export default async ({ owner, limit = 50, offset = 0 } = {}) => {
|
|
6
7
|
try {
|
|
@@ -16,8 +17,15 @@ export default async ({ owner, limit = 50, offset = 0 } = {}) => {
|
|
|
16
17
|
const q = db.select().from(setting);
|
|
17
18
|
const cq = db.select({ count: sql`count(*)` }).from(setting);
|
|
18
19
|
if(where){ q.where(where); cq.where(where); }
|
|
19
|
-
const
|
|
20
|
+
const results = await q.limit(limit).offset(offset);
|
|
20
21
|
const [{ count }] = await cq;
|
|
22
|
+
|
|
23
|
+
// Never send secret ciphertext to the admin UI, not even encrypted.
|
|
24
|
+
const settings = results.map(s => s.type === 'secret'
|
|
25
|
+
? { ...s, value: s.value === null ? null : SECRET_MASK }
|
|
26
|
+
: s
|
|
27
|
+
);
|
|
28
|
+
|
|
21
29
|
return [null, { settings, total: Number(count), limit, offset }];
|
|
22
30
|
} catch(error){
|
|
23
31
|
return [{ code: 500, msg: 'Failed to retrieve settings' }, null];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
'secret' settings (API keys, webhook signing secrets, etc.) are encrypted at rest with
|
|
5
|
+
AES-256-GCM. SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes) — generate
|
|
6
|
+
one with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
|
|
7
|
+
|
|
8
|
+
Losing or rotating this key makes every stored secret unrecoverable, so it belongs in the same
|
|
9
|
+
place as DATABASE_URL: set once per environment, backed up, never committed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const SECRET_MASK = '••••••••';
|
|
13
|
+
|
|
14
|
+
const getKey = () => {
|
|
15
|
+
const raw = process.env.SETTINGS_ENCRYPTION_KEY;
|
|
16
|
+
if(!raw){
|
|
17
|
+
throw new Error('SETTINGS_ENCRYPTION_KEY environment variable is required to store secret settings');
|
|
18
|
+
}
|
|
19
|
+
const key = Buffer.from(raw, 'hex');
|
|
20
|
+
if(key.length !== 32){
|
|
21
|
+
throw new Error('SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
|
|
22
|
+
}
|
|
23
|
+
return key;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const encryptSecret = value => {
|
|
27
|
+
const iv = crypto.randomBytes(12);
|
|
28
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', getKey(), iv);
|
|
29
|
+
const ciphertext = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
|
|
30
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const decryptSecret = encoded => {
|
|
34
|
+
const data = Buffer.from(encoded, 'base64');
|
|
35
|
+
const iv = data.subarray(0, 12);
|
|
36
|
+
const authTag = data.subarray(12, 28);
|
|
37
|
+
const ciphertext = data.subarray(28);
|
|
38
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', getKey(), iv);
|
|
39
|
+
decipher.setAuthTag(authTag);
|
|
40
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
41
|
+
};
|
|
@@ -2,32 +2,53 @@ import db from '../../db/index.js';
|
|
|
2
2
|
import { setting } from '../../db/schema.js';
|
|
3
3
|
import { eq } from 'drizzle-orm';
|
|
4
4
|
import { serializeValue } from './helpers.js';
|
|
5
|
+
import { SECRET_MASK } from './secretCrypto.js';
|
|
5
6
|
|
|
6
7
|
export default async (owner, name, value, type = null, isPublic = false, description = null) => {
|
|
7
8
|
if(!owner || !name){
|
|
8
9
|
return [{ code: 400, msg: 'Both owner and name are required' }, null];
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
+
|
|
11
12
|
try {
|
|
12
13
|
const fullName = `${owner}:${name}`;
|
|
13
14
|
const detectedType = type || (typeof value === 'object' ? 'json' : typeof value);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const existing = await db
|
|
15
|
+
|
|
16
|
+
const [existing] = await db
|
|
17
17
|
.select()
|
|
18
18
|
.from(setting)
|
|
19
19
|
.where(eq(setting.name, fullName))
|
|
20
20
|
.limit(1);
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
Secrets are never public, and the admin UI only ever shows SECRET_MASK back for an existing
|
|
24
|
+
one — if that's what comes back unchanged, keep the stored ciphertext instead of encrypting
|
|
25
|
+
the mask itself. A masked value with nothing to preserve (no prior secret) means the caller
|
|
26
|
+
never actually supplied a new one.
|
|
27
|
+
*/
|
|
28
|
+
let serializedValue;
|
|
29
|
+
if(detectedType === 'secret'){
|
|
30
|
+
isPublic = false;
|
|
31
|
+
if(value === SECRET_MASK){
|
|
32
|
+
if(!existing || existing.type !== 'secret'){
|
|
33
|
+
return [{ code: 400, msg: 'A secret value is required' }, null];
|
|
34
|
+
}
|
|
35
|
+
serializedValue = existing.value;
|
|
36
|
+
} else {
|
|
37
|
+
serializedValue = serializeValue(value, detectedType);
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
serializedValue = serializeValue(value, detectedType);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if(existing){
|
|
23
44
|
await db
|
|
24
45
|
.update(setting)
|
|
25
|
-
.set({
|
|
46
|
+
.set({
|
|
26
47
|
value: serializedValue,
|
|
27
48
|
type: detectedType,
|
|
28
49
|
isPublic,
|
|
29
50
|
description,
|
|
30
|
-
updatedAt: new Date()
|
|
51
|
+
updatedAt: new Date()
|
|
31
52
|
})
|
|
32
53
|
.where(eq(setting.name, fullName));
|
|
33
54
|
} else {
|
|
@@ -41,8 +62,8 @@ export default async (owner, name, value, type = null, isPublic = false, descrip
|
|
|
41
62
|
updatedAt: new Date()
|
|
42
63
|
});
|
|
43
64
|
}
|
|
44
|
-
|
|
45
|
-
return [null, { name: fullName, value, type: detectedType, isPublic, description }];
|
|
65
|
+
|
|
66
|
+
return [null, { name: fullName, value: detectedType === 'secret' ? SECRET_MASK : value, type: detectedType, isPublic, description }];
|
|
46
67
|
} catch(error){
|
|
47
68
|
return [{ code: 500, msg: 'Failed to save setting' }, null];
|
|
48
69
|
}
|