@adobe/helix-config-storage 2.10.3 → 2.10.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [2.10.5](https://github.com/adobe/helix-config-storage/compare/v2.10.4...v2.10.5) (2025-12-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **deps:** update dependency jose to v6.1.3 ([#210](https://github.com/adobe/helix-config-storage/issues/210)) ([4e4a521](https://github.com/adobe/helix-config-storage/commit/4e4a52106f3b7dde775a52cf0b1b42373c504c59))
7
+
8
+ ## [2.10.4](https://github.com/adobe/helix-config-storage/compare/v2.10.3...v2.10.4) (2025-12-17)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * create version on delete ([#208](https://github.com/adobe/helix-config-storage/issues/208)) ([4725f40](https://github.com/adobe/helix-config-storage/commit/4725f40720bc544cea8dd23038c8addf3445d932))
14
+
1
15
  ## [2.10.3](https://github.com/adobe/helix-config-storage/compare/v2.10.2...v2.10.3) (2025-12-01)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-config-storage",
3
- "version": "2.10.3",
3
+ "version": "2.10.5",
4
4
  "description": "Helix Config Storage",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -36,7 +36,7 @@
36
36
  "reporter-options": "configFile=.mocha-multi.json"
37
37
  },
38
38
  "devDependencies": {
39
- "@adobe/eslint-config-helix": "3.0.14",
39
+ "@adobe/eslint-config-helix": "3.0.15",
40
40
  "@eslint/config-helpers": "0.5.0",
41
41
  "@semantic-release/changelog": "6.0.3",
42
42
  "@semantic-release/git": "10.0.1",
@@ -47,7 +47,7 @@
47
47
  "husky": "9.1.7",
48
48
  "json-schema-to-typescript": "15.0.4",
49
49
  "junit-report-builder": "5.1.1",
50
- "lint-staged": "16.2.6",
50
+ "lint-staged": "16.2.7",
51
51
  "mocha": "11.7.5",
52
52
  "mocha-multi-reporters": "1.5.1",
53
53
  "mocha-suppress-logs": "0.6.0",
@@ -69,6 +69,6 @@
69
69
  "@adobe/helix-shared-utils": "^3.0.2",
70
70
  "ajv": "8.17.1",
71
71
  "ajv-formats": "3.0.1",
72
- "jose": "6.1.2"
72
+ "jose": "6.1.3"
73
73
  }
74
74
  }
@@ -10,197 +10,24 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
  /* eslint-disable no-param-reassign */
13
- import crypto from 'crypto';
14
- import { decodeJwt } from 'jose';
15
13
  import { HelixStorage } from '@adobe/helix-shared-storage';
16
14
  import processQueue from '@adobe/helix-shared-process-queue';
17
15
  import { StatusCodeError } from './status-code-error.js';
18
16
  import {
19
- createToken, createUser,
20
- migrateToken,
17
+ createUser,
21
18
  updateCodeSource,
22
19
  updateContentSource,
23
- deepGetOrCreate, deepPut, prune, createSecret, migrateSecret, isDeepEqual, base64ToBase64Url,
20
+ deepGetOrCreate,
21
+ deepPut,
22
+ prune,
23
+ isDeepEqual,
24
+ validateUniqueEmail,
24
25
  } from './utils.js';
25
26
  import { validate as validateSchema } from './config-validator.js';
26
27
  import { getMergedConfig } from './config-merge.js';
27
28
  import { ValidationError } from './ValidationError.js';
28
29
  import { ConfigVersioning } from './config-versioning.js';
