@sasjs/api 0.7.2 → 0.9.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.
@@ -404,7 +404,7 @@ exports.RECORD_CORPUS = [
404
404
  request: {
405
405
  token: RESTRICTED,
406
406
  params: { username: 'user2' },
407
- body: { password: 'wrong' }
407
+ body: { password: 'wrong123' }
408
408
  },
409
409
  expect: { status: 401, error: 'Invalid password.' }
410
410
  },
@@ -702,6 +702,70 @@ exports.RECORD_CORPUS = [
702
702
  expect: { status: 404, error: 'Group not found.' }
703
703
  },
704
704
  // ------------------------------------------------------------ permissions
705
+ {
706
+ id: 'authorized-routes',
707
+ route: 'GET /SASjsApi/info/authorizedRoutes',
708
+ description: 'answers the routes that accept a rule, to any caller',
709
+ identity: exports.ADMIN_SIGNED_IN,
710
+ request: { token: RESTRICTED },
711
+ expect: {
712
+ status: 200,
713
+ body: {
714
+ paths: [
715
+ '/AppStream',
716
+ '/SASjsApi',
717
+ '/SASjsApi/code/execute',
718
+ '/SASjsApi/stp/execute',
719
+ '/SASjsApi/drive/deploy',
720
+ '/SASjsApi/drive/deploy/upload',
721
+ '/SASjsApi/drive/file',
722
+ '/SASjsApi/drive/folder',
723
+ '/SASjsApi/drive/fileTree',
724
+ '/SASjsApi/drive/rename'
725
+ ]
726
+ }
727
+ }
728
+ },
729
+ {
730
+ id: 'my-authorized-routes-as-admin',
731
+ route: 'GET /SASjsApi/info/myAuthorizedRoutes',
732
+ description: 'answers every route to an administrator',
733
+ identity: exports.ADMIN_SIGNED_IN,
734
+ request: { token: ADMIN },
735
+ expect: {
736
+ status: 200,
737
+ body: {
738
+ paths: [
739
+ '/AppStream',
740
+ '/SASjsApi',
741
+ '/SASjsApi/code/execute',
742
+ '/SASjsApi/stp/execute',
743
+ '/SASjsApi/drive/deploy',
744
+ '/SASjsApi/drive/deploy/upload',
745
+ '/SASjsApi/drive/file',
746
+ '/SASjsApi/drive/folder',
747
+ '/SASjsApi/drive/fileTree',
748
+ '/SASjsApi/drive/rename'
749
+ ]
750
+ }
751
+ }
752
+ },
753
+ {
754
+ id: 'my-authorized-routes-as-user',
755
+ route: 'GET /SASjsApi/info/myAuthorizedRoutes',
756
+ description: 'answers a caller only the routes its rules grant',
757
+ identity: exports.ADMIN_SIGNED_IN,
758
+ request: { token: RESTRICTED },
759
+ expect: {
760
+ status: 200,
761
+ // /AppStream is visible whatever the rules say; the two grants come from
762
+ // DCGroup; /SASjsApi/code/execute is denied to this user by a user rule,
763
+ // and nothing grants the rest
764
+ body: {
765
+ paths: ['/AppStream', '/SASjsApi/stp/execute', '/SASjsApi/drive/file']
766
+ }
767
+ }
768
+ },
705
769
  {
706
770
  id: 'list-permissions-as-admin',
707
771
  route: 'GET /SASjsApi/permission',
@@ -713,55 +777,24 @@ exports.RECORD_CORPUS = [
713
777
  body: [
714
778
  {
715
779
  uid: 'rule-routes',
716
- path: '/SASjsApi/info',
780
+ path: '/SASjsApi/drive/file',
717
781
  type: 'Route',
718
782
  setting: 'Grant',
719
- group: {
720
- uid: 'group-data-controller',
721
- name: 'DCGroup',
722
- description: 'Data Controller users',
723
- isActive: true,
724
- users: [
725
- {
726
- uid: RESTRICTED_UID,
727
- username: 'user2',
728
- displayName: 'Demo User',
729
- isAdmin: false
730
- }
731
- ]
732
- }
783
+ group: 'group-data-controller'
733
784
  },
734
785
  {
735
786
  uid: 'rule-execute',
736
787
  path: '/SASjsApi/stp/execute',
737
788
  type: 'Route',
738
789
  setting: 'Grant',
739
- group: {
740
- uid: 'group-data-controller',
741
- name: 'DCGroup',
742
- description: 'Data Controller users',
743
- isActive: true,
744
- users: [
745
- {
746
- uid: RESTRICTED_UID,
747
- username: 'user2',
748
- displayName: 'Demo User',
749
- isAdmin: false
750
- }
751
- ]
752
- }
790
+ group: 'group-data-controller'
753
791
  },
754
792
  {
755
793
  uid: 'rule-code',
756
794
  path: '/SASjsApi/code/execute',
757
795
  type: 'Route',
758
796
  setting: 'Deny',
759
- user: {
760
- uid: RESTRICTED_UID,
761
- username: 'user2',
762
- displayName: 'Demo User',
763
- isAdmin: false
764
- }
797
+ user: RESTRICTED_UID
765
798
  }
766
799
  ]
767
800
  }
@@ -777,55 +810,24 @@ exports.RECORD_CORPUS = [
777
810
  body: [
778
811
  {
779
812
  uid: 'rule-routes',
780
- path: '/SASjsApi/info',
813
+ path: '/SASjsApi/drive/file',
781
814
  type: 'Route',
782
815
  setting: 'Grant',
783
- group: {
784
- uid: 'group-data-controller',
785
- name: 'DCGroup',
786
- description: 'Data Controller users',
787
- isActive: true,
788
- users: [
789
- {
790
- uid: RESTRICTED_UID,
791
- username: 'user2',
792
- displayName: 'Demo User',
793
- isAdmin: false
794
- }
795
- ]
796
- }
816
+ group: 'group-data-controller'
797
817
  },
798
818
  {
799
819
  uid: 'rule-execute',
800
820
  path: '/SASjsApi/stp/execute',
801
821
  type: 'Route',
802
822
  setting: 'Grant',
803
- group: {
804
- uid: 'group-data-controller',
805
- name: 'DCGroup',
806
- description: 'Data Controller users',
807
- isActive: true,
808
- users: [
809
- {
810
- uid: RESTRICTED_UID,
811
- username: 'user2',
812
- displayName: 'Demo User',
813
- isAdmin: false
814
- }
815
- ]
816
- }
823
+ group: 'group-data-controller'
817
824
  },
818
825
  {
819
826
  uid: 'rule-code',
820
827
  path: '/SASjsApi/code/execute',
821
828
  type: 'Route',
822
829
  setting: 'Deny',
823
- user: {
824
- uid: RESTRICTED_UID,
825
- username: 'user2',
826
- displayName: 'Demo User',
827
- isAdmin: false
828
- }
830
+ user: RESTRICTED_UID
829
831
  }
830
832
  ]
831
833
  }
@@ -846,7 +848,7 @@ exports.RECORD_CORPUS = [
846
848
  request: {
847
849
  token: ADMIN,
848
850
  body: {
849
- path: '/SASjsApi/code/lint',
851
+ path: '/SASjsApi/drive/folder',
850
852
  type: 'Route',
851
853
  setting: 'Grant',
852
854
  principalType: 'user',
@@ -855,7 +857,7 @@ exports.RECORD_CORPUS = [
855
857
  },
856
858
  expect: {
857
859
  status: 200,
858
- partial: { path: '/SASjsApi/code/lint', setting: 'Grant' }
860
+ partial: { path: '/SASjsApi/drive/folder', setting: 'Grant' }
859
861
  }
860
862
  },
861
863
  {
@@ -866,7 +868,7 @@ exports.RECORD_CORPUS = [
866
868
  request: {
867
869
  token: ADMIN,
868
870
  body: {
869
- path: '/SASjsApi/code/lint',
871
+ path: '/SASjsApi/drive/rename',
870
872
  type: 'Route',
871
873
  setting: 'Grant',
872
874
  principalType: 'group',
@@ -876,9 +878,9 @@ exports.RECORD_CORPUS = [
876
878
  expect: {
877
879
  status: 200,
878
880
  partial: {
879
- path: '/SASjsApi/code/lint',
881
+ path: '/SASjsApi/drive/rename',
880
882
  setting: 'Grant',
881
- group: { uid: 'group-data-controller', name: 'DCGroup' }
883
+ group: 'group-data-controller'
882
884
  }
883
885
  }
884
886
  },
@@ -890,7 +892,7 @@ exports.RECORD_CORPUS = [
890
892
  request: {
891
893
  token: ADMIN,
892
894
  body: {
893
- path: '/SASjsApi/code/lint',
895
+ path: '/SASjsApi/drive/deploy',
894
896
  type: 'Route',
895
897
  setting: 'Grant',
896
898
  principalType: 'role',
@@ -910,7 +912,7 @@ exports.RECORD_CORPUS = [
910
912
  request: {
911
913
  token: ADMIN,
912
914
  body: {
913
- path: '/SASjsApi/code/lint',
915
+ path: '/SASjsApi/drive/folder',
914
916
  type: 'Route',
915
917
  setting: 'Grant',
916
918
  principalType: 'user',
@@ -930,7 +932,7 @@ exports.RECORD_CORPUS = [
930
932
  request: {
931
933
  token: RESTRICTED,
932
934
  body: {
933
- path: '/SASjsApi/code/lint',
935
+ path: '/SASjsApi/drive/folder',
934
936
  type: 'Route',
935
937
  setting: 'Grant',
936
938
  principalType: 'user',
@@ -947,7 +949,7 @@ exports.RECORD_CORPUS = [
947
949
  request: {
948
950
  token: ADMIN,
949
951
  body: {
950
- path: '/SASjsApi/code/lint',
952
+ path: '/SASjsApi/drive/rename',
951
953
  type: 'Route',
952
954
  setting: 'Grant',
953
955
  principalType: 'group',
@@ -964,7 +966,7 @@ exports.RECORD_CORPUS = [
964
966
  request: {
965
967
  token: ADMIN,
966
968
  body: {
967
- path: '/SASjsApi/info',
969
+ path: '/SASjsApi/drive/file',
968
970
  type: 'Route',
969
971
  setting: 'Grant',
970
972
  principalType: 'group',
@@ -219,6 +219,16 @@ const dispatch = async (services, routeId, request = {}) => {
219
219
  status: 200,
220
220
  body: await required().deleteUser(await caller(), { username: String(params.username ?? '') }, { password: body.password })
221
221
  };
222
+ case 'GET /SASjsApi/info/authorizedRoutes':
223
+ return {
224
+ status: 200,
225
+ body: await required().listAuthorizedRoutes(await caller())
226
+ };
227
+ case 'GET /SASjsApi/info/myAuthorizedRoutes':
228
+ return {
229
+ status: 200,
230
+ body: await required().myAuthorizedRoutes(await caller())
231
+ };
222
232
  case 'GET /SASjsApi/group':
223
233
  return { status: 200, body: await required().listGroups(await caller()) };
224
234
  case 'GET /SASjsApi/group/{uid}':
@@ -322,6 +332,8 @@ const dispatch = async (services, routeId, request = {}) => {
322
332
  exports.dispatch = dispatch;
323
333
  /** Whether a route is one `dispatch` can answer today. */
324
334
  exports.HANDLED_ROUTES = [
335
+ 'GET /SASjsApi/info/authorizedRoutes',
336
+ 'GET /SASjsApi/info/myAuthorizedRoutes',
325
337
  'GET /SASjsApi/drive/file',
326
338
  'POST /SASjsApi/drive/file',
327
339
  'PATCH /SASjsApi/drive/file',
@@ -57,7 +57,7 @@ exports.DEFAULT_IDENTITY = {
57
57
  permissions: [
58
58
  {
59
59
  uid: 'rule-routes',
60
- path: '/SASjsApi/info',
60
+ path: '/SASjsApi/drive/file',
61
61
  type: 'Route',
62
62
  setting: 'Grant',
63
63
  group: 'group-data-controller'
@@ -17,6 +17,7 @@ exports.createRecordService = exports.sanitiseDisplayName = void 0;
17
17
  * the server sends.
18
18
  */
19
19
  const types_js_1 = require("../types.js");
20
+ const routeAccess_js_1 = require("./routeAccess.js");
20
21
  /** The name the server gives the group every account is added to on creation. */
21
22
  const ALL_USERS_GROUP = 'all-users';
22
23
  /** The group whose membership the API refuses to change. */
@@ -83,7 +84,8 @@ const asGroup = (group) => ({
83
84
  name: group.name,
84
85
  description: group.description
85
86
  });
86
- const createRecordService = (store) => {
87
+ const createRecordService = (store, options = {}) => {
88
+ const grantableRoutes = options.grantableRoutes || routeAccess_js_1.DEFAULT_GRANTABLE_ROUTES;
87
89
  const groupDetails = async (group) => {
88
90
  const members = [];
89
91
  for (const uid of group.users) {
@@ -113,25 +115,14 @@ const createRecordService = (store) => {
113
115
  const findBy = async (by) => by.uid !== undefined
114
116
  ? store.userById(by.uid)
115
117
  : store.userByName(by.username ?? '');
116
- const permissionDetails = async (permission) => {
117
- const response = {
118
- uid: permission.uid,
119
- path: permission.path,
120
- type: permission.type,
121
- setting: permission.setting
122
- };
123
- if (permission.user) {
124
- const user = await store.userById(permission.user);
125
- if (user)
126
- response.user = asUser(user);
127
- }
128
- if (permission.group) {
129
- const group = await store.groupById(permission.group);
130
- if (group)
131
- response.group = await groupDetails(group);
132
- }
133
- return response;
134
- };
118
+ const permissionDetails = async (permission) => ({
119
+ uid: permission.uid,
120
+ path: permission.path,
121
+ type: permission.type,
122
+ setting: permission.setting,
123
+ ...(permission.user ? { user: permission.user } : {}),
124
+ ...(permission.group ? { group: permission.group } : {})
125
+ });
135
126
  return {
136
127
  async listUsers(caller) {
137
128
  await requireCaller(store, caller);
@@ -296,6 +287,41 @@ const createRecordService = (store) => {
296
287
  throw new types_js_1.ApiError(400, 'Unable to update group.');
297
288
  return groupDetails(updated);
298
289
  },
290
+ async listAuthorizedRoutes(caller) {
291
+ // an identity is required because the route is about a caller's access,
292
+ // even though what it returns is the whole inventory
293
+ await requireCaller(store, caller);
294
+ return { paths: [...grantableRoutes] };
295
+ },
296
+ async myAuthorizedRoutes(caller) {
297
+ const reader = await requireCaller(store, caller);
298
+ // an administrator holds every route, and the always-visible ones are
299
+ // visible whatever the rules say
300
+ if (reader.isAdmin)
301
+ return { paths: [...grantableRoutes] };
302
+ const rules = await store.permissions();
303
+ const mine = {
304
+ user: rules
305
+ .filter((rule) => rule.user === reader.uid)
306
+ .map((rule) => ({ path: rule.path, setting: rule.setting })),
307
+ group: rules
308
+ .filter((rule) => rule.group !== undefined && reader.groups.includes(rule.group))
309
+ .map((rule) => ({ path: rule.path, setting: rule.setting }))
310
+ };
311
+ // a grant on the reserved public group admits any caller, and it is
312
+ // checked before the rules so no user or group rule can take it away
313
+ const publicGroup = (await store.groups()).find((group) => group.name === 'public');
314
+ const publicGrants = publicGroup
315
+ ? rules
316
+ .filter((rule) => rule.group === publicGroup.uid && rule.setting === 'Grant')
317
+ .map((rule) => rule.path)
318
+ : [];
319
+ return {
320
+ paths: grantableRoutes.filter((path) => routeAccess_js_1.ALWAYS_VISIBLE_ROUTES.includes(path) ||
321
+ publicGrants.includes(path) ||
322
+ (0, routeAccess_js_1.isRouteGranted)(mine, path, (0, routeAccess_js_1.topLevelRouteFor)(path)))
323
+ };
324
+ },
299
325
  async listPermissions(caller) {
300
326
  const reader = await requireCaller(store, caller);
301
327
  const rules = await store.permissions();
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ /**
3
+ * Which routes a caller may use, from the permission rules alone.
4
+ *
5
+ * This is the answer `GET /SASjsApi/info/myAuthorizedRoutes` gives, and it is the
6
+ * same decision the gate makes when the route is actually requested - a
7
+ * web interface that offers a route the gate then refuses is the bug this exists
8
+ * to prevent, so the two must not be two implementations. A host that gates its
9
+ * own middleware should call `isRouteGranted` rather than re-derive it.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.isRouteGranted = exports.topLevelRouteFor = exports.DEFAULT_GRANTABLE_ROUTES = exports.STATIC_AUTHORIZED_ROUTES = exports.ALWAYS_VISIBLE_ROUTES = exports.TOP_LEVEL_ROUTES = void 0;
13
+ /** The two top-level routes, which a rule can address to cover everything below. */
14
+ exports.TOP_LEVEL_ROUTES = ['/AppStream', '/SASjsApi'];
15
+ /**
16
+ * Routes any authenticated caller may use whatever the rules say. `/AppStream` is
17
+ * the navigation page whose job is to list the apps the caller may open, so
18
+ * gating it on a grant that lists them would hide the page that leads to them.
19
+ */
20
+ exports.ALWAYS_VISIBLE_ROUTES = ['/AppStream'];
21
+ /**
22
+ * The routes that accept a rule, before a host adds its own. A host with
23
+ * streamed applications extends this with `/AppStream/<app>` for each one, which
24
+ * is why the inventory is passed in rather than derived from a fixed list here.
25
+ */
26
+ exports.STATIC_AUTHORIZED_ROUTES = [
27
+ '/SASjsApi/code/execute',
28
+ '/SASjsApi/stp/execute',
29
+ '/SASjsApi/drive/deploy',
30
+ '/SASjsApi/drive/deploy/upload',
31
+ '/SASjsApi/drive/file',
32
+ '/SASjsApi/drive/folder',
33
+ '/SASjsApi/drive/fileTree',
34
+ '/SASjsApi/drive/rename'
35
+ ];
36
+ /** The route inventory when the host states no additions of its own. */
37
+ exports.DEFAULT_GRANTABLE_ROUTES = [
38
+ ...exports.TOP_LEVEL_ROUTES,
39
+ ...exports.STATIC_AUTHORIZED_ROUTES
40
+ ];
41
+ /**
42
+ * The top-level route that governs a request: `/SASjsApi/code/execute` is
43
+ * governed by `/SASjsApi`, and a rule on the top-level route applies to
44
+ * everything beneath it.
45
+ */
46
+ const topLevelRouteFor = (routePath) => exports.TOP_LEVEL_ROUTES.find((route) => routePath.toLowerCase().startsWith(route.toLowerCase())) ?? routePath;
47
+ exports.topLevelRouteFor = topLevelRouteFor;
48
+ const settingOn = (rules, path) => rules.find((rule) => rule.path === path)?.setting;
49
+ const GRANT = 'Grant';
50
+ const DENY = 'Deny';
51
+ /**
52
+ * Whether these rules grant access to `path`.
53
+ *
54
+ * The order is the documented one and the only implementation of it:
55
+ *
56
+ * 1. a rule addressed to the user on the path itself decides either way;
57
+ * 2. otherwise a rule addressed to the user on the top-level route decides;
58
+ * 3. otherwise a group grant on the path allows, and a group deny on the path
59
+ * blocks the top-level check - but a grant from another group still allows;
60
+ * 4. otherwise a group grant on the top-level route allows;
61
+ * 5. otherwise denied.
62
+ *
63
+ * A grant beats a deny at the same level, which is why step 3 looks at every
64
+ * group rule rather than stopping at the first deny.
65
+ */
66
+ const isRouteGranted = (rules, path, topLevelRoute = (0, exports.topLevelRouteFor)(path)) => {
67
+ const userSetting = settingOn(rules.user, path) ?? settingOn(rules.user, topLevelRoute);
68
+ if (userSetting)
69
+ return userSetting === GRANT;
70
+ const onPath = rules.group.filter((rule) => rule.path === path);
71
+ if (onPath.some((rule) => rule.setting === GRANT))
72
+ return true;
73
+ if (onPath.some((rule) => rule.setting === DENY))
74
+ return false;
75
+ return rules.group.some((rule) => rule.path === topLevelRoute && rule.setting === GRANT);
76
+ };
77
+ exports.isRouteGranted = isRouteGranted;
@@ -60,6 +60,8 @@ Object.defineProperty(exports, "TAG_CAPABILITIES", { enumerable: true, get: func
60
60
  var dispatch_js_1 = require("./host/dispatch.js");
61
61
  Object.defineProperty(exports, "dispatch", { enumerable: true, get: function () { return dispatch_js_1.dispatch; } });
62
62
  Object.defineProperty(exports, "HANDLED_ROUTES", { enumerable: true, get: function () { return dispatch_js_1.HANDLED_ROUTES; } });
63
+ // a host gating its own middleware reads the same decision the inventory reports
64
+ __exportStar(require("./identity/routeAccess.js"), exports);
63
65
  var service_js_2 = require("./identity/service.js");
64
66
  Object.defineProperty(exports, "createIdentityService", { enumerable: true, get: function () { return service_js_2.createIdentityService; } });
65
67
  var records_js_1 = require("./identity/records.js");
@@ -30,7 +30,6 @@ const EDITOR = 'Linting and formatting come from @sasjs/lint, which this package
30
30
  const RUNTIME = 'Starting a program needs a runtime port, which this package does not have: a host decides which languages it holds, and answers this itself until then.';
31
31
  const SESSION_RECORD = "A triggered session's record, log, output and state belong to the runtime that ran the program, and this package has no runtime port.";
32
32
  const HOST_CONFIG = "The body is the host's own configuration - ports, run times and providers - which a mock states for itself and this package cannot know.";
33
- const PERMISSIONS = "The route inventory is the host's permission model, which this package does not hold.";
34
33
  const HOST_OWNED = "The host owns this report: it describes the host's own process and files, which a browser build has no equivalent of.";
35
34
  const WEB_BUNDLE = 'The host serves its own web bundle; there is no API decision here for this package to answer.';
36
35
  const ARCHIVE = "Expanding the uploaded archive is the host's transport work - multer and a zip library on the server, `DecompressionStream` in a worker - and the host hands this package the tree it produces, which is the same tree `POST /SASjsApi/drive/deploy` takes.";
@@ -177,16 +176,12 @@ exports.OVERLAY = {
177
176
  // Info - what the host is, and what this caller may use
178
177
  'GET /SASjsApi/info': { implemented: false, reason: HOST_CONFIG },
179
178
  'GET /SASjsApi/info/authorizedRoutes': {
180
- implemented: false,
181
- // the route inventory reports what the CALLER may use, so it needs an
182
- // identity even though the rest of the Info tag needs nothing
183
- capabilities: ['identity'],
184
- reason: PERMISSIONS
179
+ implemented: true,
180
+ capabilities: ['identity']
185
181
  },
186
182
  'GET /SASjsApi/info/myAuthorizedRoutes': {
187
- implemented: false,
188
- capabilities: ['identity'],
189
- reason: PERMISSIONS
183
+ implemented: true,
184
+ capabilities: ['identity']
190
185
  },
191
186
  // Permission - the rules the inventory above is derived from
192
187
  'GET /SASjsApi/permission': {
@@ -1 +1 @@
1
- {"version":3,"file":"records.d.ts","sourceRoot":"","sources":["../../../src/corpus/records.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAA;AAkB/B,qFAAqF;AACrF,eAAO,MAAM,eAAe,EAAE,eAG7B,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,eA0C9B,CAAA;AAOD,eAAO,MAAM,aAAa,EAAE,UAAU,EA0oCrC,CAAA"}
1
+ {"version":3,"file":"records.d.ts","sourceRoot":"","sources":["../../../src/corpus/records.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAEL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAA;AAkB/B,qFAAqF;AACrF,eAAO,MAAM,eAAe,EAAE,eAG7B,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,eA0C9B,CAAA;AAOD,eAAO,MAAM,aAAa,EAAE,UAAU,EA4oCrC,CAAA"}