@adobe/helix-config-storage 2.10.2 → 2.10.4
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 +14 -0
- package/package.json +6 -6
- package/src/config-store.js +52 -384
- package/src/fragment.js +474 -0
- package/src/utils.js +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [2.10.4](https://github.com/adobe/helix-config-storage/compare/v2.10.3...v2.10.4) (2025-12-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* create version on delete ([#208](https://github.com/adobe/helix-config-storage/issues/208)) ([4725f40](https://github.com/adobe/helix-config-storage/commit/4725f40720bc544cea8dd23038c8addf3445d932))
|
|
7
|
+
|
|
8
|
+
## [2.10.3](https://github.com/adobe/helix-config-storage/compare/v2.10.2...v2.10.3) (2025-12-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **deps:** update external fixes ([#202](https://github.com/adobe/helix-config-storage/issues/202)) ([e9d4694](https://github.com/adobe/helix-config-storage/commit/e9d46942b7807561aa2121aba526109b2f6cb5e6))
|
|
14
|
+
|
|
1
15
|
## [2.10.2](https://github.com/adobe/helix-config-storage/compare/v2.10.1...v2.10.2) (2025-11-27)
|
|
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
|
+
"version": "2.10.4",
|
|
4
4
|
"description": "Helix Config Storage",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -36,18 +36,18 @@
|
|
|
36
36
|
"reporter-options": "configFile=.mocha-multi.json"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@adobe/eslint-config-helix": "3.0.
|
|
40
|
-
"@eslint/config-helpers": "0.
|
|
39
|
+
"@adobe/eslint-config-helix": "3.0.15",
|
|
40
|
+
"@eslint/config-helpers": "0.5.0",
|
|
41
41
|
"@semantic-release/changelog": "6.0.3",
|
|
42
42
|
"@semantic-release/git": "10.0.1",
|
|
43
|
-
"@semantic-release/npm": "13.1.
|
|
43
|
+
"@semantic-release/npm": "13.1.2",
|
|
44
44
|
"ajv-cli": "5.0.0",
|
|
45
45
|
"c8": "10.1.3",
|
|
46
46
|
"eslint": "9.4.0",
|
|
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.
|
|
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.
|
|
72
|
+
"jose": "6.1.2"
|
|
73
73
|
}
|
|
74
74
|
}
|
package/src/config-store.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
migrateToken,
|
|
17
|
+
createUser,
|
|
21
18
|
updateCodeSource,
|
|
22
19
|
updateContentSource,
|
|
23
|
-
deepGetOrCreate,
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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 =
|
|
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
|
|
866
|
-
this.#
|
|
867
|
-
|
|
868
|
-
await this.
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
/**
|
package/src/fragment.js
ADDED
|
@@ -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
|
+
}
|