@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 +2 -2
- package/schemas/AppLayoutDefinition.js +7 -0
- package/schemas/actions/ResourcePatchActionDefinition.js +13 -0
- package/schemas/actions/ResourceUpdateActionDefinition.js +13 -0
- package/types/actionDefinition.d.ts +15 -2
- package/types/core.d.ts +8 -1
- package/validation.js +67 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appsemble/lang-sdk",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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();
|