@adobe/helix-config 3.1.2 → 3.2.0

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,10 @@
1
+ # [3.2.0](https://github.com/adobe/helix-config/compare/v3.1.2...v3.2.0) (2024-05-29)
2
+
3
+
4
+ ### Features
5
+
6
+ * add support for org users storage and view ([0b65a1d](https://github.com/adobe/helix-config/commit/0b65a1df91c4271a6c2ab99fb669381207141a24))
7
+
1
8
  ## [3.1.2](https://github.com/adobe/helix-config/compare/v3.1.1...v3.1.2) (2024-05-22)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-config",
3
- "version": "3.1.2",
3
+ "version": "3.2.0",
4
4
  "description": "Helix Config",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "husky": "9.0.11",
47
47
  "json-schema-to-typescript": "14.0.4",
48
48
  "junit-report-builder": "3.2.1",
49
- "lint-staged": "15.2.2",
49
+ "lint-staged": "15.2.5",
50
50
  "mocha": "10.4.0",
51
51
  "mocha-multi-reporters": "1.5.1",
52
52
  "mocha-suppress-logs": "0.5.1",
@@ -21,6 +21,7 @@ import {
21
21
  } from './ConfigContext.js';
22
22
  import { resolveLegacyConfig, fetchRobotsTxt, toArray } from './config-legacy.js';
23
23
  import { getMergedConfig } from './config-merge.js';
24
+ import { deepGetOrCreate } from './storage/utils.js';
24
25
 
25
26
  /**
26
27
  * @typedef Config
@@ -55,6 +56,10 @@ const VALID_SCOPES = [
55
56
  SCOPE_PUBLIC,
56
57
  ];
57
58
 
59
+ const VALID_ORG_SCOPES = [
60
+ SCOPE_ADMIN,
61
+ ];
62
+
58
63
  /**
59
64
  * Creates a string representation of the given array that is suitable for substring matching by
60
65
  * delimiting each entry with `,` eg: ,foo@adobe.com,bar@adobe.com,
@@ -253,20 +258,39 @@ async function getSurrogateKey(opts, profile) {
253
258
  const { site, org } = opts;
254
259
  const keys = [
255
260
  await computeSurrogateKey(`${org}_config.json`),
256
- await computeSurrogateKey(`${site}--${org}_config.json`),
257
261
  ];
262
+ if (site) {
263
+ keys.push(await computeSurrogateKey(`${site}--${org}_config.json`));
264
+ }
258
265
  if (profile) {
259
266
  keys.push(await computeSurrogateKey(`p:${profile}--${org}_config.json`));
260
267
  }
261
268
  return keys.join(' ');
262
269
  }
263
270
 
264
- async function loadOrgConfig(ctx, org) {
271
+ export async function loadOrgConfig(ctx, org) {
265
272
  const key = `orgs/${org}/config.json`;
266
273
  const res = await ctx.loader.getObject(HELIX_CONFIG_BUS, key);
267
274
  return res.body ? res.json() : null;
268
275
  }
269
276
 
277
+ function computeAdminRoles(adminConfig, orgConfig) {
278
+ if (orgConfig?.users) {
279
+ // map users[].roles[] to access.admin.role[].email
280
+ const rolesObj = deepGetOrCreate(adminConfig, ['access', 'admin', 'role'], true);
281
+ for (const user of orgConfig.users) {
282
+ for (const role of user.roles) {
283
+ if (!(role in rolesObj)) {
284
+ rolesObj[role] = [];
285
+ }
286
+ if (!rolesObj[role].includes(user.email)) {
287
+ rolesObj[role].push(user.email);
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+
270
294
  export async function getConfigResponse(ctx, opts) {
271
295
  const {
272
296
  ref, site, org, scope,
@@ -361,6 +385,7 @@ export async function getConfigResponse(ctx, opts) {
361
385
  ...config.content.source,
362
386
  },
363
387
  };
388
+ computeAdminRoles(adminConfig, orgConfig);
364
389
  delete adminConfig.public;
365
390
  delete adminConfig.robots;
366
391
  return new PipelineResponse(JSON.stringify(adminConfig, null, 2), {
@@ -420,3 +445,45 @@ export async function getConfigResponse(ctx, opts) {
420
445
  },
421
446
  });
422
447
  }
448
+
449
+ export async function getOrgConfigResponse(ctx, opts) {
450
+ const { org, scope } = opts;
451
+ if (!VALID_ORG_SCOPES.includes(scope)) {
452
+ return new PipelineResponse('', {
453
+ status: 400,
454
+ headers: {
455
+ 'x-error': 'invalid scope',
456
+ },
457
+ });
458
+ }
459
+ const surrogateHeaders = {
460
+ 'x-surrogate-key': await getSurrogateKey(opts),
461
+ };
462
+
463
+ const orgConfig = await loadOrgConfig(ctx, org);
464
+ if (!orgConfig) {
465
+ return new PipelineResponse('', {
466
+ status: 404,
467
+ headers: {
468
+ 'x-error': 'config not found.',
469
+ ...surrogateHeaders,
470
+ },
471
+ });
472
+ }
473
+
474
+ // only admin scope is supported and it only needs the users
475
+ const adminConfig = {
476
+ org,
477
+ ...orgConfig,
478
+ };
479
+
480
+ computeAdminRoles(adminConfig, orgConfig);
481
+ delete adminConfig.tokens;
482
+ delete adminConfig.users;
483
+ return new PipelineResponse(JSON.stringify(adminConfig, null, 2), {
484
+ headers: {
485
+ 'content-type': 'application/json',
486
+ ...surrogateHeaders,
487
+ },
488
+ });
489
+ }
@@ -24,6 +24,9 @@
24
24
  "description": { "$ref": "https://ns.adobe.com/helix/config/common#/$defs/description" },
25
25
  "tokens": {
26
26
  "$ref": "https://ns.adobe.com/helix/config/tokens"
27
+ },
28
+ "users": {
29
+ "$ref": "https://ns.adobe.com/helix/config/users"
27
30
  }
28
31
  },
29
32
  "required": [
@@ -0,0 +1,12 @@
1
+ /*
2
+ * Copyright 2024 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
+ module.exports = require('./users.schema.json');
@@ -0,0 +1,52 @@
1
+ {
2
+ "meta:license": [
3
+ "Copyright 2024 Adobe. All rights reserved.",
4
+ "This file is licensed to you under the Apache License, Version 2.0 (the \"License\");",
5
+ "you may not use this file except in compliance with the License. You may obtain a copy",
6
+ "of the License at http://www.apache.org/licenses/LICENSE-2.0",
7
+ "",
8
+ "Unless required by applicable law or agreed to in writing, software distributed under",
9
+ "the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS",
10
+ "OF ANY KIND, either express or implied. See the License for the specific language",
11
+ "governing permissions and limitations under the License."
12
+ ],
13
+ "$schema": "http://json-schema.org/draft-07/schema#",
14
+ "$id": "https://ns.adobe.com/helix/config/users",
15
+ "title": "users",
16
+ "type": "array",
17
+ "items": {
18
+ "type": "object",
19
+ "properties": {
20
+ "id": {
21
+ "type": "string",
22
+ "pattern": "^[a-zA-Z0-9-_=]+$"
23
+ },
24
+ "email": {
25
+ "type": "string",
26
+ "format": "email"
27
+ },
28
+ "name": {
29
+ "type": "string"
30
+ },
31
+ "roles": {
32
+ "type": "array",
33
+ "items": {
34
+ "type": "string",
35
+ "enum": [
36
+ "admin",
37
+ "author",
38
+ "publish",
39
+ "config"
40
+ ]
41
+ }
42
+ }
43
+ },
44
+ "required": [
45
+ "id",
46
+ "email",
47
+ "roles"
48
+ ],
49
+ "additionalProperties": false
50
+ },
51
+ "additionalProperties": false
52
+ }
@@ -14,9 +14,9 @@ import { isDeepStrictEqual } from 'util';
14
14
  import { HelixStorage } from '@adobe/helix-shared-storage';
15
15
  import { StatusCodeError } from './status-code-error.js';
16
16
  import {
17
- createToken,
18
- jsonGet,
19
- jsonPut,
17
+ createToken, createUser,
18
+ deepGetOrCreate,
19
+ deepPut,
20
20
  migrateToken,
21
21
  updateCodeSource,
22
22
  updateContentSource,
@@ -60,6 +60,13 @@ const FRAGMENTS = {
60
60
  '.': 'token',
61
61
  },
62
62
  },
63
+ users: {
64
+ '.': 'users',
65
+ '.param': {
66
+ name: 'id',
67
+ '.': 'user',
68
+ },
69
+ },
63
70
  },
64
71
  };
65
72
 
@@ -168,6 +175,9 @@ export class ConfigStore {
168
175
  if (this.type === 'org' && data.tokens) {
169
176
  throw new StatusCodeError(400, 'creating org config with tokens not supported yet.');
170
177
  }
178
+ if (this.type === 'org' && data.users) {
179
+ throw new StatusCodeError(400, 'creating org config with users is not supported yet.');
180
+ }
171
181
  if (this.type !== 'org') {
172
182
  updateContentSource(ctx, data.content);
173
183
  updateCodeSource(ctx, data.code);
@@ -186,7 +196,7 @@ export class ConfigStore {
186
196
  let obj = JSON.parse(buf);
187
197
  const frag = getFragmentInfo(this.type, relPath);
188
198
  if (frag) {
189
- obj = jsonGet(obj, frag.relPath);
199
+ obj = deepGetOrCreate(obj, frag.relPath);
190
200
  }
191
201
  return redact(obj, frag);
192
202
  }
@@ -241,8 +251,33 @@ export class ConfigStore {
241
251
  ret = token;
242
252
  // don't expose hash in return value
243
253
  delete ret.hash;
254
+ } else if (frag.type === 'users') {
255
+ const user = createUser();
256
+ data = {
257
+ ...data,
258
+ ...user,
259
+ };
260
+ relPath = ['users', user.id];
261
+ frag.type = 'user';
262
+ ret = user;
263
+ // todo: define via "schema"
264
+ if (!old.users) {
265
+ old.users = [];
266
+ }
267
+ } else if (frag.type === 'user') {
268
+ const user = deepGetOrCreate(old, relPath);
269
+ if (!user) {
270
+ throw new StatusCodeError(404, 'object not found.');
271
+ }
272
+ if (data.id) {
273
+ if (data.id !== user.id) {
274
+ throw new StatusCodeError(400, 'object id mismatch.');
275
+ }
276
+ } else {
277
+ data.id = user.id;
278
+ }
244
279
  }
245
- config = jsonPut(old, relPath, data);
280
+ config = deepPut(old, relPath, data);
246
281
  } else if (this.type !== 'org') {
247
282
  updateContentSource(ctx, config.content);
248
283
  updateCodeSource(ctx, config.code);
@@ -261,7 +296,7 @@ export class ConfigStore {
261
296
  }
262
297
  const frag = getFragmentInfo(this.type, relPath);
263
298
  if (frag) {
264
- const data = jsonPut(JSON.parse(buf), relPath, null);
299
+ const data = deepPut(JSON.parse(buf), relPath, null);
265
300
  await validate(data, this.type);
266
301
  await storage.put(this.key, JSON.stringify(data), 'application/json');
267
302
  await this.purge(ctx, JSON.parse(buf), data);
@@ -32,6 +32,7 @@ import robotsSchema from '../schemas/robots.schema.cjs';
32
32
  import sidekickSchema from '../schemas/sidekick.schema.cjs';
33
33
  import siteSchema from '../schemas/site.schema.cjs';
34
34
  import tokensSchema from '../schemas/tokens.schema.cjs';
35
+ import usersSchema from '../schemas/users.schema.cjs';
35
36
 
36
37
  export const SCHEMAS = [
37
38
  accessSchema,
@@ -53,6 +54,7 @@ export const SCHEMAS = [
53
54
  sidekickSchema,
54
55
  siteSchema,
55
56
  tokensSchema,
57
+ usersSchema,
56
58
  ];
57
59
 
58
60
  const SCHEMA_TYPES = {
@@ -15,21 +15,60 @@ import { GitUrl } from '@adobe/helix-shared-git';
15
15
  import { decodeJwt } from 'jose';
16
16
  import { StatusCodeError } from './status-code-error.js';
17
17
 
18
- export function jsonGet(obj, path) {
19
- return path.split('/').reduce((o, p) => o[p], obj);
18
+ /**
19
+ * Returns the property addressed by the given path in the object.
20
+ * If {@code create} is {@code true}, intermediate objects will be created if they do not exist.
21
+ *
22
+ * @param {object} obj the object to search
23
+ * @param {string|string[]} path the path or path segments
24
+ * @param {boolean} create if {@code true} intermediate objects will be created if they do not exist
25
+ * @returns {*}
26
+ */
27
+ export function deepGetOrCreate(obj, path, create = false) {
28
+ const parts = Array.isArray(path) ? path : path.split('/');
29
+ return parts.reduce((o, prop) => {
30
+ if (Array.isArray(o)) {
31
+ // todo: better support for arrays
32
+ return o.find((e) => e.id === prop);
33
+ } else {
34
+ if (create && !(prop in o)) {
35
+ // eslint-disable-next-line no-param-reassign
36
+ o[prop] = {};
37
+ }
38
+ return o[prop];
39
+ }
40
+ }, obj);
20
41
  }
21
42
 
22
- export function jsonPut(obj, path, value) {
43
+ /**
44
+ * Sets the property addressed by the given path in the object.
45
+ * @param {object} obj the object to search
46
+ * @param {string|string[]} path the path or path segments
47
+ * @param {*} value the value to set on the object
48
+ * @returns {object} the passed in `obj`
49
+ */
50
+ export function deepPut(obj, path, value) {
23
51
  const parts = Array.isArray(path) ? path : path.split('/');
24
52
  const last = parts.pop();
25
- const parent = parts.reduce((o, p) => {
26
- if (!(p in o)) {
53
+ const parent = parts.reduce((o, prop) => {
54
+ if (!(prop in o)) {
27
55
  // eslint-disable-next-line no-param-reassign
28
- o[p] = {};
56
+ o[prop] = {};
29
57
  }
30
- return o[p];
58
+ return o[prop];
31
59
  }, obj);
32
- if (value === null) {
60
+
61
+ if (Array.isArray(parent)) {
62
+ // todo: better support for non id keys
63
+ const idx = parent.findIndex((e) => e.id === last);
64
+ if (idx >= 0 && value === null) {
65
+ parent.splice(idx, 1);
66
+ } else if (idx >= 0) {
67
+ parent[idx] = value;
68
+ } else if (value !== null) {
69
+ parent.push(value);
70
+ }
71
+ } else if (value === null) {
33
72
  delete parent[last];
34
73
  } else {
35
74
  parent[last] = value;
@@ -137,6 +176,13 @@ export function createToken(key) {
137
176
  };
138
177
  }
139
178
 
179
+ export function createUser() {
180
+ const id = crypto.randomBytes(16).toString('base64url');
181
+ return {
182
+ id,
183
+ };
184
+ }
185
+
140
186
  /**
141
187
  * migrates an existing jwt token
142
188
  * @param key