diff --git a/packages/backend/bin/database/utils.ts b/packages/backend/bin/database/utils.ts index b1170280..369dc3be 100644 --- a/packages/backend/bin/database/utils.ts +++ b/packages/backend/bin/database/utils.ts @@ -2,18 +2,55 @@ import appConfig from '../../src/config/app'; import logger from '../../src/helpers/logger'; import client from './client'; import User from '../../src/models/user'; +import Role from '../../src/models/role'; +import Permission from '../../src/models/permission'; import '../../src/config/orm'; +async function seedPermissionsIfNeeded() { + const existingPermissions = await Permission.query().limit(1).first(); + + if (!existingPermissions) return; + + const getPermission = (subject: string, actions: string[]) => actions.map(action => ({ subject, action })); + + await Permission.query().insert([ + ...getPermission('Connection', ['create', 'read', 'delete', 'update']), + ...getPermission('Execution', ['read']), + ...getPermission('Flow', ['create', 'delete', 'publish', 'read', 'update']), + ...getPermission('Role', ['create', 'delete', 'read', 'update']), + ...getPermission('User', ['create', 'delete', 'read', 'update']), + ]) +} + +async function createOrFetchRole() { + const role = await Role.query().limit(1).first(); + + if (!role) { + const createdRole = await Role.query().insertAndFetch({ + name: 'Admin', + key: 'admin', + }); + + return createdRole; + } + + return role; +} + export async function createUser( email = 'user@automatisch.io', password = 'sample' ) { const UNIQUE_VIOLATION_CODE = '23505'; + + await seedPermissionsIfNeeded(); + + const role = await createOrFetchRole(); const userParams = { email, password, fullName: 'Initial admin', - role: 'admin', + roleId: role.id, }; try { diff --git a/packages/backend/knexfile.ts b/packages/backend/knexfile.ts index 77d5938f..440c8ca5 100644 --- a/packages/backend/knexfile.ts +++ b/packages/backend/knexfile.ts @@ -12,6 +12,7 @@ const knexConfig = { database: appConfig.postgresDatabase, ssl: appConfig.postgresEnableSsl, }, + asyncStackTraces: appConfig.isDev, searchPath: [appConfig.postgresSchema], pool: { min: 0, max: 20 }, migrations: { diff --git a/packages/backend/package.json b/packages/backend/package.json index f7e8e1c2..14b5f803 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -24,6 +24,7 @@ "dependencies": { "@automatisch/web": "^0.7.1", "@bull-board/express": "^3.10.1", + "@casl/ability": "^6.5.0", "@graphql-tools/graphql-file-loader": "^7.3.4", "@graphql-tools/load": "^7.5.2", "@rudderstack/rudder-sdk-node": "^1.1.2", diff --git a/packages/backend/src/db/migrations/20230615200200_create_roles.ts b/packages/backend/src/db/migrations/20230615200200_create_roles.ts new file mode 100644 index 00000000..9189911a --- /dev/null +++ b/packages/backend/src/db/migrations/20230615200200_create_roles.ts @@ -0,0 +1,32 @@ +import { Knex } from 'knex'; +import capitalize from 'lodash/capitalize'; +import lowerCase from 'lodash/lowerCase'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('roles', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').notNullable(); + table.string('key').notNullable(); + table.string('description'); + + table.timestamps(true, true); + }); + + const uniqueUserRoles = await knex('users') + .select('role') + .groupBy('role'); + + for (const { role } of uniqueUserRoles) { + // skip empty roles + if (!role) continue; + + await knex('roles').insert({ + name: capitalize(role), + key: lowerCase(role), + }); + } +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('roles'); +} diff --git a/packages/backend/src/db/migrations/20230615205857_create_permissions.ts b/packages/backend/src/db/migrations/20230615205857_create_permissions.ts new file mode 100644 index 00000000..db7328c2 --- /dev/null +++ b/packages/backend/src/db/migrations/20230615205857_create_permissions.ts @@ -0,0 +1,25 @@ +import { Knex } from 'knex'; + +const getPermission = (subject: string, actions: string[]) => actions.map(action => ({ subject, action })); + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('permissions', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('action').notNullable(); + table.string('subject').notNullable(); + + table.timestamps(true, true); + }); + + await knex('permissions').insert([ + ...getPermission('Connection', ['create', 'read', 'delete', 'update']), + ...getPermission('Execution', ['read']), + ...getPermission('Flow', ['create', 'delete', 'publish', 'read', 'update']), + ...getPermission('Role', ['create', 'delete', 'read', 'update']), + ...getPermission('User', ['create', 'delete', 'read', 'update']), + ]); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('permissions'); +} diff --git a/packages/backend/src/db/migrations/20230615210510_create_roles_permissions.ts b/packages/backend/src/db/migrations/20230615210510_create_roles_permissions.ts new file mode 100644 index 00000000..3d43b9cd --- /dev/null +++ b/packages/backend/src/db/migrations/20230615210510_create_roles_permissions.ts @@ -0,0 +1,25 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('roles_permissions', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('role_id').references('id').inTable('roles'); + table.uuid('permission_id').references('id').inTable('permissions'); + }); + + const roles = await knex('roles').select('id'); + const permissions = await knex('permissions').select('id'); + + for (const role of roles) { + for (const permission of permissions) { + await knex('roles_permissions').insert({ + role_id: role.id, + permission_id: permission.id, + }); + } + } +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('roles_permissions'); +} diff --git a/packages/backend/src/db/migrations/20230615215004_add_role_id_to_users.ts b/packages/backend/src/db/migrations/20230615215004_add_role_id_to_users.ts new file mode 100644 index 00000000..e16000d7 --- /dev/null +++ b/packages/backend/src/db/migrations/20230615215004_add_role_id_to_users.ts @@ -0,0 +1,29 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table('users', async (table) => { + table.uuid('role_id').references('id').inTable('roles'); + }); + + const theRole = await knex('roles').select('id').limit(1).first(); + const roles = await knex('roles').select('id', 'key'); + + for (const role of roles) { + await knex('users') + .where({ + role: role.key + }) + .update({ + role_id: role.id + }); + } + + // backfill not-migratables + await knex('users').whereNull('role_id').update({ role_id: theRole.id }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.table('users', (table) => { + table.dropColumn('role_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20230623115503_remove_role_column_in_users.ts b/packages/backend/src/db/migrations/20230623115503_remove_role_column_in_users.ts new file mode 100644 index 00000000..5da0d486 --- /dev/null +++ b/packages/backend/src/db/migrations/20230623115503_remove_role_column_in_users.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table('users', async (table) => { + table.dropColumn('role'); + }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.table('users', (table) => { + table.string('role').defaultTo('user'); + }); +} diff --git a/packages/backend/src/graphql/mutations/create-user.ee.ts b/packages/backend/src/graphql/mutations/create-user.ee.ts index a6f7d12a..9faace22 100644 --- a/packages/backend/src/graphql/mutations/create-user.ee.ts +++ b/packages/backend/src/graphql/mutations/create-user.ee.ts @@ -1,4 +1,5 @@ import User from '../../models/user'; +import Role from '../../models/role'; type Params = { input: { @@ -17,11 +18,13 @@ const createUser = async (_parent: unknown, params: Params) => { throw new Error('User already exists!'); } + const role = await Role.query().findOne({ key: 'user' }); + const user = await User.query().insert({ fullName, email, password, - role: 'user', + roleId: role.id, }); return user; diff --git a/packages/backend/src/graphql/mutations/update-flow-status.ts b/packages/backend/src/graphql/mutations/update-flow-status.ts index 845af08e..cc0e4010 100644 --- a/packages/backend/src/graphql/mutations/update-flow-status.ts +++ b/packages/backend/src/graphql/mutations/update-flow-status.ts @@ -18,6 +18,8 @@ const updateFlowStatus = async ( params: Params, context: Context ) => { + context.currentUser.can('publish', 'Flow'); + let flow = await context.currentUser .$relatedQuery('flows') .findOne({ @@ -55,7 +57,7 @@ const updateFlowStatus = async ( } else { if (newActiveValue) { flow = await flow.$query().patchAndFetch({ - published_at: new Date().toISOString(), + publishedAt: new Date().toISOString(), }); const jobName = `${JOB_NAME}-${flow.id}`; @@ -78,9 +80,12 @@ const updateFlowStatus = async ( } } - flow = await flow.$query().withGraphFetched('steps').patchAndFetch({ - active: newActiveValue, - }); + flow = await flow + .$query() + .withGraphFetched('steps') + .patchAndFetch({ + active: newActiveValue, + }); return flow; }; diff --git a/packages/backend/src/graphql/queries/get-flows.ts b/packages/backend/src/graphql/queries/get-flows.ts index 48cb2319..b880dd43 100644 --- a/packages/backend/src/graphql/queries/get-flows.ts +++ b/packages/backend/src/graphql/queries/get-flows.ts @@ -10,6 +10,8 @@ type Params = { }; const getFlows = async (_parent: unknown, params: Params, context: Context) => { + context.currentUser.can('read', 'Flow'); + const flowsQuery = context.currentUser .$relatedQuery('flows') .joinRelated({ diff --git a/packages/backend/src/helpers/authentication.ts b/packages/backend/src/helpers/authentication.ts index 49dfaa27..49da3eae 100644 --- a/packages/backend/src/helpers/authentication.ts +++ b/packages/backend/src/helpers/authentication.ts @@ -12,7 +12,15 @@ const isAuthenticated = rule()(async (_parent, _args, req) => { const { userId } = jwt.verify(token, appConfig.appSecretKey) as { userId: string; }; - req.currentUser = await User.query().findById(userId).throwIfNotFound(); + req.currentUser = await User + .query() + .findById(userId) + .joinRelated({ + permissions: true, + }) + .withGraphFetched({ + permissions: true, + }); return true; } catch (error) { diff --git a/packages/backend/src/helpers/pagination.ts b/packages/backend/src/helpers/pagination.ts index eb0a760a..5d1c3981 100644 --- a/packages/backend/src/helpers/pagination.ts +++ b/packages/backend/src/helpers/pagination.ts @@ -1,10 +1,11 @@ import { Model } from 'objection'; import ExtendedQueryBuilder from '../models/query-builder'; +import type Base from '../models/base'; const paginate = async ( query: ExtendedQueryBuilder, limit: number, - offset: number + offset: number, ) => { if (limit < 1 || limit > 100) { throw new Error('Limit must be between 1 and 100'); @@ -20,11 +21,9 @@ const paginate = async ( currentPage: Math.ceil(offset / limit + 1), totalPages: Math.ceil(count / limit), }, - edges: records.map((record: Model) => { - return { - node: record, - }; - }), + edges: records.map((record: Base) => ({ + node: record, + })), }; }; diff --git a/packages/backend/src/models/connection.ts b/packages/backend/src/models/connection.ts index 105d8d4b..cda1d7cb 100644 --- a/packages/backend/src/models/connection.ts +++ b/packages/backend/src/models/connection.ts @@ -40,6 +40,9 @@ class Connection extends Base { userId: { type: 'string', format: 'uuid' }, verified: { type: 'boolean', default: false }, draft: { type: 'boolean' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; diff --git a/packages/backend/src/models/execution-step.ts b/packages/backend/src/models/execution-step.ts index b26a3766..61fa6a5b 100644 --- a/packages/backend/src/models/execution-step.ts +++ b/packages/backend/src/models/execution-step.ts @@ -31,6 +31,9 @@ class ExecutionStep extends Base { dataOut: { type: ['object', 'null'] }, status: { type: 'string', enum: ['success', 'failure'] }, errorDetails: { type: ['object', 'null'] }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; diff --git a/packages/backend/src/models/execution.ts b/packages/backend/src/models/execution.ts index e5bd82f2..10670138 100644 --- a/packages/backend/src/models/execution.ts +++ b/packages/backend/src/models/execution.ts @@ -22,6 +22,9 @@ class Execution extends Base { flowId: { type: 'string', format: 'uuid' }, testRun: { type: 'boolean', default: false }, internalId: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index 20b48c73..81602e3e 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -19,7 +19,7 @@ class Flow extends Base { status: 'paused' | 'published' | 'draft'; steps: Step[]; triggerStep: Step; - published_at: string; + publishedAt: string; remoteWebhookId: string; executions?: Execution[]; lastExecution?: Execution; @@ -37,6 +37,10 @@ class Flow extends Base { userId: { type: 'string', format: 'uuid' }, remoteWebhookId: { type: 'string' }, active: { type: 'boolean' }, + publishedAt: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; diff --git a/packages/backend/src/models/permission.ts b/packages/backend/src/models/permission.ts new file mode 100644 index 00000000..393a4a76 --- /dev/null +++ b/packages/backend/src/models/permission.ts @@ -0,0 +1,24 @@ +import Base from './base'; + +class Permission extends Base { + id: string; + action: string; + subject: string; + + static tableName = 'permissions'; + + static jsonSchema = { + type: 'object', + required: ['action', 'subject'], + + properties: { + id: { type: 'string', format: 'uuid' }, + action: { type: 'string', minLength: 1 }, + subject: { type: 'string', minLength: 1 }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; +} + +export default Permission; diff --git a/packages/backend/src/models/query-builder.ts b/packages/backend/src/models/query-builder.ts index 1413933d..921ba073 100644 --- a/packages/backend/src/models/query-builder.ts +++ b/packages/backend/src/models/query-builder.ts @@ -15,7 +15,7 @@ const buildQueryBuidlerForClass = (): ForClassMethod => { modelClass ); qb.onBuild((builder) => { - if (!builder.context().withSoftDeleted) { + if (!builder.context().withSoftDeleted && qb.modelClass().jsonSchema.properties.deletedAt) { builder.whereNull( `${qb.modelClass().tableName}.${DELETED_COLUMN_NAME}` ); diff --git a/packages/backend/src/models/role.ts b/packages/backend/src/models/role.ts new file mode 100644 index 00000000..0c5d2d2f --- /dev/null +++ b/packages/backend/src/models/role.ts @@ -0,0 +1,39 @@ +import Base from './base'; +import User from './user'; + +class Role extends Base { + id!: string; + name!: string; + key: string; + description: string; + users?: User[]; + + static tableName = 'roles'; + + static jsonSchema = { + type: 'object', + required: ['name', 'key'], + + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1 }, + key: { type: 'string', minLength: 1 }, + description: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + users: { + relation: Base.HasManyRelation, + modelClass: User, + join: { + from: 'roles.id', + to: 'users.role_id', + }, + }, + }); +} + +export default Role; diff --git a/packages/backend/src/models/step.ts b/packages/backend/src/models/step.ts index 2f086898..f23faec9 100644 --- a/packages/backend/src/models/step.ts +++ b/packages/backend/src/models/step.ts @@ -46,6 +46,9 @@ class Step extends Base { position: { type: 'integer' }, parameters: { type: 'object' }, webhookPath: { type: ['string', 'null'] }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; diff --git a/packages/backend/src/models/subscription.ee.ts b/packages/backend/src/models/subscription.ee.ts index cf7f1939..028dc332 100644 --- a/packages/backend/src/models/subscription.ee.ts +++ b/packages/backend/src/models/subscription.ee.ts @@ -46,6 +46,9 @@ class Subscription extends Base { nextBillDate: { type: 'string' }, lastBillDate: { type: 'string' }, cancellationEffectiveDate: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; @@ -84,7 +87,7 @@ class Subscription extends Base { return ( this.status === 'deleted' && Number(this.cancellationEffectiveDate) > - DateTime.now().startOf('day').toMillis() + DateTime.now().startOf('day').toMillis() ); } diff --git a/packages/backend/src/models/usage-data.ee.ts b/packages/backend/src/models/usage-data.ee.ts index 6cae3e56..ab1c8be2 100644 --- a/packages/backend/src/models/usage-data.ee.ts +++ b/packages/backend/src/models/usage-data.ee.ts @@ -24,6 +24,9 @@ class UsageData extends Base { subscriptionId: { type: 'string', format: 'uuid' }, consumedTaskCount: { type: 'integer' }, nextResetAt: { type: 'string' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index e0bfdf42..3258d0fe 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -1,13 +1,18 @@ +import crypto from 'node:crypto'; import { QueryContext, ModelOptions } from 'objection'; import bcrypt from 'bcrypt'; -import crypto from 'crypto'; import { DateTime } from 'luxon'; +import { Ability } from '@casl/ability'; +import type { Subject } from '@casl/ability'; + import appConfig from '../config/app'; import Base from './base'; import ExtendedQueryBuilder from './query-builder'; import Connection from './connection'; import Flow from './flow'; import Step from './step'; +import Role from './role'; +import Permission from './permission'; import Execution from './execution'; import UsageData from './usage-data.ee'; import Subscription from './subscription.ee'; @@ -16,8 +21,8 @@ class User extends Base { id!: string; fullName!: string; email!: string; + roleId: string; password!: string; - role: string; resetPasswordToken: string; resetPasswordTokenSentAt: string; trialExpiryDate: string; @@ -29,6 +34,8 @@ class User extends Base { currentUsageData?: UsageData; subscriptions?: Subscription[]; currentSubscription?: Subscription; + role: Role; + permissions: Permission[]; static tableName = 'users'; @@ -41,7 +48,13 @@ class User extends Base { fullName: { type: 'string', minLength: 1 }, email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, password: { type: 'string', minLength: 1, maxLength: 255 }, - role: { type: 'string', enum: ['admin', 'user'] }, + resetPasswordToken: { type: 'string' }, + resetPasswordTokenSentAt: { type: 'string' }, + trialExpiryDate: { type: 'string' }, + roleId: { type: 'string', format: 'uuid' }, + deletedAt: { type: 'string' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, }, }; @@ -124,6 +137,26 @@ class User extends Base { builder.orderBy('created_at', 'desc').limit(1).first(); }, }, + role: { + relation: Base.HasOneRelation, + modelClass: Role, + join: { + from: 'roles.id', + to: 'users.role_id', + }, + }, + permissions: { + relation: Base.ManyToManyRelation, + modelClass: Permission, + join: { + from: 'users.role_id', + through: { + from: 'roles_permissions.role_id', + to: 'roles_permissions.permission_id', + }, + to: 'permissions.id', + }, + }, }); login(password: string) { @@ -248,6 +281,30 @@ class User extends Base { }); } } + + get ability() { + if (!this.permissions) { + throw new Error('User.permissions must be fetched!'); + } + + return new Ability(this.permissions); + } + + can(action: string, subject: Subject) { + const can = this.ability.can(action, subject); + + if (!can) throw new Error('Not authorized!'); + + return can; + } + + cannot(action: string, subject: Subject) { + const cannot = this.ability.cannot(action, subject); + + if (cannot) throw new Error('Not authorized!'); + + return cannot; + } } export default User; diff --git a/yarn.lock b/yarn.lock index 2c6d6be5..9eb19a59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1428,6 +1428,13 @@ dependencies: "@bull-board/api" "3.10.1" +"@casl/ability@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@casl/ability/-/ability-6.5.0.tgz#a151a7637886099b8ffe52a96601225004a5c157" + integrity sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw== + dependencies: + "@ucast/mongo2js" "^1.3.0" + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" @@ -4528,6 +4535,34 @@ "@typescript-eslint/types" "5.10.0" eslint-visitor-keys "^3.0.0" +"@ucast/core@^1.0.0", "@ucast/core@^1.4.1", "@ucast/core@^1.6.1": + version "1.10.2" + resolved "https://registry.yarnpkg.com/@ucast/core/-/core-1.10.2.tgz#30b6b893479823265368e528b61b042f752f2c92" + integrity sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g== + +"@ucast/js@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@ucast/js/-/js-3.0.3.tgz#6ff618a85bd95f1a8f46658cc663a1f798de327f" + integrity sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ== + dependencies: + "@ucast/core" "^1.0.0" + +"@ucast/mongo2js@^1.3.0": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@ucast/mongo2js/-/mongo2js-1.3.4.tgz#579f9e5eb074cba54640d5c70c71c500580f3af3" + integrity sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA== + dependencies: + "@ucast/core" "^1.6.1" + "@ucast/js" "^3.0.0" + "@ucast/mongo" "^2.4.0" + +"@ucast/mongo@^2.4.0": + version "2.4.3" + resolved "https://registry.yarnpkg.com/@ucast/mongo/-/mongo-2.4.3.tgz#92b1dd7c0ab06a907f2ab1422aa3027518ccc05e" + integrity sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA== + dependencies: + "@ucast/core" "^1.4.1" + "@vitejs/plugin-vue@^3.1.2": version "3.1.2" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.1.2.tgz#3cd52114e8871a0b5e7bd7d837469c032e503036"