@appsemble/lang-sdk 0.36.10 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/lang-sdk",
3
- "version": "0.36.10",
3
+ "version": "0.37.0",
4
4
  "description": "Language SDK for Appsemble",
5
5
  "keywords": [
6
6
  "app",
@@ -49,7 +49,7 @@
49
49
  "intl-messageformat": "^11.0.0",
50
50
  "jsonschema": "~1.4.1",
51
51
  "language-tags": "^1.0.0",
52
- "lcm": "^0.0.3",
52
+ "lcm": "^0.0.4",
53
53
  "openapi-types": "^12.0.0",
54
54
  "parse-duration": "^1.0.0",
55
55
  "type-fest": "^4.0.0",
@@ -94,6 +94,13 @@ If this is omitted, a collapsable side navigation menu will be rendered on the l
94
94
  type: 'boolean',
95
95
  default: false,
96
96
  },
97
+ hideGroupDropdown: {
98
+ description: `Whether to hide the group dropdown.
99
+
100
+ Set to \`true\` to hide it for all users, or provide a list of role names to hide it only
101
+ for members holding any of those roles.`,
102
+ oneOf: [{ type: 'boolean' }, { type: 'array', items: { type: 'string' } }],
103
+ },
97
104
  },
98
105
  };
99
106
  //# sourceMappingURL=AppLayoutDefinition.js.map
@@ -17,6 +17,19 @@ export const ResourcePatchActionDefinition = extendJSONSchema(RequestActionDefin
17
17
  $ref: '#/components/schemas/RemapperDefinition',
18
18
  description: 'The ID of the group to scope the request to. Defaults to the currently selected group.',
19
19
  },
20
+ optimistic: {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ description: 'Fetch the latest resource before writing, merge its `$etag` into the request data so the implicit `If-Match` precondition holds, and retry on precondition conflicts. The fetched resource is also exposed to remappers as `{ context: resource }`.',
24
+ properties: {
25
+ retries: {
26
+ type: 'integer',
27
+ minimum: 0,
28
+ default: 0,
29
+ description: 'The number of precondition conflicts to retry after fetching the latest resource again.',
30
+ },
31
+ },
32
+ },
20
33
  id: {
21
34
  $ref: '#/components/schemas/RemapperDefinition',
22
35
  },
@@ -17,6 +17,19 @@ export const ResourceUpdateActionDefinition = extendJSONSchema(RequestActionDefi
17
17
  $ref: '#/components/schemas/RemapperDefinition',
18
18
  description: 'The ID of the group to scope the request to. Defaults to the currently selected group.',
19
19
  },
20
+ optimistic: {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ description: 'Fetch the latest resource before writing, merge its `$etag` into the request data so the implicit `If-Match` precondition holds, and retry on precondition conflicts. The fetched resource is also exposed to remappers as `{ context: resource }`.',
24
+ properties: {
25
+ retries: {
26
+ type: 'integer',
27
+ minimum: 0,
28
+ default: 0,
29
+ description: 'The number of precondition conflicts to retry after fetching the latest resource again.',
30
+ },
31
+ },
32
+ },
20
33
  },
21
34
  }, ['url', 'method']);
22
35
  //# sourceMappingURL=ResourceUpdateActionDefinition.js.map
@@ -564,6 +564,19 @@ interface ResourceOrderQueryDefinition {
564
564
  */
565
565
  order?: 'asc' | 'desc';
566
566
  }
567
+ interface OptimisticResourceWriteActionDefinition {
568
+ /**
569
+ * Fetch the latest resource before writing, merge its `$etag` into the request data so the
570
+ * implicit `If-Match` precondition holds, and retry on precondition conflicts. The fetched
571
+ * resource is also exposed to remappers as `{ context: resource }`.
572
+ */
573
+ optimistic?: {
574
+ /**
575
+ * The number of precondition conflicts to retry after fetching the latest resource again.
576
+ */
577
+ retries?: number;
578
+ };
579
+ }
567
580
  export interface ControllerActionDefinition extends BaseActionDefinition<'controller'> {
568
581
  handler: string;
569
582
  }
