UNPKG

@appsemble/lang-sdk

Version:

Language SDK for Appsemble

1,035 lines 58.9 kB
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):[^:]+$/; /** * 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'; } 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 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; } } } 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/prefer-ts-expect-error // @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; } const allPermissions = definition.security.guest?.permissions || []; if (definition.security?.cron) { allPermissions.push(...(definition.security.cron.permissions ?? [])); } if (definition.security.roles) { const allRolePermissions = getAppRolePermissions(definition.security, Object.keys(definition.security.roles)); allPermissions.push(...allRolePermissions); } // TODO: repeats way too much, code not self-explanatory at all if (!allPermissions.some((permission) => { if (resourcePermissionPattern.test(permission)) { const [, permissionResourceName, permissionResourceAction] = permission.split(':'); return (['all', resourceName].includes(permissionResourceName) && (permissionResourceAction === resourceAction || (resourceAction === 'count' && permissionResourceAction === 'query'))); } if (ownResourcePermissionPattern.test(permission)) { const [, permissionResourceName, , permissionResourceAction] = permission.split(':'); return (['all', resourceName].includes(permissionResourceName) && (permissionResourceAction === resourceAction || (resourceAction === 'count' && permissionResourceAction === 'query'))); } return false; })) { report(action.type, 'there is no one in the app who has permissions to use this action', [...path, 'resource']); return; } if (view && !allPermissions.some((permission) => { if (resourceViewPermissionPattern.test(permission)) { const [, permissionResourceName, permissionResourceAction, permissionResourceView] = permission.split(':'); return (['all', resourceName].includes(permissionResourceName) && permissionResourceAction === resourceAction && (!permissionResourceView || permissionResourceView === view)); } return false; })) { report(action.type, 'there is no one in the app who has permissions to use this action', [...path, 'resource']); return; } } } if (action.type.startsWith('flow.')) { // TODO: path indexes are not sane. Raw-dogging the prefix is not sane. const page = definition.pages?.[Number(path[1])]; if (page.type !== 'flow' && page.type !== 'loop') { report(action.type, 'flow actions can only be used on pages with the type ‘flow’ or ‘loop’', [...path, 'type']); return; } if (action.type === 'flow.cancel' && !page.actions?.onFlowCancel) { report(action.type, 'was defined but ‘onFlowCancel’ page action wasn’t defined', [ ...path, 'type', ]); return; } if (action.type === 'flow.finish' && !page.actions?.onFlowFinish) { report(action.type, 'was defined but ‘onFlowFinish’ page action wasn’t defined', [ ...path, 'type', ]); return; } if (action.type === 'flow.back' && path[3] === 0) { report(action.type, 'is not allowed on the first step in the flow', [...path, 'type']); return; } if (page.type === 'flow' && action.type === 'flow.next' && Number(path[3]) === page.steps.length - 1 && !page.actions?.onFlowFinish) { report(action.type, 'was defined on the last step but ‘onFlowFinish’ page action wasn’t defined', [...path, 'type']); return; } if (page.type === 'flow' && action.type === 'flow.to' && !page.steps.some((step) => step.name === action.step)) { report(action.type, 'refers to a step that doesn’t exist', [...path, 'step']); return; } } if (action.type === 'link') { const { to } = action; if (typeof to === 'object' && (!Array.isArray(to) || (Array.isArray(to) && to.every((entry) => typeof entry === 'object')))) { return; } if (typeof to === 'string' && urlRegex.test(to)) { return; } if (isAppLink(to)) { return; } const [toBase, toSub] = [].concat(to); // @ts-expect-error 2345 argument of type is not assignable to parameter of type // (strictNullChecks) - Severe const toPage = findPageByName(definition.pages, toBase); if (!toPage) { report(to, 'refers to a page that doesn’t exist', [...path, 'to']); return; } if (toPage.type !== 'tabs' && toSub) { report(toSub, 'refers to a sub page on a page that isn’t of type ‘tabs’ or ‘flow’', [ ...path, 'to', 1, ]); return; } if (toPage.type === 'tabs' && toSub && Array.isArray(toPage.tabs)) { const subPage = toPage.tabs.find(({ name }) => n