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.
@@ -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
- settingData.value = value;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo",
3
- "version": "4.2.38",
3
+ "version": "4.2.40",
4
4
  "type": "module",
5
5
  "description": "A fullstack CMS framework for creating websites with user authentication",
6
6
  "scripts": {
@@ -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: convertValue(result.value, result.type),
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 settings = await q.limit(limit).offset(offset);
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
- const serializedValue = serializeValue(value, detectedType);
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
- if(existing.length > 0){
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
  }