@appsemble/lang-sdk
Version:
Language SDK for Appsemble
1,079 lines • 67.2 kB
JavaScript
import cronParser from 'cron-parser';
import { ValidationError, Validator } from 'jsonschema';
import languageTags from 'language-tags';
import { getAppInheritedRoles, getAppPossibleGuestPermissions, getAppPossiblePermissions, getAppRolePermissions, } from './authorization.js';
import { getAppBlocks, normalizeBlockName } from './blockUtils.js';
import { findPageByName } from './findPageByName.js';
import { BlockParamInstanceValidator, normalize, partialNormalized } from './index.js';
import { iterApp } from './iterApp.js';
import { has } from './miscellaneous.js';
import { serverActions } from './serverActions.js';
import { PredefinedAppRole, predefinedAppRolePermissions, } from './types/index.js';
const allResourcePermissionPattern = /^\$resource:all:(get|history:get|query|create|delete|patch|update)$/;
const resourcePermissionPattern = /^\$resource:[^:]+:(get|history:get|query|create|delete|patch|update)$/;
const allOwnResourcePermissionPattern = /^\$resource:all:own:(get|query|delete|patch|update)$/;
const ownResourcePermissionPattern = /^\$resource:[^:]+:own:(get|query|delete|patch|update)$/;
const allResourceViewPermissionPattern = /^\$resource:all:(get|query):[^:]+$/;
const resourceViewPermissionPattern = /^\$resource:[^:]+:(get|query):[^:]+$/;
const allWebhookPermissionPattern = /^\$webhook:all:invoke$/;
const webhookPermissionPattern = /^\$webhook:[^:]+:invoke$/;
/**
* Check whether or not the given link represents a link related to the Appsemble core.
*
* @param link The link to check
* @returns Whether or not the given link represents a link related to the Appsemble core.
*/
export function isAppLink(link) {
return (link === '/Login' || link === '/Settings' || link === '/Register' || link === '/Reset-Password');
}
function validateJSONSchema(schema, prefix, report) {
if (schema.type !== 'object') {
return;
}
if (!('properties' in schema)) {
report(schema, 'is missing properties', prefix);
return;
}
if (Array.isArray(schema.required)) {
for (const [index, name] of schema.required.entries()) {
if (!has(schema.properties, name)) {
report(name, 'is not defined in properties', [...prefix, 'required', index]);
}
}
}
for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) {
validateJSONSchema(propertySchema, [...prefix, 'properties', key], report);
}
}
/**
* Validates the pages in the app definition to ensure there are no duplicate page names.
*
* @param definition The definition of the app
* @param report A function used to report a value.
*/
function validateUniquePageNames(definition, report) {
if (!definition.pages) {
return;
}
const pageNames = new Map();
function checkPages(pages, parentPath = []) {
for (const page of pages) {
const pageName = page.name;
const normalizedPageName = normalize(page.name);
const pagePath = [...parentPath, pageName];
if (pageNames.has(normalizedPageName)) {
const paths = pageNames.get(normalizedPageName);
paths.push(pagePath);
report(pageName, 'is a duplicate page name', pagePath);
}
else {
pageNames.set(normalizedPageName, [pagePath]);
}
if (page.type === 'container') {
checkPages(page.pages, pagePath);
}
}
}
checkPages(definition.pages);
}
function validateMembersSchema(definition, report) {
if (!definition.members?.properties) {
return;
}
for (const [propertyName, propertyDefinition] of Object.entries(definition.members.properties ?? {})) {
// Handled by schema validation
if (!propertyDefinition?.schema) {
continue;
}
const { schema } = propertyDefinition;
const prefix = ['members', 'properties', propertyName, 'schema'];
validateJSONSchema(schema, prefix, report);
if (!('type' in schema) && !('enum' in schema)) {
report(schema, 'must define type or enum', prefix);
}
if ('reference' in propertyDefinition && propertyDefinition.reference) {
const { resource: resourceName } = propertyDefinition.reference;
const resourceDefinition = definition.resources?.[resourceName];
if (!resourceDefinition) {
report(resourceName, 'refers to a resource that doesn’t exist', [
'members',
'properties',
propertyName,
'reference',
resourceName,
]);
}
}
}
}
function validatePhoneNumberDefinition(definition, report) {
if (!definition.members?.phoneNumber) {
return;
}
if (!definition.members.phoneNumber.enable && definition.members.phoneNumber.required) {
report(definition.members.phoneNumber, 'phone number should be enabled', [
'members',
'phoneNumber',
'required',
]);
}
}
// XXX: Very not good nesting
function validateResourceSchemas(definition, report) {
if (!definition.resources) {
return;
}
for (const [resourceName, resource] of Object.entries(definition.resources)) {
// Handled by schema validation
if (!resource?.schema) {
continue;
}
const { enforceOrderingGroupByFields, positioning, schema } = resource;
const prefix = ['resources', resourceName, 'schema'];
if (!positioning && enforceOrderingGroupByFields?.length) {
report(enforceOrderingGroupByFields, 'must set positioning to true', [
'resources',
resourceName,
'enforceOrderingGroupByFields',
]);
}
if (enforceOrderingGroupByFields?.some((item) => !item.match('^[a-zA-Z0-9]*$'))) {
report(enforceOrderingGroupByFields, 'must be alphanumeric', [
'resources',
resourceName,
'enforceOrderingGroupByFields',
]);
}
const reservedKeywords = new Set([
'created',
'updated',
'author',
'editor',
'seed',
'ephemeral',
'clonable',
'expires',
// XXX: is position reserved?
]);
if (reservedKeywords.has(resourceName)) {
report(schema, 'is a reserved keyword', ['resources', resourceName]);
}
validateJSONSchema(schema, prefix, report);
if (!('type' in schema)) {
report(schema, 'must define type object', prefix);
}
else if (schema.type !== 'object') {
report(schema.type, 'must define type object', [...prefix, 'type']);
}
if ('properties' in schema) {
for (const [propertyName, propertySchema] of Object.entries(schema.properties ?? {})) {
if (propertyName === 'id') {
for (const [validatorKey, value] of Object.entries(propertySchema)) {
if (validatorKey === 'description' || validatorKey === 'title') {
continue;
}
if (validatorKey === 'type') {
if (value !== 'integer' && value !== 'number') {
report(value, 'must be integer', [
...prefix,
'properties',
propertyName,
validatorKey,
]);
}
continue;
}
report(value, 'does not support custom validators', [
...prefix,
'properties',
propertyName,
validatorKey,
]);
}
}
else if (propertyName === '$created' || propertyName === '$updated') {
for (const [validatorKey, value] of Object.entries(propertySchema)) {
if (validatorKey === 'description' || validatorKey === 'title') {
continue;
}
if (validatorKey === 'type') {
if (value !== 'string') {
report(value, 'must be string', [
...prefix,
'properties',
propertyName,
validatorKey,
]);
}
continue;
}
if (validatorKey === 'format') {
if (value !== 'date-time') {
report(value, 'must be date-time', [
...prefix,
'properties',
propertyName,
validatorKey,
]);
}
continue;
}
report(value, 'does not support custom validators', [
...prefix,
'properties',
propertyName,
validatorKey,
]);
}
}
else if (propertyName.startsWith('$')) {
report(propertySchema, 'may not start with $', [...prefix, 'properties', propertyName]);
}
}
}
}
}
function validateController(definition, controllerImplementations, report) {
if (!definition.controller || !controllerImplementations) {
return;
}
iterApp(definition, {
onController(controller, path) {
// TODO: Google what an early return/guard statement is. Never ever forget it.
const actionParameters = new Set();
if (controller.actions) {
if (controllerImplementations.actions) {
for (const [key, action] of Object.entries(controller.actions)) {
if (action.type in
// TODO: What is this doing here? Hardcoded? Make a central place for FE-only actions,
// like we have for server actions
[
'link',
'link.back',
'link.next',
'dialog',
'dialog.ok',
'dialog.error',
'flow.back',
'flow.cancel',
'flow.finish',
'flow.next',
'flow.to',
]) {
report(action, 'cannot be used in controllers', [...path, 'actions', key]);
}
if (controllerImplementations.actions.$any) {
if (actionParameters.has(key)) {
continue;
}
if (!has(controllerImplementations.actions, key)) {
report(action, 'is unused', [...path, 'actions', key]);
}
}
else if (!has(controllerImplementations.actions, key)) {
report(action, 'is an unknown action for this controller', [...path, 'actions', key]);
}
}
}
else {
report(controller.actions, 'is not allowed on this controller', [...path, 'actions']);
}
}
if (!controller.events) {
return;
}
if (controller.events.emit) {
for (const [key, value] of Object.entries(controller.events.emit)) {
if (!controllerImplementations.events?.emit?.$any &&
!has(controllerImplementations.events?.emit, key)) {
report(value, 'is an unknown event emitter', [...path, 'events', 'emit', key]);
}
}
}
if (controller.events.listen) {
for (const [key, value] of Object.entries(controller.events.listen)) {
if (!controllerImplementations.events?.listen?.$any &&
!has(controllerImplementations.events?.listen, key)) {
report(value, 'is an unknown event listener', [...path, 'events', 'listen', key]);
}
}
}
},
});
}
function getTemplateAreas(layoutDefinition) {
const areas = new Set();
if (!layoutDefinition) {
return areas;
}
for (const deviceDefinition of Object.values(layoutDefinition)) {
if (deviceDefinition?.layout?.template) {
for (const row of deviceDefinition.layout.template) {
for (const area of row.split(' ')) {
if (area && area !== '.') {
areas.add(area);
}
}
}
}
}
return areas;
}
function validateTemplateAreasAreRectangular(template, report, path) {
const grid = template.map((row) => row.split(' '));
function isAreaRectangular(areaName) {
const cells = [];
for (const [row, rowCells] of grid.entries()) {
for (const [col, cell] of rowCells.entries()) {
if (cell === areaName) {
cells.push({ row, col });
}
}
}
if (cells.length === 0) {
return true;
}
let minRow = Infinity;
let maxRow = -Infinity;
let minCol = Infinity;
let maxCol = -Infinity;
for (const cell of cells) {
minRow = Math.min(minRow, cell.row);
maxRow = Math.max(maxRow, cell.row);
minCol = Math.min(minCol, cell.col);
maxCol = Math.max(maxCol, cell.col);
}
const expectedCount = (maxRow - minRow + 1) * (maxCol - minCol + 1);
if (cells.length !== expectedCount) {
return false;
}
for (let row = minRow; row <= maxRow; row += 1) {
for (let col = minCol; col <= maxCol; col += 1) {
if (grid[row][col] !== areaName) {
return false;
}
}
}
return true;
}
const areas = new Set();
for (const row of grid) {
for (const cell of row) {
if (cell && cell !== '.') {
areas.add(cell);
}
}
}
for (const area of areas) {
if (!isAreaRectangular(area)) {
report(template, `grid area '${area}' does not form a single contiguous rectangle`, path);
}
}
}
function validatePageLayoutDefinition(layoutDefinition, report, path) {
if (!layoutDefinition) {
return;
}
for (const [deviceName, deviceDefinition] of Object.entries(layoutDefinition)) {
if (!deviceDefinition?.layout?.template || !deviceDefinition?.layout?.columns) {
continue;
}
if (deviceDefinition.layout.template.some((row) => row.split(' ').length !== deviceDefinition.layout.columns)) {
report(deviceDefinition.layout.template, 'template needs to be the same length as number of columns', [...path, deviceName, 'layout', 'template']);
}
validateTemplateAreasAreRectangular(deviceDefinition.layout.template, report, [
...path,
deviceName,
'layout',
'template',
]);
}
}
function validateBlockGridAreas(blocks, layoutDefinition, report, path) {
const templateAreas = getTemplateAreas(layoutDefinition);
for (const [idx, block] of blocks.entries()) {
if (block.gridArea && !templateAreas.has(block.gridArea)) {
report(block.gridArea, `does not match any area defined in the layout template. Available areas: ${[...templateAreas].join(', ') || 'none'}`, [...path, idx, 'gridArea']);
}
}
}
function validateSubPageLayout(layout, blocks, report, path) {
validatePageLayoutDefinition(layout, report, path);
if (layout && blocks) {
validateBlockGridAreas(blocks, layout, report, [...path, 'blocks']);
}
}
function validateGridLayout(definition, report) {
iterApp(definition, {
onPage(page, path) {
// Basic page
if (page.type === 'page' || page.type === undefined) {
const basicPage = page;
validateSubPageLayout(basicPage.layout, basicPage.blocks, report, path);
return;
}
// Tabs page
if (page.type === 'tabs') {
const tabsPage = page;
if (tabsPage.tabs) {
for (const [tabIdx, tab] of tabsPage.tabs.entries()) {
validateSubPageLayout(tab.layout, tab.blocks, report, [...path, 'tabs', tabIdx]);
}
}
else if (tabsPage.definition?.foreach) {
validateSubPageLayout(tabsPage.definition.foreach.layout, tabsPage.definition.foreach.blocks, report, [...path, 'definition', 'foreach']);
}
return;
}
// Flow page
if (page.type === 'flow') {
const flowPage = page;
for (const [stepIdx, step] of flowPage.steps.entries()) {
validateSubPageLayout(step.layout, step.blocks, report, [...path, 'steps', stepIdx]);
}
return;
}
// Loop page
if (page.type === 'loop') {
const loopPage = page;
validateSubPageLayout(loopPage.foreach.layout, loopPage.foreach.blocks, report, [
...path,
'foreach',
]);
}
},
});
}
function validateBlocks(definition, blockVersions, report) {
iterApp(definition, {
onBlock(block, path) {
const type = normalizeBlockName(block.type);
const versions = blockVersions.get(type);
if (!versions) {
report(block.type, 'is not a known block type', [...path, 'type']);
return;
}
const version = versions.get(block.version);
if (!version) {
report(block.version, 'is not a known version for this block type', [...path, 'version']);
return;
}
const validateBlockParams = () => {
if (!version.parameters) {
if (block.parameters) {
report(block.parameters, 'is not allowed on this block type', [...path, 'parameters']);
}
return { actionsReferenced: new Set() };
}
const paramValidator = new BlockParamInstanceValidator({
actions: Object.keys(block.actions ?? {}),
emitters: Object.keys(block.events?.emit ?? {}),
listeners: Object.keys(block.events?.listen ?? {}),
});
const [result, actionsReferenced] = paramValidator.validateParametersInstance(block.parameters ?? {}, version.parameters);
if ('parameters' in block) {
for (const error of result.errors) {
report(error.instance, error.message, [...path, 'parameters', ...error.path]);
}
}
else if (!result.valid) {
report(block, 'requires property "parameters"', path);
}
return { actionsReferenced };
};
const validateBlockActions = (actionsReferenced) => {
if (!block.actions) {
return;
}
if (!version.actions) {
report(block.actions, 'is not allowed on this block', [...path, 'actions']);
return;
}
for (const [key, action] of Object.entries(block.actions)) {
if (!version.actions.$any) {
if (!has(version.actions, key)) {
report(action, 'is an unknown action for this block', [...path, 'actions', key]);
}
continue;
}
if (actionsReferenced.has(key)) {
continue;
}
if (!has(version.actions, key) && !version.wildcardActions) {
report(action, 'is unused', [...path, 'actions', key]);
}
}
};
const { actionsReferenced } = validateBlockParams();
validateBlockActions(actionsReferenced);
if (!block.events) {
return;
}
if (block.events.emit) {
for (const [key, value] of Object.entries(block.events.emit)) {
if (!version.events?.emit?.$any && !has(version.events?.emit, key)) {
report(value, 'is an unknown event emitter', [...path, 'events', 'emit', key]);
}
}
}
if (block.events.listen) {
for (const [key, value] of Object.entries(block.events.listen)) {
if (!version.events?.listen?.$any && !has(version.events?.listen, key)) {
report(value, 'is an unknown event listener', [...path, 'events', 'listen', key]);
}
}
}
},
});
}
// XXX: very repetitive code
function validatePermissions(appDefinition, permissions, inheritedPermissions, possiblePermissions, report, path) {
const checked = [];
for (const [index, permission] of permissions.entries()) {
if (checked.includes(permission)) {
report(appDefinition, 'duplicate permission declaration', [...path, 'permissions', index]);
return;
}
if (!possiblePermissions.includes(permission)) {
if (resourcePermissionPattern.test(permission) ||
ownResourcePermissionPattern.test(permission)) {
const [, resourceName] = permission.split(':');
if (resourceName && resourceName !== 'all' && !appDefinition.resources?.[resourceName]) {
report(appDefinition, `resource ${resourceName} does not exist in the app's resources definition`, [...path, 'permissions', index]);
return;
}
}
if (resourceViewPermissionPattern.test(permission)) {
const [, resourceName, , resourceView] = permission.split(':');
if (resourceName === 'all') {
for (const [rName, resourceDefinition] of Object.entries(appDefinition.resources ?? {})) {
if (!resourceDefinition.views?.[resourceView]) {
report(appDefinition, `resource ${rName} is missing a definition for the ${resourceView} view`, [...path, 'permissions', index]);
return;
}
}
}
else {
if (!appDefinition.resources?.[resourceName]?.views?.[resourceView]) {
report(appDefinition, `resource ${resourceName} is missing a definition for the ${resourceView} view`, [...path, 'permissions', index]);
return;
}
}
}
if (webhookPermissionPattern.test(permission)) {
const [, webhookName] = permission.split(':');
if (webhookName !== 'all' && !appDefinition.webhooks?.[webhookName]) {
report(appDefinition, `webhook ${webhookName} does not exist in the app's webhooks definition`, [...path, 'permissions', index]);
return;
}
}
report(appDefinition, 'invalid permission', [...path, 'permissions', index]);
return;
}
if (inheritedPermissions.includes(permission)) {
report(appDefinition, 'permission is already inherited from another role', [
...path,
'permissions',
index,
]);
return;
}
const otherPermissions = permissions.filter((p) => p !== permission);
// XXX: very duplicated code below, not readable, not in english/human/functional terms.
if (resourcePermissionPattern.test(permission)) {
const [, , resourceAction] = permission.split(':');
if (otherPermissions.some((p) => {
if (allResourcePermissionPattern.test(p)) {
const [, , otherResourceAction] = p.split(':');
return otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all is already declared`, [...path, 'permissions', index]);
return;
}
if (inheritedPermissions.some((p) => {
if (allResourcePermissionPattern.test(p)) {
const [, , otherResourceAction] = p.split(':');
return otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all is already inherited from another role`, [...path, 'permissions', index]);
return;
}
}
if (ownResourcePermissionPattern.test(permission)) {
const [, resourceName, , resourceAction] = permission.split(':');
if (otherPermissions.some((p) => {
if (resourcePermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction] = p.split(':');
return (resourceName !== 'all' &&
otherResourceName === resourceName &&
otherResourceAction === resourceAction);
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action on resource ${resourceName} is already declared`, [...path, 'permissions', index]);
return;
}
if (otherPermissions.some((p) => {
if (allOwnResourcePermissionPattern.test(p)) {
const [, , , otherResourceAction] = p.split(':');
return otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. An own permission for the ${resourceAction} resource action with scope all is already declared`, [...path, 'permissions', index]);
return;
}
if (otherPermissions.some((p) => {
if (allResourcePermissionPattern.test(p)) {
const [, , otherResourceAction] = p.split(':');
return otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all is already declared`, [...path, 'permissions', index]);
return;
}
if (inheritedPermissions.some((p) => {
if (resourcePermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction] = p.split(':');
return (resourceName !== 'all' &&
otherResourceName === resourceName &&
otherResourceAction === resourceAction);
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action on resource ${resourceName} is already inherited from another role`, [...path, 'permissions', index]);
return;
}
if (inheritedPermissions.some((p) => {
if (allOwnResourcePermissionPattern.test(p)) {
const [, , , otherResourceAction] = p.split(':');
return otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. An own permission for the ${resourceAction} resource action with scope all is already inherited from another role`, [...path, 'permissions', index]);
return;
}
if (inheritedPermissions.some((p) => {
if (allResourcePermissionPattern.test(p)) {
const [, , otherResourceAction] = p.split(':');
return otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all is already inherited from another role`, [...path, 'permissions', index]);
return;
}
}
if (resourceViewPermissionPattern.test(permission)) {
const [, resourceName, resourceAction, resourceView] = permission.split(':');
// $resource:type:query:public, $resource:type:query:private
if (otherPermissions.some((p) => {
if (resourceViewPermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction] = p.split(':');
return (otherResourceName !== 'all' &&
otherResourceName === resourceName &&
otherResourceAction === resourceAction);
}
return false;
})) {
report(appDefinition, `a view permission for the ${resourceAction} action on resource ${resourceName} is already declared`, [...path, 'permissions', index]);
return;
}
// $resource:type:query:public, $resource:all:query:private
if (otherPermissions.some((p) => {
if (allResourceViewPermissionPattern.test(p)) {
const [, , otherResourceAction, otherResourceView] = p.split(':');
return otherResourceAction === resourceAction && otherResourceView !== resourceView;
}
return false;
})) {
report(appDefinition, `a view permission for the ${resourceAction} action with scope all is already declared`, [...path, 'permissions', index]);
return;
}
// $resource:type:query:public, $resource:type:query
if (otherPermissions.some((p) => {
if (resourcePermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction] = p.split(':');
return otherResourceName === resourceName && otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} action on resource ${resourceName} without a specific view is already declared`, [...path, 'permissions', index]);
return;
}
// $resource:type:query:public, $resource:all:query
if (otherPermissions.some((p) => {
if (resourcePermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction] = p.split(':');
return otherResourceName === 'all' && otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all without a specific view is already declared`, [...path, 'permissions', index]);
return;
}
// $resource:type:query:public, $resource:all:query:public
if (otherPermissions.some((p) => {
if (allResourceViewPermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction, otherResourceView] = p.split(':');
return (otherResourceName === 'all' &&
otherResourceAction === resourceAction &&
otherResourceView === resourceView);
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all for this view is already declared`, [...path, 'permissions', index]);
return;
}
// $resource:type:query:private
// $resource:type:query:public
if (inheritedPermissions.some((p) => {
if (resourceViewPermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction] = p.split(':');
return (otherResourceName !== 'all' &&
otherResourceName === resourceName &&
otherResourceAction === resourceAction);
}
return false;
})) {
report(appDefinition, `a view permission for the ${resourceAction} action on resource ${resourceName} is already inherited from another role`, [...path, 'permissions', index]);
return;
}
// $resource:all:query:private
// $resource:type:query:public
if (inheritedPermissions.some((p) => {
if (allResourceViewPermissionPattern.test(p)) {
const [, , otherResourceAction, otherResourceView] = p.split(':');
return otherResourceAction === resourceAction && otherResourceView !== resourceView;
}
return false;
})) {
report(appDefinition, `a view permission for the ${resourceAction} action with scope all is already inherited from another role`, [...path, 'permissions', index]);
return;
}
// $resource:type:query
// $resource:type:query:public
if (inheritedPermissions.some((p) => {
if (resourcePermissionPattern.test(p)) {
const [, otherResourceName, otherResourceAction] = p.split(':');
return otherResourceName === resourceName && otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} action on resource ${resourceName} without a specific view is already inherited from another role`, [...path, 'permissions', index]);
return;
}
// $resource:all:query
// $resource:type:query:public
if (inheritedPermissions.some((p) => {
if (allResourcePermissionPattern.test(p)) {
const [, , otherResourceAction] = p.split(':');
return otherResourceAction === resourceAction;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all without a specific view is already inherited from another role`, [...path, 'permissions', index]);
return;
}
// $resource:all:query:public
// $resource:type:query:public
if (inheritedPermissions.some((p) => {
if (allResourceViewPermissionPattern.test(p)) {
const [, , otherResourceAction, otherResourceView] = p.split(':');
return otherResourceAction === resourceAction && otherResourceView === resourceView;
}
return false;
})) {
report(appDefinition, `redundant permission. A permission for the ${resourceAction} resource action with scope all for this view is already inherited from another role`, [...path, 'permissions', index]);
return;
}
}
checked.push(permission);
}
}
function checkCyclicRoleInheritance(roles, name, report) {
let lastChecked;
const stack = [];
const checkRoleRecursively = (role) => {
lastChecked = role;
if (stack.includes(role)) {
return true;
}
stack.push(role);
return Boolean(roles[role]?.inherits?.some(checkRoleRecursively));
};
const duplicate = checkRoleRecursively(name);
if (duplicate && lastChecked === name) {
report(roles[name], 'cyclically inherits itself', ['security', 'roles', name]);
}
}
/**
* Validate security related definitions within the app definition.
*
* @param definition The definition of the app
* @param report A function used to report a value.
*/
function validateSecurity(definition, report) {
const { notifications, security } = definition;
const predefinedRoles = Object.keys(PredefinedAppRole);
const checkRoleExists = (name, path, roles = predefinedRoles) => {
if (!has(security?.roles, name) && !roles.includes(name)) {
report(name, 'does not exist in this app’s roles', path);
return false;
}
return true;
};
const checkRoles = (object, path) => {
if (!object?.roles) {
return;
}
for (const [index, role] of object.roles.entries()) {
checkRoleExists(role, [...path, 'roles', index], ['$guest', ...predefinedRoles]);
}
};
if (!security) {
if (notifications === 'login') {
report(notifications, 'only works if security is defined', ['notifications']);
}
return;
}
if ((!security.default || !security.roles) && !security.guest && !security.cron) {
report(definition, 'invalid security definition. Must define either guest or cron or roles and default', ['security']);
return;
}
if (security.guest) {
if (security.guest.inherits && security.guest.inherits.length && !security.roles) {
report(definition, 'guest can not inherit roles if the roles property is not defined', [
'security',
'guest',
'inherits',
]);
return;
}
const inheritedPermissions = getAppRolePermissions(security, security.guest.inherits || []);
const possibleGuestPermissions = getAppPossibleGuestPermissions(definition);
if (inheritedPermissions.some((ip) => !possibleGuestPermissions.includes(ip))) {
report(definition, 'invalid security definition. Guest cannot inherit roles that contain own resource permissions', ['security', 'guest', 'inherits']);
return;
}
if (security.guest.permissions) {
validatePermissions(definition, security.guest.permissions, inheritedPermissions, possibleGuestPermissions, report, ['security', 'guest']);
}
}
else if (security.cron) {
if (!definition.cron) {
report(definition, 'can not define cron definition without a cron job', ['security', 'cron']);
return;
}
if (security.cron.inherits && security.cron.inherits.length && !security.roles) {
report(definition, 'cron can not inherit roles if the roles property is not defined', [
'security',
'cron',
'inherits',
]);
return;
}
const inheritedPermissions = getAppRolePermissions(security, security.cron.inherits || []);
const possibleCronPermissions = getAppPossibleGuestPermissions(definition);
if (inheritedPermissions.some((ip) => !possibleCronPermissions.includes(ip))) {
report(definition, 'invalid security definition. Guest cannot inherit roles that contain own resource permissions', ['security', 'cron', 'inherits']);
return;
}
if (security.cron.permissions) {
validatePermissions(definition, security.cron.permissions, inheritedPermissions, possibleCronPermissions, report, ['security', 'cron']);
}
}
else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore 18048 variable is possibly undefined (strictNullChecks)
checkRoleExists(security.default.role, ['security', 'default', 'role']);
}
if (security.roles) {
const possibleAppPermissions = getAppPossiblePermissions(definition);
for (const [name, role] of Object.entries(security.roles)) {
if ([...predefinedRoles, 'cron'].includes(name)) {
report(definition, `not allowed to overwrite role ${name}`, ['security', 'roles', name]);
}
const inheritedPermissions = [];
if (role?.inherits) {
let found = false;
for (const [index, inherited] of (role.inherits || []).entries()) {
found || (found = checkRoleExists(inherited, ['security', 'roles', name, 'inherits', index]));
}
if (found) {
checkCyclicRoleInheritance(security.roles, name, report);
}
const inheritedRoles = getAppInheritedRoles(security, [name]).filter((r) => r !== name);
for (const inheritedRole of inheritedRoles) {
const roleDefinition = security.roles[inheritedRole];
if (roleDefinition) {
const rolePermissions = roleDefinition.permissions;
if (rolePermissions) {
inheritedPermissions.push(...rolePermissions);
}
}
else {
const predefinedRolePermissions = predefinedAppRolePermissions[inheritedRole];
if (predefinedRolePermissions) {
inheritedPermissions.push(...predefinedRolePermissions);
}
}
}
}
if (role.permissions) {
validatePermissions(definition, role.permissions, inheritedPermissions, possibleAppPermissions, report, ['security', 'roles', name]);
}
}
}
iterApp(definition, { onBlock: checkRoles, onPage: checkRoles });
}
/**
* Validates the hooks in resource definition to ensure its properties are valid.
*
* @param definition The definition of the app
* @param report A function used to report a value.
*/
function validateHooks(definition, report) {
if (!definition.resources) {
return;
}
const actionTypes = ['create', 'update', 'delete'];
for (const [resourceKey, resource] of Object.entries(definition.resources)) {
for (const actionType of actionTypes) {
if (!has(resource, actionType)) {
continue;
}
const tos = resource[actionType]?.hooks?.notification?.to;
if (tos) {
for (const [index, to] of tos.entries()) {
if (to !== '$author' && !has(definition.security?.roles, to)) {
report(to, 'is an unknown role', [
'resources',
resourceKey,
actionType,
'hooks',
'notifications',
'to',
index,
]);
}
}
}
}
}
}
function validateResourceReferences(definition, report) {
if (!definition.resources) {
return;
}
for (const [resourceType, resource] of Object.entries(definition.resources)) {
if (!resource.references) {
continue;
}
for (const [field, reference] of Object.entries(resource.references)) {
if (!has(definition.resources, reference.resource)) {
report(reference.resource, 'is not an existing resource', [
'resources',
resourceType,
'references',
field,
'resource',
]);
continue;
}
if (!has(resource.schema.properties, field)) {
report(field, 'does not exist on this resource', [
'resources',
resourceType,
'references',
field,
]);
}
}
}
}
function validateLanguage({ defaultLanguage }, report) {
if (defaultLanguage != null && !languageTags.check(defaultLanguage)) {
report(defaultLanguage, 'is not a valid language code', ['defaultLanguage']);
}
}
function validateDefaultPage({ defaultPage, pages }, report) {
const page = findPageByName(pages, defaultPage);
if (!page) {
report(defaultPage, 'does not refer to an existing page', ['defaultPage']);
return;
}
if (page.parameters) {
report(defaultPage, 'may not specify parameters', ['defaultPage']);
}
}
function validateCronJobs({ cron }, report) {
if (!cron) {
return;
}
for (const [id, job] of Object.entries(cron)) {
if (typeof job?.schedule !== 'string') {
continue;
}
try {
cronParser.parseExpression(job.schedule);
}
catch {
report(job.schedule, 'contains an invalid expression', ['cron', id, 'schedule']);
}
}
}
// TODO: horrible nesting
function validateActions(definition, report) {
const urlRegex = new RegExp(`^${partialNormalized.source}:`);
iterApp(definition, {
onAction(action, path) {
// XXX: could we validate server-side actions differently
if (path[0] === 'cron' && !serverActions.has(action.type)) {
report(action.type, 'action type is not supported for cron jobs', [...path, 'type']);
return;
}
if (path[0] === 'webhooks' && !serverActions.has(action.type)) {
report(action.type, 'action type is not supported for webhooks', [...path, 'type']);
return;
}
if (action.type.startsWith('app.member.') && !definition.security) {
report(action.type, 'refers to an app member action but the app doesn’t have a security definition', [...path, 'type']);
return;
}
if (['app.member.register', 'app.member.properties.patch', 'app.member.current.patch'].includes(action.type) &&
Object.values(action.properties ?? {})[0] &&
definition.members?.properties) {
for (const propertyName of Object.keys(Object.values(action.properties ?? {})[0])) {
if (!definition.members?.properties[propertyName]) {
report(action.type, 'contains a property that doesn’t exist in app member properties', [
...path,
'properties',
]);
}
}
}
if (action.type.startsWith('resource.')) {
// All of the actions starting with `resource.` contain a property called `resource`.
const { resource: resourceName, view } = action;
const resource = definition.resources?.[resourceName];
const [, resourceAction] = action.type.split('.');
if (!resource) {
report(action.type, 'refers to a resource that doesn’t exist', [...path, 'resource']);
return;
}
if (!action.type.startsWith('resource.subscription.')) {
if (!definition.security) {
report(action.type, 'missing security definition', [...path, 'resource']);
return;
}
// IMPORTANT: Create a COPY of the permissions array to avoid mutating the definition
const allPermissions = [...(definition.security.guest?.permissions || [])];
if (definition.security?.cron) {
allPermissions.push(...(definiti