@@ -576,12 +589,12 @@ export type ResourceHistoryGetActionDefinition = ResourceActionDefinition<'resou
576
589
  export type ResourceGetActionDefinition = ResourceActionDefinition<'resource.get'> & ResourceActionWithIdDefinition & ViewResourceDefinition;
577
590
  export type ResourceQueryActionDefinition = OwnResourceDefinition & ResourceActionDefinition<'resource.query'> & ViewResourceDefinition;
578
591
  export type ResourceCountActionDefinition = OwnResourceDefinition & ResourceActionDefinition<'resource.count'>;
579
- export type ResourceUpdateActionDefinition = ResourceActionDefinition<'resource.update'>;
592
+ export type ResourceUpdateActionDefinition = ResourceActionDefinition<'resource.update'> & OptimisticResourceWriteActionDefinition;
580
593
  export type ResourceUpdateGroupActionDefinition = ResourceActionDefinition<'resource.update.group'> & ResourceActionWithIdDefinition & {
581
594
  groupId?: Remapper;
582
595
  };
583
596
  export type ResourceUpdatePositionsActionDefinition = ResourceActionDefinition<'resource.update.positions'> & ResourceActionWithIdDefinition & ResourceOrderQueryDefinition;
584
- export type ResourcePatchActionDefinition = ResourceActionDefinition<'resource.patch'> & ResourceActionWithIdDefinition;
597
+ export type ResourcePatchActionDefinition = ResourceActionDefinition<'resource.patch'> & ResourceActionWithIdDefinition & OptimisticResourceWriteActionDefinition;
585
598
  export type AppMemberLogoutAction = BaseActionDefinition<'app.member.logout'>;
586
599
  export interface BaseResourceSubscribeActionDefinition<T extends ActionName> extends BaseActionDefinition<T> {
587
600
  /**
package/types/core.d.ts CHANGED
@@ -6,7 +6,7 @@ import { type CompanionContainerDefinition } from './container.js';
6
6
  import { type LayoutPosition, type Navigation } from './layout.js';
7
7
  import { type Remapper } from './remapper.js';
8
8
  import { type ResourceDefinition } from './resource.js';
9
- import { type ViewRole } from './roles.js';
9
+ import { type AppRole, type ViewRole } from './roles.js';
10
10
  import { type Security } from './security.js';
11
11
  import { type Theme } from './theme.js';
12
12
  import { type PageLayoutDefinition } from './gridLayout.js';
@@ -110,6 +110,13 @@ export interface AppDefinition {
110
110
  * Whether to hide the title bar from all pages in the app.
111
111
  */
112
112
  hideTitleBar?: boolean;
113
+ /**
114
+ * Whether to hide the group dropdown.
115
+ *
116
+ * Set to `true` to hide it for all users, or provide a list of role names to hide it
117
+ * only for members holding any of those roles.
118
+ */
119
+ hideGroupDropdown?: boolean | AppRole[];
113
120
  };