29
-
30
- const FRAGMENTS_COMMON = {
31
- content: 'object',
32
- code: 'object',
33
- folders: 'object',
34
- headers: 'object',
35
- metadata: 'object',
36
- sidekick: 'object',
37
- cdn: {
38
- '.': 'object',
39
- prod: 'object',
40
- preview: 'object',
41
- live: 'object',
42
- },
43
- secrets: {
44
- '.': 'secrets',
45
- '.param': {
46
- name: 'id',
47
- '.': 'secret',
48
- },
49
- '.allowEmpty': true,
50
- },
51
- access: {
52
- '.': 'object',
53
- site: 'object',
54
- admin: {
55
- '.': 'object',
56
- role: {
57
- '.': 'object',
58
- admin: 'object',
59
- author: 'object',
60
- publish: 'object',
61
- develop: 'object',
62
- basic_author: 'object',
63
- basic_publish: 'object',
64
- config: 'object',
65
- config_admin: 'object',
66
- },
67
- },
68
- preview: 'object',
69
- live: 'object',
70
- },
71
- tokens: {
72
- '.': 'tokens',
73
- '.param': {
74
- name: 'id',
75
- '.': 'token',
76
- },
77
- '.allowEmpty': true,
78
- },
79
- groups: {
80
- '.': 'object',
81
- '.param': {
82
- '.': 'object',
83
- members: 'object',
84
- },
85
- },
86
- public: '*',
87
- features: {
88
- '.': '*',
89
- '.allowEmpty': true,
90
-
91
- },
92
- limits: {
93
- '.': '*',
94
- '.allowEmpty': true,
95
- },
96
- robots: 'object',
97
- events: {
98
- github: 'object',
99
- },
100
- };
101
-
102
- const FRAGMENTS = {
103
- sites: {
104
- ...FRAGMENTS_COMMON,
105
- extends: 'object',
106
- apiKeys: {
107
- '.': 'apiKeys',
108
- '.param': {
109
- name: 'id',
110
- '.': 'apiKey',
111
- },
112
- },
113
- },
114
- profiles: FRAGMENTS_COMMON,
115
- org: {
116
- secrets: {
117
- '.': 'secrets',
118
- '.param': {
119
- name: 'id',
120
- '.': 'secret',
121
- },
122
- '.allowEmpty': true,
123
- },
124
- apiKeys: {
125
- '.': 'apiKeys',
126
- '.param': {
127
- name: 'id',
128
- '.': 'apiKey',
129
- },
130
- '.allowEmpty': true,
131
- },
132
- tokens: {
133
- '.': 'tokens',
134
- '.param': {
135
- name: 'id',
136
- '.': 'token',
137
- },
138
- '.allowEmpty': true,
139
- },
140
- users: {
141
- '.': 'users',
142
- '.param': {
143
- name: 'id',
144
- '.': 'user',
145
- },
146
- },
147
- groups: {
148
- '.': 'object',
149
- '.param': {
150
- '.': 'object',
151
- members: 'object',
152
- },
153
- },
154
- access: {
155
- '.': 'object',
156
- admin: 'object',
157
- },
158
- },
159
- };
160
-
161
- /**
162
- * Returns the fragment info for a given type and relative path.
163
- * @param {string} type
164
- * @param {string|array} relPath
165
- * @returns {{relPath}|null}
166
- */
167
- export function getFragmentInfo(type, relPath = '') {
168
- if (!relPath) {
169
- return null;
170
- }
171
- relPath = relPath.split('/');
172
- let fragment = FRAGMENTS[type];
173
- const info = {
174
- relPath,
175
- name: relPath[relPath.length - 1],
176
- };
177
- for (const part of relPath) {
178
- let next = fragment[part];
179
- if (next === '*') {
180
- fragment = '*';
181
- break;
182
- }
183
- if (typeof next === 'object' && next['.'] === '*') {
184
- fragment = next;
185
- break;
186
- }
187
- if (!next) {
188
- next = fragment['.param'];
189
- if (!next) {
190
- throw new StatusCodeError(400, 'invalid object path.');
191
- }
192
- info[next.name] = part;
193
- }
194
- fragment = next;
195
- }
196
- if (typeof fragment === 'string') {
197
- info.type = fragment;
198
- } else {
199
- info.type = fragment['.'];
200
- }
201
- info.allowEmpty = fragment['.allowEmpty'] || false;
202
- return info;
203
- }
30
+ import { Fragment } from './fragment.js';
204
31
 
205
32
  /**
206
33
  * Redact / transform the secret config
@@ -248,54 +75,6 @@ function redact(config, frag) {
248
75
  return ret;
249
76
  }
250
77
 
251
- /**
252
- * Updates a secret based on its type. if type is 'key' the value is updated.
253
- * @param {string} type
254
- * @param {string} id
255
- * @param {object} oldData
256
- * @param {object} data
257
- * @returns {object} the updated secret data
258
- */
259
- function updateSecret(type, id, oldData, data) {
260
- oldData.type = type;
261
- oldData.id = id;
262
- const now = new Date().toISOString();
263
-
264
- // keep the description if missing in data
265
- if ('description' in data) {
266
- oldData.description = data.description;
267
- }
268
-
269
- // handle value specifically, as we don't allow deletion
270
- if (type === 'key' && data.value) {
271
- oldData.value = data.value;
272
- oldData.lastModified = now;
273
- }
274
- if (!oldData.created) {
275
- oldData.created = now;
276
- }
277
- if (!oldData.lastModified) {
278
- oldData.lastModified = oldData.created;
279
- }
280
- return prune(oldData);
281
- }
282
-
283
- /**
284
- * Ensures that the given email is not used by another user.
285
- * @param {string} email
286
- * @param {User[]} users
287
- * @throws {StatusCodeError} a 400 status code error if the email is already used
288
- */
289
- function validateUniqueEmail(email, users) {
290
- if (!email) {
291
- throw new StatusCodeError(400, 'missing email');
292
- }
293
- const existing = users.find((user) => user.email === email);
294
- if (existing) {
295
- throw new StatusCodeError(400, `email already in use by '${existing.id}'`);
296
- }
297
- }
298
-
299
78
  /**
300
79
  * General purpose config store.
301
80
  */