114
121
  /**
115
122
  * The strategy to use for apps to subscribe to push notifications.
package/validation.js CHANGED
@@ -6,7 +6,7 @@ import { BlockParamInstanceValidator } from './BasicValidator.js';
6
6
  import { getAppBlocks, normalizeBlockName } from './blockUtils.js';
7
7
  import { partialNormalized } from './constants/index.js';
8
8
  import { findPageByName } from './findPageByName.js';
9
- import { iterApp } from './iterApp.js';
9
+ import { iterAction, iterApp } from './iterApp.js';
10
10
  import { has } from './miscellaneous.js';
11
11
  import { normalize } from './normalize.js';
12
12
  import { serverActions } from './serverActions.js';
@@ -19,6 +19,29 @@ const allResourceViewPermissionPattern = /^\$resource:all:(get|query):[^:]+$/;
19
19
  const resourceViewPermissionPattern = /^\$resource:[^:]+:(get|query):[^:]+$/;
20
20
  const allWebhookPermissionPattern = /^\$webhook:all:invoke$/;
21
21
  const webhookPermissionPattern = /^\$webhook:[^:]+:invoke$/;
22
+ function hasResourcePermission(permissions, resourceName, resourceAction, view) {
23
+ return permissions.some((permission) => {
24
+ if (resourcePermissionPattern.test(permission)) {
25
+ const [, permissionResourceName, permissionResourceAction] = permission.split(':');
26
+ return (['all', resourceName].includes(permissionResourceName) &&
27
+ (permissionResourceAction === resourceAction ||
28
+ (resourceAction === 'count' && permissionResourceAction === 'query')));
29
+ }
30
+ if (ownResourcePermissionPattern.test(permission)) {
31
+ const [, permissionResourceName, , permissionResourceAction] = permission.split(':');
32
+ return (['all', resourceName].includes(permissionResourceName) &&
33
+ (permissionResourceAction === resourceAction ||
34
+ (resourceAction === 'count' && permissionResourceAction === 'query')));
35
+ }
36
+ if (view && resourceViewPermissionPattern.test(permission)) {
37
+ const [, permissionResourceName, permissionResourceAction, permissionResourceView] = permission.split(':');
38
+ return (['all', resourceName].includes(permissionResourceName) &&
39
+ permissionResourceAction === resourceAction &&
40
+ (!permissionResourceView || permissionResourceView === view));
41
+ }
42
+ return false;
43
+ });
44
+ }
22
45
  /**
23
46
  * Check whether or not the given link represents a link related to the Appsemble core.
24
47
  *
@@ -968,6 +991,12 @@ function validateSecurity(definition, report) {
968
991
  }
969
992
  }
970
993
  iterApp(definition, { onBlock: checkRoles, onPage: checkRoles });
994
+ const { hideGroupDropdown } = definition.layout ?? {};
995
+ if (Array.isArray(hideGroupDropdown)) {
996
+ for (const [index, role] of hideGroupDropdown.entries()) {
997
+ checkRoleExists(role, ['layout', 'hideGroupDropdown', index]);
998
+ }
999
+ }
971
1000
  }
972
1001
  /**
973
1002
  * Validates the hooks in resource definition to ensure its properties are valid.
@@ -1068,6 +1097,18 @@ function validateCronJobs({ cron }, report) {
1068
1097
  // TODO: horrible nesting
1069
1098
  function validateActions(definition, report) {
1070
1099
  const urlRegex = new RegExp(`^${partialNormalized.source}:`);
1100
+ const validateUnsupportedServerOptimisticResourceWrite = (action, path) => {
1101
+ if ((path[0] === 'cron' || path[0] === 'webhooks') &&
1102
+ (action.type === 'resource.update' || action.type === 'resource.patch') &&
1103
+ 'optimistic' in action) {
1104
+ report(action.type, 'optimistic resource writes are not supported for server-side actions', [
1105
+ ...path,
1106
+ 'optimistic',
1107
+ ]);
1108
+ return true;
1109
+ }
1110
+ return false;
1111
+ };
1071
1112
  iterApp(definition, {
1072
1113
  onAction(action, path) {
1073
1114
  // XXX: could we validate server-side actions differently
@@ -1075,8 +1116,7 @@ function validateActions(definition, report) {
1075
1116
  report(action.type, 'action type is not supported for cron jobs', [...path, 'type']);
1076
1117
  return;
1077
1118
  }
1078
- if (path[0] === 'webhooks' && !serverActions.has(action.type)) {
1079
- report(action.type, 'action type is not supported for webhooks', [...path, 'type']);
1119
+ if (validateUnsupportedServerOptimisticResourceWrite(action, path)) {
1080
1120
  return;
1081
1121
  }
1082
1122
  if (action.type.startsWith('app.member.') && !definition.security) {
@@ -1100,6 +1140,13 @@ function validateActions(definition, report) {
1100
1140
  const { resource: resourceName, view } = action;
1101
1141
  const resource = definition.resources?.[resourceName];
1102
1142
  const [, resourceAction] = action.type.split('.');
1143
+ // Optimistic concurrency is implicit via `data.$etag`; the explicit
1144
+ // `ifMatch` field used to live on the action schema and has been removed.
1145
+ if (['update', 'patch', 'delete'].includes(resourceAction) &&
1146
+ 'ifMatch' in action) {
1147
+ report(action.type, 'ifMatch is no longer accepted; resource writes use the implicit $etag carried in data', [...path, 'ifMatch']);
1148
+ return;
1149
+ }
1103
1150
  if (!resource) {
1104
1151
  report(action.type, 'refers to a resource that doesn’t exist', [...path, 'resource']);
1105
1152
  return;
@@ -1118,31 +1165,16 @@ function validateActions(definition, report) {
1118
1165
  const allRolePermissions = getAppRolePermissions(definition.security, Object.keys(definition.security.roles));
1119
1166
  allPermissions.push(...allRolePermissions);
1120
1167
  }
1121
- // TODO: repeats way too much, code not self-explanatory at all
1122
- if (!allPermissions.some((permission) => {
1123
- if (resourcePermissionPattern.test(permission)) {
1124
- const [, permissionResourceName, permissionResourceAction] = permission.split(':');
1125
- return (['all', resourceName].includes(permissionResourceName) &&
1126
- (permissionResourceAction === resourceAction ||
1127
- (resourceAction === 'count' && permissionResourceAction === 'query')));
1128
- }
1129
- if (ownResourcePermissionPattern.test(permission)) {
1130
- const [, permissionResourceName, , permissionResourceAction] = permission.split(':');
1131
- return (['all', resourceName].includes(permissionResourceName) &&
1132
- (permissionResourceAction === resourceAction ||
1133
- (resourceAction === 'count' && permissionResourceAction === 'query')));
1134
- }
1135
- if (view && resourceViewPermissionPattern.test(permission)) {
1136
- const [, permissionResourceName, permissionResourceAction, permissionResourceView] = permission.split(':');
1137
- return (['all', resourceName].includes(permissionResourceName) &&
1138
- permissionResourceAction === resourceAction &&
1139
- (!permissionResourceView || permissionResourceView === view));
1140
- }
1141
- return false;
1142
- })) {
1168
+ if (!hasResourcePermission(allPermissions, resourceName, resourceAction, view)) {
1143
1169
  report(action.type, 'there is no one in the app who has permissions to use this action', [...path, 'resource']);
1144
1170
  return;
1145
1171
  }
1172
+ if (['update', 'patch'].includes(resourceAction) &&
1173
+ 'optimistic' in action &&
1174
+ !hasResourcePermission(allPermissions, resourceName, 'get', view)) {
1175
+ report(action.type, 'there is no one in the app who has permissions to fetch this resource for optimistic writes', [...path, 'optimistic']);
1176
+ return;
1177
+ }
1146
1178
  }
1147
1179
  }
1148
1180
  if (action.type === 'webhook') {
@@ -1254,6 +1286,16 @@ function validateActions(definition, report) {
1254
1286
  }
1255
1287
  },
1256
1288
  });
1289
+ for (const [name, webhook] of Object.entries(definition.webhooks ?? {})) {
1290
+ if (webhook.action &&
1291
+ iterAction(webhook.action, {
1292
+ onAction(action, path) {
1293
+ return validateUnsupportedServerOptimisticResourceWrite(action, path);
1294
+ },
1295
+ }, ['webhooks', name, 'action'])) {
1296
+ return;
1297
+ }
1298
+ }
1257
1299
  }
1258
1300
  function validateEvents(definition, blockVersions, report) {
1259
1301
  const indexMap = new Map();