@@ -471,7 +250,7 @@ export class ConfigStore {
471
250
 
472
251
  /**
473
252
  * Checks if the permissions allow to update the config.
474
- * @param {AdminConfig} ctx
253
+ * @param {UniversalContext} ctx
475
254
  * @param {object} oldConfig
476
255
  * @param {object} newConfig
477
256
  * @returns {Promise<void>}
@@ -534,6 +313,14 @@ export class ConfigStore {
534
313
  }
535
314
  }
536
315
 
316
+ /**
317
+ * Creates a new config entry.
318
+ * @param {Object} ctx - The context object.
319
+ * @param {Object} data - The configuration data to create.
320
+ * @param {string} [relPath=''] - The relative path for substructures (not supported).
321
+ * @returns {Promise<void>}
322
+ * @throws {StatusCodeError} If creation is not supported or config already exists.
323
+ */
537
324
  async create(ctx, data, relPath = '') {
538
325
  if (relPath) {
539
326
  throw new StatusCodeError(409, 'create not supported on substructures.');
@@ -586,6 +373,12 @@ export class ConfigStore {
586
373
  await this.purge(ctx, null, config);
587
374
  }
588
375
 
376
+ /**
377
+ * Reads the config or a substructure based on the relative path.
378
+ * @param {Object} ctx - The context object.
379
+ * @param {string} [relPath=''] - The relative path for substructures or versions.
380
+ * @returns {Promise<Object|null>} The config object or null if not found.
381
+ */
589
382
  async read(ctx, relPath = '') {
590
383
  if (this.name === '' && (this.type === 'sites' || this.type === 'profiles')) {
591
384
  return this.#list(ctx);
@@ -605,22 +398,30 @@ export class ConfigStore {
605
398
  return null;
606
399
  }
607
400
  let obj = JSON.parse(buf);
608
- const frag = getFragmentInfo(this.type, relPath);
401
+ const frag = Fragment.createFragment(this.org, this.type, relPath);
609
402
  if (frag) {
610
403
  obj = deepGetOrCreate(obj, frag.relPath);
611
404
  }
612
405
  return redact(obj, frag) ?? null;
613
406
  }
614
407
 
408
+ /**
409
+ * Updates the config or a substructure based on the relative path.
410
+ * @param {Object} ctx - The context object.
411
+ * @param {Object} data - The configuration data to update.
412
+ * @param {string} [relPath=''] - The relative path for substructures or versions.
413
+ * @returns {Promise<Object|null>} The updated config object or null.
414
+ */
615
415
  async update(ctx, data, relPath = '') {
616
416
  if (relPath.startsWith('versions/')) {
617
417
  return this.#versioning.updateVersion(ctx, Number.parseInt(relPath.substring('versions/'.length), 10), data.name);
618
418
  }
619
- const frag = getFragmentInfo(this.type, relPath);
419
+ const frag = Fragment.createFragment(this.org, this.type, relPath);
620
420
  const { restoreVersion } = data ?? {};
621
421
  if (restoreVersion && frag) {
622
422
  throw new StatusCodeError(400, 'restoreVersion not allowed with object path.');
623
423
  }
424
+
624
425
  const storage = HelixStorage.fromContext(ctx).configBus();
625
426
  const buf = await storage.get(this.key);
626
427
  let old = buf ? JSON.parse(buf) : null;
@@ -638,10 +439,10 @@ export class ConfigStore {
638
439
  config = null;
639
440
  }
640
441
 
641
- let ret = null;
642
442
  if (!config && frag?.type !== 'secrets' && frag?.type !== 'secret' && frag?.type !== 'tokens' && frag?.type !== 'token') {
643
443
  throw new StatusCodeError(400, 'no config in body.');
644
444
  }
445
+ let returnValue = null;
645
446
  if (frag) {
646
447
  if (!old) {
647
448
  if (this.type === 'profiles' && frag.allowEmpty) {
@@ -650,140 +451,9 @@ export class ConfigStore {
650
451
  throw new StatusCodeError(404, 'config not found.');
651
452
  }
652
453
  }
653
- if (frag.type === 'token') {
654
- // don't allow to update individual token
655
- throw new StatusCodeError(400, 'invalid object path.');
656
- } else if (frag.type === 'tokens') {
657
- // TODO: remove support after all helix4 projects are migrated
658
- let token;
659
- if (data.jwt) {
660
- token = await migrateToken(this.org, data.jwt);
661
- } else {
662
- // create new token
663
- token = createToken(this.org);
664
- }
665
-
666
- data = {
667
- ...token,
668
- };
669
- // don't store token value in the config
670
- delete data.value;
671
- frag.name = token.id;
672
- frag.type = 'token';
673
- frag.relPath.push(frag.name);
674
- ret = token;
675
- // don't expose hash in return value
676
- delete ret.hash;
677
- }
678
- if (frag.type === 'apiKeys') {
679
- if (!data.jwt) {
680
- throw new StatusCodeError(400, 'jwt missing for new keys');
681
- }
682
- try {
683
- const payload = await decodeJwt(data.jwt);
684
- data.id = payload.jti;
685
- data.roles = payload.roles;
686
- data.subject = payload.sub;
687
- data.expiration = new Date(payload.exp * 1000).toISOString();
688
- delete data.jwt;
689
- } catch (e) {
690
- throw new StatusCodeError(400, e.message);
691
- }
692
- data.created = new Date().toISOString();
693
- frag.name = base64ToBase64Url(data.id);
694
- frag.type = 'apiKey';
695
- frag.relPath.push(frag.name);
696
- } else if (frag.type === 'apiKey') {
697
- if (Object.keys(data).some((key) => key !== 'description')) {
698
- throw new StatusCodeError(400, 'not allowed to alter properties other than "description" in apiKey');
699
- }
700
- const oldData = deepGetOrCreate(old, frag.relPath, false);
701
- if (!oldData) {
702
- throw new StatusCodeError(404, 'object not found.');
703
- }
704
- data = Object.assign(oldData, data);
705
- }
706
- if (frag.type === 'secrets') {
707
- // create new secret with random id
708
- frag.name = crypto.randomBytes(32).toString('base64url');
709
- frag.type = 'secret';
710
- frag.relPath.push(frag.name);
711
- }
712
- if (frag.type === 'secret') {
713
- const oldData = deepGetOrCreate(old, frag.relPath);
714
- if (oldData) {
715
- if (oldData.type === 'hashed') {
716
- data = updateSecret('hashed', frag.name, oldData, data);
717
- } else {
718
- data = updateSecret('key', frag.name, oldData, data);
719
- }
720
- } else {
721
- if (data.value) {
722
- data = updateSecret('key', frag.name, { id: frag.name }, data);
723
- } else {
724
- // TODO: remove support after all helix4 projects are migrated
725
- let token;
726
- if (data.jwt) {
727
- token = await migrateSecret(this.org, data.jwt);
728
- frag.name = token.id;
729
- frag.relPath.splice(-1, 1, token.id);
730
- } else {
731
- // create new token
732
- token = createSecret(this.org, 'hlx', data);
733
- token.id = frag.name;
734
- }
735
- data = {
736
- ...token,
737
- };
738
- // don't store token value in the config
739
- delete data.value;
740
- ret = token;
741
- // don't expose hash in return value
742
- delete ret.hash;
743
- }
744
- data = prune(data);
745
- }
746
- } else if (frag.type === 'users') {
747
- // todo: define via "schema"
748
- if (!old.users) {
749
- old.users = [];
750
- }
751
- if (Array.isArray(data)) {
752
- const users = [...old.users];
753
- for (const userData of data) {
754
- validateUniqueEmail(userData.email, users);
755
- users.push(Object.assign(
756
- Object.create(null),
757
- createUser(),
758
- userData,
759
- ));
760
- }
761
- data = users;
762
- } else {
763
- data = Object.assign(
764
- Object.create(null),
765
- createUser(),
766
- data,
767
- );
768
- validateUniqueEmail(data.email, old.users);
769
- frag.name = data.id;
770
- frag.relPath.push(data.id);
771
- frag.type = 'user';
772
- }
773
- } else if (frag.type === 'user') {
774
- const user = deepGetOrCreate(old, frag.relPath);
775
- if (!user) {
776
- throw new StatusCodeError(404, 'object not found.');
777
- }
778
- if (data.id) {
779
- if (data.id !== user.id) {
780
- throw new StatusCodeError(400, 'object id mismatch.');
781
- }
782
- } else {
783
- data.id = user.id;
784
- }
785
- }
786
- config = deepPut(old, frag.relPath, data);
454
+ const result = await Fragment.applyFragment(old, frag, data);
455
+ config = result.config;
456
+ returnValue = result.returnValue;
787
457
  } else if (config) {
788
458
  if (!config.features && old?.features) {
789
459
  config.features = old.features;
@@ -805,6 +475,11 @@ export class ConfigStore {
805
475
  updateCodeSource(ctx, config.code);
806
476
  }
807
477
 
478
+ await this.#store(ctx, storage, buf, config, restoreVersion, versionName);
479
+ return returnValue ?? redact(deepGetOrCreate(config, frag?.relPath || []), frag);
480
+ }
481
+
482
+ async #store(ctx, storage, buf, config, restoreVersion = false, versionName = '') {
808
483
  let oldConfig = null;
809
484
  let modified = true;
810
485
  if (buf) {
@@ -840,7 +515,6 @@ export class ConfigStore {
840
515
  }
841
516
  await storage.put(this.key, JSON.stringify(config), 'application/json');
842
517
  await this.purge(ctx, purgeConfig, newConfig);
843
- return ret ?? redact(data, frag);
844
518
  }
845
519
 
846
520
  async remove(ctx, relPath) {
@@ -854,25 +528,19 @@ export class ConfigStore {
854
528
  if (!buf) {
855
529
  throw new StatusCodeError(404, 'config not found.');
856
530
  }
857
- const frag = getFragmentInfo(this.type, relPath);
858
- let oldConfig = JSON.parse(buf);
859
- if (this.type === 'sites') {
860
- // apply profile
861
- oldConfig = await this.getAggregatedConfig(ctx, oldConfig);
862
- }
863
-
531
+ const frag = Fragment.createFragment(this.org, this.type, relPath);
864
532
  if (frag) {
865
- const data = deepPut(JSON.parse(buf), relPath, null);
866
- this.#updateTimeStamps(data);
867
- const newConfig = await this.getAggregatedConfig(ctx, data);
868
- await this.validate(ctx, newConfig);
869
- await storage.put(this.key, JSON.stringify(data), 'application/json');
870
- await this.purge(ctx, oldConfig, newConfig);
871
- return;
533
+ const config = deepPut(JSON.parse(buf), relPath, null);
534
+ await this.#store(ctx, storage, buf, config);
535
+ } else {
536
+ await storage.remove(this.key);
537
+ let oldConfig = JSON.parse(buf);
538
+ if (this.type === 'sites') {
539
+ // apply profile
540
+ oldConfig = await this.getAggregatedConfig(ctx, oldConfig);
541
+ }
542
+ await this.purge(ctx, oldConfig, null);
872
543
  }
873
-
874
- await storage.remove(this.key);
875
- await this.purge(ctx, oldConfig, null);
876
544
  }
877
545
 
878
546
  /**
@@ -0,0 +1,474 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ /* eslint-disable no-param-reassign */
13
+ import crypto from 'crypto';
14
+ import { decodeJwt } from 'jose';
15
+ import { StatusCodeError } from './status-code-error.js';
16
+ import {
17
+ createToken,
18
+ createUser,
19
+ migrateToken,
20
+ deepGetOrCreate,
21
+ prune,
22
+ createSecret,
23
+ migrateSecret,
24
+ base64ToBase64Url,
25
+ validateUniqueEmail,
26
+ deepPut,
27
+ } from './utils.js';
28
+
29
+ const FRAGMENTS_COMMON = {
30
+ content: 'object',
31
+ code: 'object',
32
+ folders: 'object',
33
+ headers: 'object',
34
+ metadata: 'object',
35
+ sidekick: 'object',
36
+ cdn: {
37
+ '.': 'object',
38
+ prod: 'object',
39
+ preview: 'object',
40
+ live: 'object',
41
+ },
42
+ secrets: {
43
+ '.': 'secrets',
44
+ '.param': {
45
+ name: 'id',
46
+ '.': 'secret',
47
+ },
48
+ '.allowEmpty': true,
49
+ },
50
+ access: {
51
+ '.': 'object',
52
+ site: 'object',
53
+ admin: {
54
+ '.': 'object',
55
+ role: {
56
+ '.': 'object',
57
+ admin: 'object',
58
+ author: 'object',
59
+ publish: 'object',
60
+ develop: 'object',
61
+ basic_author: 'object',
62
+ basic_publish: 'object',
63
+ config: 'object',
64
+ config_admin: 'object',
65
+ },
66
+ },
67
+ preview: 'object',
68
+ live: 'object',
69
+ },
70
+ tokens: {
71
+ '.': 'tokens',
72
+ '.param': {
73
+ name: 'id',
74
+ '.': 'token',
75
+ },
76
+ '.allowEmpty': true,
77
+ },
78
+ groups: {
79
+ '.': 'object',
80
+ '.param': {
81
+ '.': 'object',
82
+ members: 'object',
83
+ },
84
+ },
85
+ public: '*',
86
+ features: {
87
+ '.': '*',
88
+ '.allowEmpty': true,
89
+
90
+ },
91
+ limits: {
92
+ '.': '*',
93
+ '.allowEmpty': true,
94
+ },
95
+ robots: 'object',
96
+ events: {
97
+ github: 'object',
98
+ },
99
+ };
100
+
101
+ const FRAGMENTS = {
102
+ sites: {
103
+ ...FRAGMENTS_COMMON,
104
+ extends: 'object',
105
+ apiKeys: {
106
+ '.': 'apiKeys',
107
+ '.param': {
108
+ name: 'id',
109
+ '.': 'apiKey',
110
+ },
111
+ },
112
+ },
113
+ profiles: FRAGMENTS_COMMON,
114
+ org: {
115
+ secrets: {
116
+ '.': 'secrets',
117
+ '.param': {
118
+ name: 'id',
119
+ '.': 'secret',
120
+ },
121
+ '.allowEmpty': true,
122
+ },
123
+ apiKeys: {
124
+ '.': 'apiKeys',
125
+ '.param': {
126
+ name: 'id',
127
+ '.': 'apiKey',
128
+ },
129
+ '.allowEmpty': true,
130
+ },
131
+ tokens: {
132
+ '.': 'tokens',
133
+ '.param': {
134
+ name: 'id',
135
+ '.': 'token',
136
+ },
137
+ '.allowEmpty': true,
138
+ },
139
+ users: {
140
+ '.': 'users',
141
+ '.param': {
142
+ name: 'id',
143
+ '.': 'user',
144
+ },
145
+ },
146
+ groups: {
147
+ '.': 'object',
148
+ '.param': {
149
+ '.': 'object',
150
+ members: 'object',
151
+ },
152
+ },
153
+ access: {
154
+ '.': 'object',
155
+ admin: 'object',
156
+ },
157
+ },
158
+ };
159
+
160
+ /**
161
+ * Updates a secret based on its type. if type is 'key' the value is updated.
162
+ * @param {string} type
163
+ * @param {string} id
164
+ * @param {object} oldData
165
+ * @param {object} data
166
+ * @returns {object} the updated secret data
167
+ */
168
+ function updateSecret(type, id, oldData, data) {
169
+ oldData.type = type;
170
+ oldData.id = id;
171
+ const now = new Date().toISOString();
172
+
173
+ // keep the description if missing in data
174
+ if ('description' in data) {
175
+ oldData.description = data.description;
176
+ }
177
+
178
+ // handle value specifically, as we don't allow deletion
179
+ if (type === 'key' && data.value) {
180
+ oldData.value = data.value;
181
+ oldData.lastModified = now;
182
+ }
183
+ if (!oldData.created) {
184
+ oldData.created = now;
185
+ }
186
+ if (!oldData.lastModified) {
187
+ oldData.lastModified = oldData.created;
188
+ }
189
+ return prune(oldData);
190
+ }
191
+
192
+ export class Fragment {
193
+ /**
194
+ * Fragment name
195
+ * @type string
196
+ */
197
+ name;
198
+
199
+ /**
200
+ * Fragment type
201
+ * @type string
202
+ */
203
+ type;
204
+
205
+ /**
206
+ * Config type
207
+ * @type string
208
+ */
209
+ configType;
210
+
211
+ /**
212
+ * object path segments
213
+ * @type [string]
214
+ */
215
+ relPath;
216
+
217
+ /**
218
+ * org of the config
219
+ * @type string
220
+ */
221
+ org;
222
+
223
+ /**
224
+ * Returns the fragment info for a given type and relative path.
225
+ * @param {string} org config org
226
+ * @param {string} configType type of the config, org|site|profile
227
+ * @param {string|array} relPath
228
+ * @returns {Fragment|null}
229
+ */
230
+ static createFragment(org, configType, relPath = '') {
231
+ if (!relPath) {
232
+ return null;
233
+ }
234
+ relPath = relPath.split('/');
235
+ let fragment = FRAGMENTS[configType];
236
+ const info = new Fragment(org, configType, relPath);
237
+ for (const part of relPath) {
238
+ let next = fragment[part];
239
+ if (next === '*') {
240
+ fragment = '*';
241
+ break;
242
+ }
243
+ if (typeof next === 'object' && next['.'] === '*') {
244
+ fragment = next;
245
+ break;
246
+ }
247
+ if (!next) {
248
+ next = fragment['.param'];
249
+ if (!next) {
250
+ throw new StatusCodeError(400, 'invalid object path.');
251
+ }
252
+ info[next.name] = part;
253
+ }
254
+ fragment = next;
255
+ }
256
+ if (typeof fragment === 'string') {
257
+ info.type = fragment;
258
+ } else {
259
+ info.type = fragment['.'];
260
+ }
261
+ info.allowEmpty = fragment['.allowEmpty'] || false;
262
+ return info;
263
+ }
264
+
265
+ constructor(org, configType, relPath) {
266
+ this.org = org;
267
+ this.configType = configType;
268
+ this.relPath = relPath;
269
+ this.name = relPath.at(-1);
270
+ }
271
+
272
+ static async applyFragment(oldConfig, frag, payload) {
273
+ let result = await frag.#preProcess(oldConfig, payload);
274
+ if (result.reprocess) {
275
+ result = await frag.#preProcess(oldConfig, result.data);
276
+ }
277
+ return {
278
+ config: deepPut(oldConfig, [...frag.relPath], result.data),
279
+ ...result,
280
+ };
281
+ }
282
+
283
+ #handlers = {
284
+ token: this.#updateToken,
285
+ tokens: this.#updateTokens,
286
+ apiKey: this.#updateApiKey,
287
+ apiKeys: this.#updateApiKeys,
288
+ user: this.#updateUser,
289
+ users: this.#updateUsers,
290
+ secret: this.#updateSecret,
291
+ secrets: this.#updateSecrets,
292
+ };
293
+
294
+ async #preProcess(oldConfig, data) {
295
+ if (this.#handlers[this.type]) {
296
+ return this.#handlers[this.type].bind(this)(oldConfig, data);
297
+ }
298
+ return {
299
+ data,
300
+ };
301
+ }
302
+
303
+ // eslint-disable-next-line class-methods-use-this,no-unused-vars
304
+ #updateToken(oldConfig, data) {
305
+ // don't allow to update individual token
306
+ throw new StatusCodeError(400, 'invalid object path.');
307
+ }
308
+
309
+ async #updateTokens(oldConfig, data) {
310
+ // TODO: remove support after all helix4 projects are migrated
311
+ let token;
312
+ if (data.jwt) {
313
+ token = await migrateToken(this.org, data.jwt);
314
+ } else {
315
+ // create new token
316
+ token = createToken(this.org);
317
+ }
318
+
319
+ data = {
320
+ ...token,
321
+ };
322
+ // don't store token value in the config
323
+ delete data.value;
324
+ this.name = token.id;
325
+ this.type = 'token';
326
+ this.relPath.push(this.name);
327
+ const ret = token;
328
+ // don't expose hash in return value
329
+ delete ret.hash;
330
+ return {
331
+ data,
332
+ returnValue: ret,
333
+ };
334
+ }
335
+
336
+ async #updateApiKeys(oldConfig, data) {
337
+ if (!data.jwt) {
338
+ throw new StatusCodeError(400, 'jwt missing for new keys');
339
+ }
340
+ try {
341
+ const payload = await decodeJwt(data.jwt);
342
+ data.id = payload.jti;
343
+ data.roles = payload.roles;
344
+ data.subject = payload.sub;
345
+ data.expiration = new Date(payload.exp * 1000).toISOString();
346
+ delete data.jwt;
347
+ } catch (e) {
348
+ throw new StatusCodeError(400, e.message);
349
+ }
350
+ data.created = new Date().toISOString();
351
+ this.name = base64ToBase64Url(data.id);
352
+ this.type = 'apiKey';
353
+ this.relPath.push(this.name);
354
+ return {
355
+ data,
356
+ };
357
+ }
358
+
359
+ async #updateApiKey(oldConfig, data) {
360
+ if (Object.keys(data).some((key) => key !== 'description')) {
361
+ throw new StatusCodeError(400, 'not allowed to alter properties other than "description" in apiKey');
362
+ }
363
+ const oldData = deepGetOrCreate(oldConfig, this.relPath, false);
364
+ if (!oldData) {
365
+ throw new StatusCodeError(404, 'object not found.');
366
+ }
367
+ return {
368
+ data: Object.assign(oldData, data),
369
+ };
370
+ }
371
+
372
+ async #updateSecrets(oldConfig, data) {
373
+ this.name = crypto.randomBytes(32).toString('base64url');
374
+ this.type = 'secret';
375
+ this.relPath.push(this.name);
376
+ return {
377
+ data,
378
+ // tell processor to re-apply the data to the next (secret) handler.
379
+ reprocess: true,
380
+ };
381
+ }
382
+
383
+ async #updateSecret(oldConfig, data) {
384
+ const oldData = deepGetOrCreate(oldConfig, this.relPath);
385
+ let returnValue;
386
+
387
+ if (oldData) {
388
+ if (oldData.type === 'hashed') {
389
+ data = updateSecret('hashed', this.name, oldData, data);
390
+ } else {
391
+ data = updateSecret('key', this.name, oldData, data);
392
+ }
393
+ } else {
394
+ if (data.value) {
395
+ data = updateSecret('key', this.name, { id: this.name }, data);
396
+ } else {
397
+ // TODO: remove support after all helix4 projects are migrated
398
+ let token;
399
+ if (data.jwt) {
400
+ token = await migrateSecret(this.org, data.jwt);
401
+ this.name = token.id;
402
+ this.relPath.splice(-1, 1, token.id);
403
+ } else {
404
+ // create new token
405
+ token = createSecret(this.org, 'hlx', data);
406
+ token.id = this.name;
407
+ }
408
+ data = {
409
+ ...token,
410
+ };
411
+ // don't store token value in the config
412
+ delete data.value;
413
+ returnValue = token;
414
+ // don't expose hash in return value
415
+ delete returnValue.hash;
416
+ }
417
+ data = prune(data);
418
+ }
419
+ return {
420
+ data,
421
+ returnValue,
422
+ };
423
+ }
424
+
425
+ async #updateUsers(oldConfig, data) {
426
+ // todo: define via "schema"
427
+ if (!oldConfig.users) {
428
+ oldConfig.users = [];
429
+ }
430
+ if (Array.isArray(data)) {
431
+ const users = [...oldConfig.users];
432
+ for (const userData of data) {
433
+ validateUniqueEmail(userData.email, users);
434
+ users.push(Object.assign(
435
+ Object.create(null),
436
+ createUser(),
437
+ userData,
438
+ ));
439
+ }
440
+ return {
441
+ data: users,
442
+ };
443
+ }
444
+
445
+ data = Object.assign(
446
+ Object.create(null),
447
+ createUser(),
448
+ data,
449
+ );
450
+ validateUniqueEmail(data.email, oldConfig.users);
451
+ this.name = data.id;
452
+ this.relPath.push(data.id);
453
+ this.type = 'user';
454
+ return {
455
+ data,
456
+ };
457
+ }
458
+
459
+ async #updateUser(oldConfig, data) {
460
+ const user = deepGetOrCreate(oldConfig, this.relPath);
461
+ if (!user) {
462
+ throw new StatusCodeError(404, 'object not found.');
463
+ }
464
+ if (data.id && data.id !== user.id) {
465
+ throw new StatusCodeError(400, 'object id mismatch.');
466
+ }
467
+ return {
468
+ data: {
469
+ ...data,
470
+ id: user.id,
471
+ },
472
+ };
473
+ }
474
+ }
package/src/utils.js CHANGED
@@ -328,3 +328,19 @@ export function deepPut(obj, path, value) {
328
328
  export function isDeepEqual(o0, o1) {
329
329
  return JSON.stringify(o0) === JSON.stringify(o1);
330
330
  }
331
+
332
+ /**
333
+ * Ensures that the given email is not used by another user.
334
+ * @param {string} email
335
+ * @param {User[]} users
336
+ * @throws {StatusCodeError} a 400 status code error if the email is already used
337
+ */
338
+ export function validateUniqueEmail(email, users) {
339
+ if (!email) {
340
+ throw new StatusCodeError(400, 'missing email');
341
+ }
342
+ const existing = users.find((user) => user.email === email);
343
+ if (existing) {
344
+ throw new StatusCodeError(400, `email already in use by '${existing.id}'`);
345
+ }
346
+ }