Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@flowfuse/flowfuse

Package Overview
Dependencies
Maintainers
2
Versions
964
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@flowfuse/flowfuse - npm Package Compare versions

Comparing version 1.14.1 to 1.14.2-2ec1846-202312211045.0

forge/db/controllers/StorageSession.js

23

forge/app.js

@@ -10,3 +10,3 @@ #!/usr/bin/env node

/**
* The main entry point to the FlowForge application.
* The main entry point to the FlowFuse application.
*

@@ -21,3 +21,3 @@ * This creates the Fastify server, registers our plugins and starts it listen

if (!semver.satisfies(process.version, '>=16.0.0')) {
console.error(`FlowForge requires at least NodeJS v16, ${process.version} found`)
console.error(`FlowFuse requires at least NodeJS v16, ${process.version} found`)
process.exit(1)

@@ -30,5 +30,5 @@ }

const enableRepl = process.argv.includes('--repl')
let server
try {
const server = await forge()
server = await forge()

@@ -40,5 +40,5 @@ // Setup shutdown event handling

stopping = true
server.log.info('Stopping FlowForge platform')
server.log.info('Stopping FlowFuse platform')
await server.close()
server.log.info('FlowForge platform stopped')
server.log.info('FlowFuse platform stopped')
process.exit(0)

@@ -66,3 +66,3 @@ }

server.log.info('****************************************************')
server.log.info('* To finish setting up FlowForge, open this url: *')
server.log.info('* To finish setting up FlowFuse, open this url: *')
server.log.info(`* ${setupURL.padEnd(47, ' ')}*`)

@@ -72,3 +72,3 @@ server.log.info('****************************************************')

server.log.info('****************************************************')
server.log.info('* FlowForge is now running and can be accessed at: *')
server.log.info('* FlowFuse is now running and can be accessed at: *')
server.log.info(`* ${server.config.base_url.padEnd(47, ' ')}*`)

@@ -86,3 +86,10 @@ server.log.info('****************************************************')

process.exitCode = 1
try {
if (server) {
await server.close()
}
} catch (err) {
console.error('Error shutting down:', err.toString())
}
}
})()

@@ -36,2 +36,16 @@ const { generateBody, triggerObject } = require('./formatters')

}
},
deviceGroup: {
async created (actionedBy, error, application, deviceGroup) {
await log('application.deviceGroup.created', actionedBy, application?.id, generateBody({ error, application, deviceGroup }))
},
async updated (actionedBy, error, application, deviceGroup, updates) {
await log('application.deviceGroup.updated', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates }))
},
async deleted (actionedBy, error, application, deviceGroup) {
await log('application.deviceGroup.deleted', actionedBy, application?.id, generateBody({ error, application, deviceGroup }))
},
async membersChanged (actionedBy, error, application, deviceGroup, updates, info) {
await log('application.deviceGroup.members.changed', actionedBy, application?.id, generateBody({ error, application, deviceGroup, updates, info }))
}
}

@@ -38,0 +52,0 @@ }

@@ -10,6 +10,6 @@ const { RoleNames } = require('../lib/roles')

* Any items null or missing must not generate a property in the body
* @param {{ error?, team?, project?, sourceProject?, targetProject?, device?, sourceDevice?, targetDevice?, user?, stack?, billingSession?, subscription?, license?, updates?, snapshot?, role?, projectType?, info? } == {}} objects objects to include in body
* @returns {{ error?, team?, project?, sourceProject?, targetProject?, device?, user?, stack?, billingSession?, subscription?, license?, updates?, snapshot?, role?, projectType? info? }
* @param {{ error?, team?, project?, sourceProject?, targetProject?, device?, sourceDevice?, targetDevice?, user?, stack?, billingSession?, subscription?, license?, updates?, snapshot?, pipeline?, pipelineStage?, pipelineStageTarget?, role?, projectType?, info?, deviceGroup?, interval?, threshold? } == {}} objects objects to include in body
* @returns {{ error?, team?, project?, sourceProject?, targetProject?, device?, user?, stack?, billingSession?, subscription?, license?, updates?, snapshot?, pipeline?, pipelineStage?, pipelineStageTarget?, role?, projectType? info?, deviceGroup?, interval?, threshold? }}
*/
const generateBody = ({ error, team, application, project, sourceProject, targetProject, device, sourceDevice, targetDevice, user, stack, billingSession, subscription, license, updates, snapshot, pipeline, pipelineStage, role, projectType, info, interval, threshold } = {}) => {
const generateBody = ({ error, team, application, project, sourceProject, targetProject, device, sourceDevice, targetDevice, user, stack, billingSession, subscription, license, updates, snapshot, pipeline, pipelineStage, pipelineStageTarget, role, projectType, info, deviceGroup, interval, threshold } = {}) => {
const body = {}

@@ -73,2 +73,5 @@

}
if (isObject(pipelineStageTarget)) {
body.pipelineStageTarget = pipelineStageObject(pipelineStageTarget)
}
if (isObject(role) || typeof role === 'number') {

@@ -85,2 +88,5 @@ body.role = roleObject(role)

}
if (isObject(deviceGroup)) {
body.deviceGroup = deviceGroupObject(deviceGroup)
}

@@ -152,2 +158,3 @@ if (interval) {

device: body?.device,
deviceGroup: body?.deviceGroup,
sourceDevice: body?.sourceDevice,

@@ -159,2 +166,3 @@ targetDevice: body?.targetDevice,

pipelineStage: body?.pipelineStage,
pipelineStageTarget: body?.pipelineStageTarget,
interval: body?.interval,

@@ -314,2 +322,9 @@ threshold: body?.threshold

}
const deviceGroupObject = (deviceGroup) => {
return {
id: deviceGroup?.id || null,
hashid: deviceGroup?.hashid || null,
name: deviceGroup?.name || null
}
}
/**

@@ -582,2 +597,3 @@ * Generates the `trigger` part of the audit log report

deviceObject,
deviceGroupObject,
userObject,

@@ -584,0 +600,0 @@ stackObject,

@@ -22,2 +22,4 @@ const fp = require('fastify-plugin')

next()
}, {
name: 'app.auditLog'
})

@@ -152,2 +152,5 @@ const { projectObject, generateBody, triggerObject } = require('./formatters')

await log('application.pipeline.stage-added', actionedBy, team?.id, generateBody({ error, team, application, pipeline, pipelineStage }))
},
async stageDeployed (actionedBy, error, team, application, pipeline, sourcePipelineStage, targetPipelineStage) {
await log('application.pipeline.stage-deployed', actionedBy, team?.id, generateBody({ error, team, application, pipeline, pipelineStage: sourcePipelineStage, pipelineStageTarget: targetPipelineStage }))
}

@@ -154,0 +157,0 @@ }

@@ -45,8 +45,8 @@ const fp = require('fastify-plugin')

app.ready().then(async () => {
// Once the whole platform is ready, tell the client to connect
return await client.init()
}).catch(err => {
app.log.info('[comms] problem starting comms client')
throw err
app.addHook('onReady', async () => {
try {
await client.init()
} catch (err) {
app.log.info('[comms] problem starting comms client:', err.toString())
}
})

@@ -61,2 +61,4 @@ app.addHook('onClose', async (_) => {

next()
}, {
name: 'app.comms'
})

@@ -139,3 +139,3 @@ /**

next()
})
}, { name: 'app.config' })
}

@@ -70,2 +70,2 @@ /**

next()
})
}, { name: 'app.containers' })

@@ -158,4 +158,2 @@ /**

})
} else {
throw new Error(`${project.id} not found`)
}

@@ -272,3 +270,4 @@ },

}
}
},
revokeUserToken: async (project, token) => { }
}

@@ -206,2 +206,5 @@ class SubscriptionHandler {

if (this._driver.revokeUserToken) {
if (project.state === 'suspended') {
return
}
await this._driver.revokeUserToken(project, token) // logout:nodered(step-3)

@@ -208,0 +211,0 @@ }

@@ -32,4 +32,7 @@ const { Op } = require('sequelize')

createTokenForPasswordReset: async function (app, user) {
// Ensure any existing tokens are removed first
await app.db.controllers.AccessToken.deleteAllUserPasswordResetTokens(user)
const token = generateToken(32, 'ffpr')
const expiresAt = new Date(Date.now() + (86400 * 1000))
const expiresAt = new Date(Date.now() + (86400 * 1000)) // 1 day
await app.db.models.AccessToken.create({

@@ -44,3 +47,17 @@ token,

},
/**
* Deletes any pending password-change tokens for a user.
*/
deleteAllUserPasswordResetTokens: async function (app, user) {
await app.db.models.AccessToken.destroy({
where: {
ownerType: 'user',
scope: 'password:Reset',
ownerId: user.hashid
}
})
},
/**
* Create an AccessToken for the given device.

@@ -47,0 +64,0 @@ * The token is hashed in the database. The only time the

@@ -30,3 +30,4 @@ /**

'StorageFlows',
'StorageSettings'
'StorageSettings',
'StorageSession'
]

@@ -33,0 +34,0 @@

@@ -193,2 +193,4 @@ const { BUILT_IN_MODULES } = require('../../lib/builtInModules')

result.palette.denyList = paletteDenyList
} else {
result.palette.denyList = []
}

@@ -195,0 +197,0 @@ }

@@ -40,2 +40,8 @@ const { generateToken } = require('../utils')

/**
* Delete all sessions for a user
*/
deleteAllUserSessions: async function (app, user) {
return app.db.models.Session.destroy({ where: { UserId: user.id } })
},
/**
* Get a session by its id. If the session has expired, it is deleted

@@ -42,0 +48,0 @@ * and nothing returned.

const jwt = require('jsonwebtoken')
const { fn, col, where } = require('sequelize')
const zxcvbn = require('zxcvbn')

@@ -29,2 +30,5 @@ const { compareHash, sha256 } = require('../utils')

if (compareHash(oldPassword, user.password)) {
if (zxcvbn(newPassword).score < 3) {
throw new Error('Password Too Weak')
}
user.password = newPassword

@@ -39,2 +43,5 @@ user.password_expired = false

resetPassword: async function (app, user, newPassword) {
// if (zxcvbn(newPassword.score < 3)) {
// throw new Error('Password Too Weak')
// }
user.password = newPassword

@@ -188,18 +195,12 @@ user.password_expired = false

// log suspended user out of all projects they have access to
const sessions = await app.db.models.StorageSession.byUsername(user.username)
for (let index = 0; index < sessions.length; index++) {
const session = sessions[index]
const ProjectId = session.ProjectId
const project = await app.db.models.Project.byId(ProjectId)
for (let index = 0; index < session.sessions.length; index++) {
const token = session.sessions[index].accessToken
try {
await app.containers.revokeUserToken(project, token) // logout:nodered(step-2)
} catch (error) {
app.log.warn(`Failed to revoke token for Project ${ProjectId}: ${error.toString()}`) // log error but continue to delete session
}
}
}
await user.save()
await app.db.controllers.User.logout(user)
},
logout: async function (app, user) {
// Do a full logout.
// - Clear all node-red login sessions
// - Clear all accessTokens
await app.db.controllers.StorageSession.removeUserFromSessions(user)
}
}

@@ -93,2 +93,2 @@ /**

next()
})
}, { name: 'app.db' })

@@ -15,2 +15,12 @@ /**

},
hooks: function (M, app) {
return {
afterDestroy: async (application, opts) => {
const where = {
ApplicationId: application.id
}
M.Device.update({ ApplicationId: null }, { where })
}
}
},
associations: function (M) {

@@ -20,2 +30,3 @@ this.hasMany(M.Project)

this.belongsTo(M.Team, { foreignKey: { allowNull: false } })
this.hasMany(M.DeviceGroup, { onDelete: 'CASCADE' })
},

@@ -22,0 +33,0 @@ finders: function (M) {

@@ -31,3 +31,3 @@ /**

get () {
return this.Project?.id ? 'instance' : (this.Application?.hashid ? 'application' : null)
return this.ProjectId ? 'instance' : (this.ApplicationId ? 'application' : null)
}

@@ -57,2 +57,3 @@ },

this.hasMany(M.ProjectSnapshot) // associate device at application level with snapshots
this.belongsTo(M.DeviceGroup, { foreignKey: { allowNull: true } }) // SEE: forge/db/models/DeviceGroup.js for the other side of this relationship
},

@@ -213,3 +214,3 @@ hooks: function (M, app) {

},
getAll: async (pagination = {}, where = {}, { includeInstanceApplication = false } = {}) => {
getAll: async (pagination = {}, where = {}, { includeInstanceApplication = false, includeDeviceGroup = false } = {}) => {
// Pagination

@@ -313,2 +314,8 @@ const limit = Math.min(parseInt(pagination.limit) || 100, 100)

if (includeDeviceGroup) {
includes.push({
model: M.DeviceGroup,
attributes: ['hashid', 'id', 'name', 'description', 'ApplicationId']
})
}
const statusOnlyIncludes = projectInclude.include?.where ? [projectInclude] : []

@@ -315,0 +322,0 @@

@@ -72,2 +72,3 @@ /**

'Device',
'DeviceGroup',
'DeviceSettings',

@@ -74,0 +75,0 @@ 'StorageFlow',

@@ -25,3 +25,3 @@ /**

byUsername: async (username) => {
const allStorageSessions = await this.findAll({
return this.findAll({
where: {

@@ -32,10 +32,4 @@ sessions: {

},
attributes: ['sessions', 'ProjectId']
attributes: ['id', 'sessions', 'ProjectId']
})
return allStorageSessions.map(m => {
const session = m.sessions ? JSON.parse(m.sessions) : {}
const sessions = Object.values(session) || []
const usersSessions = sessions.filter(e => e.user === username && e.client === 'node-red-editor')
return { ProjectId: m.ProjectId, sessions: usersSessions }
}).filter(m => m.sessions.length > 0 && M.ProjectId)
}

@@ -42,0 +36,0 @@ }

@@ -63,2 +63,10 @@ /**

})
// This should only be empty Applications as the
// beforeDestroy hook will block deletion of the
// Team if any Applications have Instances
await M.Application.destroy({
where: {
TeamId: team.id
}
})
}

@@ -65,0 +73,0 @@ }

@@ -30,3 +30,7 @@ module.exports = function (app) {

application: { $ref: 'ApplicationSummary' },
editor: { type: 'object', additionalProperties: true }
editor: { type: 'object', additionalProperties: true },
deviceGroup: {
nullable: true,
allOf: [{ $ref: 'DeviceGroupSummary' }]
}
}

@@ -40,3 +44,3 @@ })

const result = device.toJSON()
const result = device.toJSON ? device.toJSON() : device

@@ -49,2 +53,3 @@ if (statusOnly) {

status: result.state || 'offline',
mode: result.mode || 'autonomous',
isDeploying: app.db.controllers.Device.isDeploying(device)

@@ -69,3 +74,4 @@ }

ownerType: result.ownerType,
isDeploying: app.db.controllers.Device.isDeploying(device)
isDeploying: app.db.controllers.Device.isDeploying(device),
deviceGroup: device.DeviceGroup && app.db.views.DeviceGroup.deviceGroupSummary(device.DeviceGroup)
}

@@ -103,2 +109,3 @@ if (device.Team) {

status: { type: 'string' },
mode: { type: 'string' },
isDeploying: { type: 'boolean' },

@@ -110,3 +117,3 @@ links: { $ref: 'LinksMeta' }

if (device) {
const result = device.toJSON()
const result = device.toJSON ? device.toJSON() : device
const filtered = {

@@ -120,2 +127,3 @@ id: result.hashid,

status: result.state || 'offline',
mode: result.mode || 'autonomous',
isDeploying: app.db.controllers.Device.isDeploying(device),

@@ -122,0 +130,0 @@ links: result.links

@@ -18,2 +18,3 @@ /**

'Device',
'DeviceGroup',
'Invitation',

@@ -20,0 +21,0 @@ 'Project',

const modelTypes = [
'Subscription',
'UserBillingCode',
'Pipeline'
'Pipeline',
'DeviceGroup'
]

@@ -6,0 +7,0 @@

@@ -12,7 +12,112 @@ const { ControllerError } = require('../../../lib/errors')

module.exports = {
/**
* Update a pipeline stage
* @param {*} app The application instance
* @param {*} pipeline A pipeline object
* @param {String|Number} stageId The ID of the stage to update
* @param {Object} options Options to update the stage with
* @param {String} [options.name] The name of the stage
* @param {String} [options.action] The action to take when deploying to this stage
* @param {String} [options.instanceId] The ID of the instance to deploy to
* @param {String} [options.deviceId] The ID of the device to deploy to
* @param {String} [options.deviceGroupId] The ID of the device group to deploy to
* @param {Boolean} [options.deployToDevices] Whether to deploy to devices of the source stage
*/
updatePipelineStage: async function (app, stageId, options) {
const stage = await app.db.models.PipelineStage.byId(stageId)
if (!stage) {
throw new PipelineControllerError('not_found', 'Pipeline stage not found', 404)
}
const pipeline = await app.db.models.Pipeline.byId(stage.PipelineId)
if (!pipeline) {
throw new PipelineControllerError('not_found', 'Pipeline not found', 404)
}
if (options.name) {
stage.name = options.name
}
if (options.action) {
stage.action = options.action
}
// Null will remove devices and instances, undefined skips
if (options.instanceId !== undefined || options.deviceId !== undefined || options.deviceGroupId !== undefined) {
// Check that only one of instanceId, deviceId or deviceGroupId is set
const idCount = [options.instanceId, options.deviceId, options.deviceGroupId].filter(id => !!id).length
if (idCount > 1) {
throw new PipelineControllerError('invalid_input', 'Must provide only one instance, device or device group', 400)
}
// If this stage is being set as a device group, check all stages.
// * A device group cannot be the first stage
// * There can be only one device group and it can only be the last stage
if (options.deviceGroupId) {
const stages = await pipeline.stages()
// stages are a linked list, so ensure we use the sorted stages
const orderedStages = app.db.models.PipelineStage.sortStages(stages)
if (orderedStages.length === 0) {
// this should never be reached but here for completeness
throw new PipelineControllerError('invalid_input', 'A Device Group cannot be the first stage', 400)
}
const firstStage = orderedStages[0]
const lastStage = orderedStages[orderedStages.length - 1]
// if the first stage is the same as the stage being updated, then it's the first stage
if (firstStage && firstStage.id === stage.id) {
throw new PipelineControllerError('invalid_input', 'A Device Group cannot be the first stage', 400)
}
// filter out the stage being updated and check if any other stages have a device group
const otherStages = stages.filter(s => s.id !== stage.id)
if (otherStages.filter(s => s.DeviceGroups?.length).length) {
throw new PipelineControllerError('invalid_input', 'A Device Group can only set on the last stage', 400)
}
if (lastStage && lastStage.id !== stage.id) {
throw new PipelineControllerError('invalid_input', 'A Device Group can only set on the last stage', 400)
}
}
// Currently only one instance, device or device group per stage is supported
const instances = await stage.getInstances()
for (const instance of instances) {
await stage.removeInstance(instance)
}
const devices = await stage.getDevices()
for (const device of devices) {
await stage.removeDevice(device)
}
const deviceGroups = await stage.getDeviceGroups()
for (const deviceGroup of deviceGroups) {
await stage.removeDeviceGroup(deviceGroup)
}
if (options.instanceId) {
await stage.addInstanceId(options.instanceId)
} else if (options.deviceId) {
await stage.addDeviceId(options.deviceId)
} else if (options.deviceGroupId) {
await stage.addDeviceGroupId(options.deviceGroupId)
}
}
if (options.deployToDevices !== undefined) {
stage.deployToDevices = options.deployToDevices
}
await stage.save()
await stage.reload()
return stage
},
addPipelineStage: async function (app, pipeline, options) {
if (options.instanceId && options.deviceId) {
throw new PipelineControllerError('invalid_input', 'Cannot add a pipeline stage with both instance and a device', 400)
} else if (!options.instanceId && !options.deviceId) {
throw new PipelineControllerError('invalid_input', 'Param instanceId or deviceId is required when creating a new pipeline stage', 400)
const idCount = [options.instanceId, options.deviceId, options.deviceGroupId].filter(id => !!id).length
if (idCount > 1) {
throw new PipelineControllerError('invalid_input', 'Cannot add a pipeline stage with a mixture of instance, device or device group. Only one is permitted', 400)
} else if (idCount === 0) {
throw new PipelineControllerError('invalid_input', 'An instance, device or device group is required when creating a new pipeline stage', 400)
}

@@ -28,20 +133,43 @@

}
const stage = await app.db.models.PipelineStage.create(options)
if (options.instanceId) {
await stage.addInstanceId(options.instanceId)
} else if (options.deviceId) {
await stage.addDeviceId(options.deviceId)
} else {
// This should never be reached due to guard at top of function
throw new PipelineControllerError('invalid_input', 'Must provide an instanceId or an deviceId', 400)
// before we create the stage, we need to check a few things
// if this is being added as a device group, check all stages.
// * A device group cannot be the first stage
// * There can be only one device group and it can only be the last stage
if (options.deviceGroupId) {
const stages = await pipeline.stages()
const stageCount = stages.length
if (stageCount === 0) {
throw new PipelineControllerError('invalid_input', 'A Device Group cannot be the first stage', 400)
} else if (stages.filter(s => s.DeviceGroups?.length).length) {
throw new PipelineControllerError('invalid_input', 'Only one Device Group can only set in a pipeline', 400)
}
}
if (source) {
const sourceStage = await app.db.models.PipelineStage.byId(source)
sourceStage.NextStageId = stage.id
await sourceStage.save()
const transaction = await app.db.sequelize.transaction()
try {
const stage = await app.db.models.PipelineStage.create(options, { transaction })
if (options.instanceId) {
await stage.addInstanceId(options.instanceId, { transaction })
} else if (options.deviceId) {
await stage.addDeviceId(options.deviceId, { transaction })
} else if (options.deviceGroupId) {
await stage.addDeviceGroupId(options.deviceGroupId, { transaction })
} else {
// This should never be reached due to guard at top of function
throw new PipelineControllerError('invalid_input', 'Must provide an instanceId, deviceId or deviceGroupId', 400)
}
if (source) {
const sourceStage = await app.db.models.PipelineStage.byId(source, { transaction })
sourceStage.NextStageId = stage.id
await sourceStage.save({ transaction })
}
await transaction.commit()
return stage
} catch (err) {
transaction.rollback()
throw err
}
return stage
},

@@ -77,7 +205,9 @@

const targetDevices = await targetStage.getDevices()
const totalTargets = targetInstances.length + targetDevices.length
const targetDeviceGroups = await targetStage.getDeviceGroups()
const totalTargets = targetInstances.length + targetDevices.length + targetDeviceGroups.length
if (totalTargets === 0) {
throw new PipelineControllerError('invalid_stage', 'Target stage must have at least one instance or device', 400)
throw new PipelineControllerError('invalid_stage', 'Target stage must have at least one instance, device or device group', 400)
} else if (targetInstances.length > 1) {
throw new PipelineControllerError('invalid_stage', 'Deployments are currently only supported for target stages with a single instance or device', 400)
throw new PipelineControllerError('invalid_stage', 'Deployments are currently only supported for target stages with a single instance, device or device group', 400)
}

@@ -90,21 +220,24 @@

const targetDevice = targetDevices[0]
const targetDeviceGroup = targetDeviceGroups[0]
const sourceObject = sourceInstance || sourceDevice
const targetObject = targetInstance || targetDevice
const targetObject = targetInstance || targetDevice || targetDeviceGroup
sourceObject.Team = await app.db.models.Team.byId(sourceObject.TeamId)
if (!sourceObject.Team) {
throw new PipelineControllerError('invalid_stage', `Source ${sourceInstance ? 'instance' : 'device'} not associated with a team`, 404)
}
const sourceType = sourceInstance ? 'instance' : (sourceDevice ? 'device' : '')
const targetType = targetInstance ? 'instance' : (targetDevice ? 'device' : (targetDeviceGroup ? 'device group' : ''))
targetObject.Team = await app.db.models.Team.byId(targetObject.TeamId)
if (!targetObject.Team) {
throw new PipelineControllerError('invalid_stage', `Source ${sourceInstance ? 'instance' : 'device'} not associated with a team`, 404)
const sourceApplication = await app.db.models.Application.byId(sourceObject.ApplicationId)
const targetApplication = await app.db.models.Application.byId(targetObject.ApplicationId)
if (!sourceApplication || !targetApplication) {
throw new PipelineControllerError('invalid_stage', `Source ${sourceType} and target ${targetType} must be associated with an application`, 400)
}
if (sourceApplication.id !== targetApplication.id || sourceApplication.TeamId !== targetApplication.TeamId) {
throw new PipelineControllerError('invalid_stage', `Source ${sourceType} and target ${targetType} must be associated with in the same team application`, 400)
}
if (sourceObject.TeamId !== targetObject.TeamId) {
throw new PipelineControllerError('invalid_stage', `Source ${sourceInstance ? 'instance' : 'device'} and target ${targetInstance ? 'instance' : 'device'} must be in the same team`, 403)
if (targetDevice && targetDevice.mode === 'developer') {
throw new PipelineControllerError('invalid_target_stage', 'Target device cannot not be in developer mode', 400)
}
return { sourceInstance, targetInstance, sourceDevice, targetDevice, targetStage }
return { sourceInstance, targetInstance, sourceDevice, targetDevice, targetDeviceGroup, targetStage }
},

@@ -282,3 +415,46 @@

}
},
/**
* Deploy a snapshot to a device group
* @param {Object} app - The application instance
* @param {Object} sourceSnapshot - The source snapshot object
* @param {Object} targetDeviceGroup - The target device group object
* @param {Object} user - The user performing the deploy
* @returns {Promise<Function>} - Resolves with the deploy is complete
*/
deploySnapshotToDeviceGroup: async function (app, sourceSnapshot, targetDeviceGroup, deployMeta = { user: null }) {
// Only used for reporting and logging, should not be used for any logic
// const { user } = deployMeta // TODO: implement device audit logs
try {
// store original value for later audit log
// const originalSnapshotId = targetDeviceGroup.targetSnapshotId // TODO: implement device audit logs
// start a transaction
const transaction = await app.db.sequelize.transaction()
try {
// Update the targetSnapshot of the device group
await targetDeviceGroup.PipelineStageDeviceGroup.update({ targetSnapshotId: sourceSnapshot.id }, { transaction })
// update all devices targetSnapshotId
await app.db.models.Device.update({ targetSnapshotId: sourceSnapshot.id }, { where: { DeviceGroupId: targetDeviceGroup.id }, transaction })
// commit the transaction
transaction.commit()
} catch (error) {
// rollback the transaction
transaction.rollback()
throw error
}
// TODO: implement device audit logs
// const updates = new app.auditLog.formatters.UpdatesCollection()
// updates.push('targetSnapshotId', originalSnapshotId, targetDeviceGroup.targetSnapshotId)
// await app.auditLog.Team.team.device.updated(user, null, targetDeviceGroup.Team, targetDeviceGroup, updates)
await app.db.controllers.DeviceGroup.sendUpdateCommand(targetDeviceGroup)
} catch (err) {
throw new PipelineControllerError('unexpected_error', `Error during deploy: ${err.toString()}`, 500, { cause: err })
}
}
}

@@ -16,2 +16,5 @@ /**

category: { type: DataTypes.STRING, defaultValue: '' },
order: { type: DataTypes.INTEGER, defaultValue: 0 },
default: { type: DataTypes.BOOLEAN, defaultValue: false },
icon: { type: DataTypes.STRING, allowNull: true },
flows: {

@@ -66,2 +69,15 @@ type: DataTypes.TEXT,

},
hooks: {
afterValidate (flowTemplate, options) {
if (flowTemplate.changed('default') && flowTemplate.default === true) {
return this.update({
default: false
}, {
where: {
default: true
}
})
}
}
},
associations: function (M) {

@@ -68,0 +84,0 @@ this.belongsTo(M.User, { as: 'createdBy' })

@@ -14,2 +14,3 @@ const Hashids = require('hashids/cjs')

'PipelineStageDevice',
'PipelineStageDeviceGroup',
'FlowTemplate',

@@ -16,0 +17,0 @@ 'MFAToken'

@@ -102,11 +102,26 @@ const {

},
async devicesOrInstancesNotBoth () {
async deviceGroupsHaveSameApplication () {
const deviceGroupsPromise = this.getDeviceGroups()
const pipelinePromise = this.getPipeline()
const deviceGroups = await deviceGroupsPromise
const pipeline = await pipelinePromise
deviceGroups.forEach((group) => {
if (group.ApplicationId !== pipeline.ApplicationId) {
throw new Error(`All device groups on a pipeline stage, must be a member of the same application as the pipeline. ${group.name} is not a member of application ${pipeline.ApplicationId}.`)
}
})
},
async devicesInstancesOrDeviceGroups () {
const devicesPromise = this.getDevices()
const instancesPromise = this.getInstances()
const deviceGroupsPromise = this.getDeviceGroups()
const devices = await devicesPromise
const instances = await instancesPromise
if (devices.length > 0 && instances.length > 0) {
throw new Error('A pipeline stage can contain devices or instances, but never both.')
const deviceGroups = await deviceGroupsPromise
const count = devices.length ? 1 : 0 + instances.length ? 1 : 0 + deviceGroups.length ? 1 : 0
if (count > 1) {
throw new Error('A pipeline stage can only contain instances, devices or device groups, not a combination of them.')
}

@@ -120,2 +135,3 @@ }

this.belongsToMany(M.Device, { through: M.PipelineStageDevice, as: 'Devices' })
this.belongsToMany(M.DeviceGroup, { through: M.PipelineStageDeviceGroup, as: 'DeviceGroups' })
this.hasOne(M.PipelineStage, { as: 'NextStage', foreignKey: 'NextStageId', allowNull: true })

@@ -127,3 +143,3 @@ },

instance: {
async addInstanceId (instanceId) {
async addInstanceId (instanceId, options = {}) {
const instance = await M.Project.byId(instanceId)

@@ -146,5 +162,5 @@ if (!instance) {

await this.addInstance(instance)
await this.addInstance(instance, options)
},
async addDeviceId (deviceId) {
async addDeviceId (deviceId, options = {}) {
const device = await M.Device.byId(deviceId)

@@ -167,3 +183,11 @@ if (!device) {

await this.addDevice(device)
await this.addDevice(device, options)
},
async addDeviceGroupId (deviceGroupId, options = {}) {
const deviceGroup = await M.DeviceGroup.byId(deviceGroupId)
if (!deviceGroup) {
throw new ValidationError(`deviceGroupId (${deviceGroupId}) not found`)
}
await this.addDeviceGroup(deviceGroup, options)
}

@@ -188,2 +212,6 @@ },

attributes: ['hashid', 'id', 'name', 'type', 'links', 'ownerType']
},
{
association: 'DeviceGroups',
attributes: ['hashid', 'id', 'name', 'description']
}

@@ -202,5 +230,16 @@ ]

}
const deviceGroupsInclude = {
association: 'DeviceGroups',
attributes: ['hashid', 'id', 'name', 'description'],
include: [
{
association: 'Devices',
attributes: ['hashid', 'id', 'name', 'type', 'ownerType', 'links']
}
]
}
if (includeDeviceStatus) {
devicesInclude.attributes.push('targetSnapshotId', 'activeSnapshotId', 'lastSeenAt', 'state')
devicesInclude.attributes.push('targetSnapshotId', 'activeSnapshotId', 'lastSeenAt', 'state', 'mode')
deviceGroupsInclude.include[0].attributes.push('targetSnapshotId', 'activeSnapshotId', 'lastSeenAt', 'state', 'mode')
}

@@ -217,3 +256,4 @@

},
devicesInclude
devicesInclude,
deviceGroupsInclude
]

@@ -230,2 +270,31 @@ })

})
},
/**
* Static helper to ensure the stages are ordered correctly
* @param {[]} stages The stages to order
*/
sortStages: function (stages) {
// Must ensure the stages are listed in the correct order
const stagesById = {}
const backReferences = {}
let pointer = null
// Scan the list of stages
// - build an id->stage reference table
// - find the last stage (!NextStageId) and set pointer
// - build a reference table of which stage points at which
stages.forEach(stage => {
stagesById[stage.id] = stage
if (!stage.NextStageId) {
pointer = stage
} else {
backReferences[stage.NextStageId] = stage.id
}
})
const orderedStages = []
// Starting at the last stage, work back through the references
while (pointer) {
orderedStages.unshift(pointer)
pointer = stagesById[backReferences[pointer.id]]
}
return orderedStages
}

@@ -232,0 +301,0 @@ }

@@ -9,3 +9,3 @@ const {

// A subset of the statuses on Stripe that are important to FlowForge
// A subset of the statuses on Stripe that are important to FlowFuse
// https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses

@@ -12,0 +12,0 @@ ACTIVE: 'active',

@@ -11,2 +11,5 @@ module.exports = function (app) {

category: { type: 'string' },
icon: { type: 'string' },
order: { type: 'number' },
default: { type: 'boolean' },
createdAt: { type: 'string' },

@@ -23,2 +26,5 @@ updatedAt: { type: 'string' }

category: blueprint.category,
icon: blueprint.icon,
order: blueprint.order,
default: blueprint.default,
createdAt: blueprint.createdAt,

@@ -25,0 +31,0 @@ updatedAt: blueprint.updatedAt

@@ -15,2 +15,8 @@ module.exports = function (app) {

},
deviceGroups: {
type: 'array',
items: {
$ref: 'DeviceGroupPipelineSummary'
}
},
action: { type: 'string', enum: Object.values(app.db.models.PipelineStage.SNAPSHOT_ACTIONS) },

@@ -38,2 +44,6 @@ NextStageId: { type: 'string' }

if (stage.DeviceGroups?.length > 0) {
filtered.deviceGroups = stage.DeviceGroups.map(app.db.views.DeviceGroup.deviceGroupPipelineSummary)
}
if (stage.NextStageId) {

@@ -60,23 +70,3 @@ // Check stage actually exists before including it in response

// Must ensure the stages are listed in the correct order
const stagesById = {}
const backReferences = {}
let pointer = null
// Scan the list of stages
// - build an id->stage reference table
// - find the last stage (!NextStageId) and set pointer
// - build a reference table of which stage points at which
stages.forEach(stage => {
stagesById[stage.id] = stage
if (!stage.NextStageId) {
pointer = stage
} else {
backReferences[stage.NextStageId] = stage.id
}
})
const orderedStages = []
// Starting at the last stage, work back through the references
while (pointer) {
orderedStages.unshift(pointer)
pointer = stagesById[backReferences[pointer.id]]
}
const orderedStages = app.db.models.PipelineStage.sortStages(stages)
return await Promise.all(orderedStages.map(stage))

@@ -83,0 +73,0 @@ }

const fp = require('fastify-plugin')
/**
* Loads the FlowForge EE components
* Loads the FlowFuse EE components
*/

@@ -10,9 +10,12 @@ module.exports = fp(async function (app, opts, next) {

app.log.info('Loading EE Features')
app.log.trace(' - EE Database models')
await require('./db/index.js').init(app)
app.log.trace(' - EE Routes')
await app.register(require('./routes'), { logLevel: app.config.logging.http })
app.log.trace(' - EE Libs')
await app.register(require('./lib'))
app.log.trace(' - EE Templates')
app.postoffice.registerTemplate('LicenseReminder', require('./emailTemplates/LicenseReminder'))
app.postoffice.registerTemplate('LicenseExpired', require('./emailTemplates/LicenseExpired'))
}
next()
})
}, { name: 'app.ee' })

@@ -381,8 +381,10 @@ module.exports.init = async function (app) {

closeSubscription: async (subscription) => {
app.log.info(`Closing subscription for team ${subscription.Team.hashid}`)
if (subscription.subscription) {
app.log.info(`Canceling subscription ${subscription.subscription} for team ${subscription.Team.hashid}`)
await stripe.subscriptions.del(subscription.subscription, {
invoice_now: true,
prorate: true
})
await stripe.subscriptions.del(subscription.subscription, {
invoice_now: true,
prorate: true
})
}
subscription.status = app.db.models.Subscription.STATUS.CANCELED

@@ -517,3 +519,4 @@ await subscription.save()

*
* This can only be done with teams that are currently in trial mode
* If the team has an active subscription, we will check the subscription
* state on stripe and, if necessary, cancel the subscription.
*

@@ -524,10 +527,6 @@ * @param {*} team

enableManualBilling: async (team) => {
app.log.info(`Enabling manual billing for team ${team.hashid}`)
const subscription = await team.getSubscription()
if (!subscription.isTrial()) {
// For first iteration, only teams in trial mode can be put into
// manual billing mode
const err = new Error('Team not in trial mode')
err.code = 'invalid_request'
throw err
}
const existingSubscription = subscription.subscription
subscription.subscription = ''
subscription.status = app.db.models.Subscription.STATUS.UNMANAGED

@@ -537,4 +536,33 @@ subscription.trialEndsAt = null

await subscription.save()
// Now we have marked the local subscription as unmanaged, we need to
// check to see if there is a stripe subscription to cancel
if (existingSubscription) {
try {
const stripeSubscription = await stripe.subscriptions.retrieve(existingSubscription)
if (stripeSubscription && stripeSubscription.status !== 'canceled') {
app.log.info(`Canceling existing subscription ${existingSubscription} for team ${team.hashid}`)
// There is an existing subscription to cancel
try {
// We do not use `app.billing.closeSubscription` because
// that expects a Subscription object. However, we've already
// updated the local Subscription object to remove the information
// needed by closeSubscription. This is to ensure when the
// stripe callback arrives we don't trigger a suspension of
// the team resources.
await stripe.subscriptions.del(subscription.subscription, {
invoice_now: true,
prorate: true
})
} catch (err) {
app.log.warn(`Error canceling existing subscription ${existingSubscription} for team ${team.hashid}: ${err.toString()}`)
}
}
} catch (err) {
// Could not find a matching stripe subscription - that's means
// we have nothing cancel
}
}
}
}
}

@@ -9,2 +9,3 @@ const fp = require('fastify-plugin')

require('./deviceEditor').init(app)
require('./alerts').init(app)

@@ -16,2 +17,4 @@ if (app.license.get('tier') === 'enterprise') {

app.config.features.register('mfa', true, true)
// Set the Device Groups Feature Flag
app.config.features.register('deviceGroups', true, true)
}

@@ -29,2 +32,2 @@

done()
})
}, { name: 'app.ee.lib' })

@@ -120,11 +120,4 @@ const { registerPermissions } = require('../../../lib/permissions')

type: 'object',
required: ['name'],
properties: {
active: { type: 'boolean' },
name: { type: 'string' },
description: { type: 'string' },
category: { type: 'string' },
flows: { type: 'object' },
modules: { type: 'object' }
}
allOf: [{ $ref: 'FlowBlueprint' }],
required: ['name']
},

@@ -143,6 +136,9 @@ response: {

const properties = {
active: request.body.active !== undefined ? request.body.active : false,
name: request.body.name,
description: request.body.description,
category: request.body.category,
active: request.body.active !== undefined ? request.body.active : false,
icon: request.body.icon,
order: request.body.order,
default: request.body.default,
flows: request.body.flows,

@@ -179,11 +175,3 @@ modules: request.body.modules

body: {
type: 'object',
properties: {
active: { type: 'boolean' },
name: { type: 'string' },
description: { type: 'string' },
category: { type: 'string' },
flows: { type: 'object' },
modules: { type: 'object' }
}
$ref: 'FlowBlueprint'
},

@@ -212,3 +200,6 @@ response: {

'category',
'active'
'active',
'icon',
'order',
'default'
].forEach(prop => {

@@ -215,0 +206,0 @@ if (hasValueChanged(request.body[prop], flowTemplate[prop])) {

@@ -19,2 +19,3 @@ /**

if (app.license.get('tier') === 'enterprise') {
await app.register(require('./applicationDeviceGroups'), { prefix: '/api/v1/applications/:applicationId/device-groups', logLevel: app.config.logging.http })
await app.register(require('./ha'), { prefix: '/api/v1/projects/:projectId/ha', logLevel: app.config.logging.http })

@@ -21,0 +22,0 @@ await app.register(require('./mfa'), { prefix: '/api/v1', logLevel: app.config.logging.http })

@@ -7,3 +7,9 @@ const { ValidationError } = require('sequelize')

// Declare getLogger functions to provide type hints / quick code nav / code completion
/** @type {import('../../../../forge/auditLog/team').getLoggers} */
const getTeamLogger = (app) => { return app.auditLog.Team }
module.exports = async function (app) {
const teamLogger = getTeamLogger(app)
registerPermissions({

@@ -89,8 +95,4 @@ 'pipeline:read': { description: 'View a pipeline', role: Roles.Member },

} catch (error) {
if (error instanceof ValidationError) {
if (error.errors[0]) {
return reply.status(400).type('application/json').send({ code: `invalid_${error.errors[0].path}`, error: error.errors[0].message })
}
return reply.status(400).type('application/json').send({ code: 'invalid_input', error: error.message })
if (handleValidationError(error, reply)) {
return
}

@@ -101,3 +103,3 @@

return reply.status(500).send({ code: 'unexpected_error', error: error.toString() })
return reply.code(500).send({ code: 'unexpected_error', error: error.toString() })
}

@@ -261,2 +263,3 @@

deviceId: { type: 'string' },
deviceGroupId: { type: 'string' },
source: { type: 'string' },

@@ -278,3 +281,3 @@ action: { type: 'string', enum: Object.values(app.db.models.PipelineStage.SNAPSHOT_ACTIONS) }

const name = request.body.name?.trim() // name of the stage
const { instanceId, deviceId, deployToDevices, action } = request.body
const { instanceId, deviceId, deviceGroupId, deployToDevices, action } = request.body

@@ -287,2 +290,3 @@ let stage

deviceId,
deviceGroupId,
deployToDevices,

@@ -299,17 +303,8 @@ action

} catch (error) {
if (error instanceof ValidationError) {
if (error.errors[0]) {
return reply.code(400).type('application/json').send({ code: `invalid_${error.errors[0].path}`, error: error.errors[0].message })
}
return reply.code(400).type('application/json').send({ code: 'invalid_input', error: error.message })
if (handleValidationError(error, reply)) {
return
}
if (error instanceof ControllerError) {
return reply
.code(error.statusCode || 400)
.send({
code: error.code || 'unexpected_error',
error: error.error || error.message
})
if (handleControllerError(error, reply)) {
return
}

@@ -358,2 +353,3 @@

deviceId: { type: 'string' },
deviceGroupId: { type: 'string' },
action: { type: 'string', enum: Object.values(app.db.models.PipelineStage.SNAPSHOT_ACTIONS) }

@@ -373,58 +369,34 @@ }

try {
const stage = await app.db.models.PipelineStage.byId(request.params.stageId)
if (request.body.name) {
stage.name = request.body.name
const options = {
name: request.body.name,
instanceId: request.body.instanceId,
deployToDevices: request.body.deployToDevices,
action: request.body.action,
deviceId: request.body.deviceId,
deviceGroupId: request.body.deviceGroupId
}
if (request.body.action) {
stage.action = request.body.action
}
const stage = await app.db.controllers.Pipeline.updatePipelineStage(
request.params.stageId,
options
)
// Null will remove devices and instances, undefined skips
if (request.body.instanceId !== undefined || request.body.deviceId !== undefined) {
// Currently only one instance or device per stage is supported
const instances = await stage.getInstances()
for (const instance of instances) {
await stage.removeInstance(instance)
reply.send(await app.db.views.PipelineStage.stage(stage))
} catch (error) {
if (error instanceof ValidationError) {
if (error.errors[0]) {
return reply.status(400).type('application/json').send({ code: `invalid_${error.errors[0].path}`, error: error.errors[0].message })
}
// Currently only one device per stage is supported
const devices = await stage.getDevices()
for (const device of devices) {
await stage.removeDevice(device)
}
if (request.body.instanceId && request.body.deviceId) {
return reply.code(400).send({ code: 'invalid_input', error: 'Must provide instanceId or deviceId, not both' })
} else if (request.body.instanceId) {
await stage.addInstanceId(request.body.instanceId)
} else if (request.body.deviceId) {
await stage.addDeviceId(request.body.deviceId)
}
return reply.status(400).type('application/json').send({ code: 'invalid_input', error: error.message })
}
if (request.body.deployToDevices !== undefined) {
stage.deployToDevices = request.body.deployToDevices
if (handleControllerError(error, reply)) {
return
}
await stage.save()
// Load in devices and instance objects from ids
await stage.reload()
reply.send(await app.db.views.PipelineStage.stage(stage))
} catch (err) {
if (err instanceof ValidationError) {
if (err.errors[0]) {
return reply.status(400).type('application/json').send({ code: `invalid_${err.errors[0].path}`, error: err.errors[0].message })
}
return reply.status(400).type('application/json').send({ code: 'invalid_input', error: err.message })
}
app.log.error('Error while updating pipeline stage:')
app.log.error(err)
app.log.error(error)
return reply.code(500).send({ code: 'unexpected_error', error: err.toString() })
return reply.code(500).send({ code: 'unexpected_error', error: error.toString() })
}

@@ -536,2 +508,3 @@ })

let repliedEarly = false
let sourceDeployed, deployTarget
try {

@@ -547,2 +520,3 @@ const sourceStage = await app.db.models.PipelineStage.byId(

targetDevice,
targetDeviceGroup,
targetStage

@@ -566,2 +540,3 @@ } = await app.db.controllers.Pipeline.validateSourceStageForDeploy(

)
sourceDeployed = sourceInstance
} else if (sourceDevice) {

@@ -573,2 +548,3 @@ sourceSnapshot = await app.db.controllers.Pipeline.getOrCreateSnapshotForSourceDevice(

)
sourceDeployed = sourceDevice
} else {

@@ -595,3 +571,3 @@ throw new Error('No source device or instance found.')

repliedEarly = true
deployTarget = targetInstance
await deployPromise

@@ -609,3 +585,15 @@ } else if (targetDevice) {

repliedEarly = true
deployTarget = targetDevice
await deployPromise
} else if (targetDeviceGroup) {
const deployPromise = app.db.controllers.Pipeline.deploySnapshotToDeviceGroup(
sourceSnapshot,
targetDeviceGroup,
{
user
})
reply.code(200).send({ status: 'importing' })
repliedEarly = true
deployTarget = targetDeviceGroup
await deployPromise

@@ -615,2 +603,4 @@ } else {

}
await teamLogger.application.pipeline.stageDeployed(request.session.User, null, request.application.Team, request.application, request.pipeline, sourceDeployed, deployTarget)
} catch (err) {

@@ -676,2 +666,27 @@ if (repliedEarly) {

})
function handleValidationError (error, reply) {
if (error instanceof ValidationError) {
if (error.errors[0]) {
return reply.status(400).type('application/json').send({ code: `invalid_${error.errors[0].path}`, error: error.errors[0].message })
}
reply.status(400).type('application/json').send({ code: 'invalid_input', error: error.message })
return true // handled
}
return false // not handled
}
function handleControllerError (error, reply) {
if (error instanceof ControllerError) {
reply
.code(error.statusCode || 400)
.send({
code: error.code || 'unexpected_error',
error: error.error || error.message
})
return true // handled
}
return false // not handled
}
}

@@ -139,2 +139,2 @@ const { Authenticator } = require('@fastify/passport')

done()
})
}, { name: 'app.ee.routes.sso.auth' })

@@ -95,2 +95,2 @@ const fp = require('fastify-plugin')

done()
})
}, { name: 'app.ee.routes.sso' })

@@ -15,3 +15,2 @@ const cookie = require('@fastify/cookie')

const license = require('./licensing')
const monitor = require('./monitor')
const postoffice = require('./postoffice')

@@ -80,3 +79,5 @@ const routes = require('./routes')

trustProxy: true,
logger: loggerConfig
logger: loggerConfig,
// Increase the default timeout
pluginTimeout: 20000
})

@@ -91,8 +92,9 @@

const environment = process.env.SENTRY_ENV ?? (process.env.NODE_ENV ?? 'unknown')
const sentrySampleRate = environment === 'production' ? 0.1 : 0.5
server.register(require('@immobiliarelabs/fastify-sentry'), {
dsn: runtimeConfig.telemetry.backend.sentry.dsn,
sendClientReports: true,
environment,
release: `flowfuse@${runtimeConfig.version}`,
tracesSampleRate: environment === 'production' ? 0.05 : 0.1,
profilesSampleRate: environment === 'production' ? 0.05 : 0.1,
profilesSampleRate: sentrySampleRate, // relative to output from tracesSampler
integrations: [

@@ -114,2 +116,37 @@ new ProfilingIntegration()

return extractedUser
},
tracesSampler: (samplingContext) => {
// Adjust sample rates for routes with high volumes, sorted descending by volume
// Used for mosquitto auth
if (samplingContext?.transactionContext?.name === 'POST /api/comms/auth/client' || samplingContext?.transactionContext?.name === 'POST /api/comms/auth/acl') {
return 0.001
}
// Used by nr-launcher and for nr-auth
if (samplingContext?.transactionContext?.name === 'GET POST /account/token') {
return 0.01
}
// Common endpoints in app (list devices by team, list devices by project)
if (samplingContext?.transactionContext?.name === 'GET /api/v1/teams/:teamId/devices' || samplingContext?.transactionContext?.name === 'GET /api/v1/projects/:instanceId/devices') {
return 0.01
}
// Used by device editor device tunnel
if (samplingContext?.transactionContext?.name === 'GET /api/v1/devices/:deviceId/editor/proxy/*') {
return 0.01
}
// Prometheus scraping
if (samplingContext?.transactionContext?.name === 'GET /metrics') {
return 0.01
}
// OAuth check
if (samplingContext?.transactionContext?.name === 'GET /account/check/:ownerType/:ownerId') {
return 0.01
}
return sentrySampleRate
}

@@ -298,5 +335,2 @@ })

// Monitor
await server.register(monitor)
await server.ready()

@@ -312,6 +346,12 @@

} catch (err) {
console.error(err)
server.log.error(`Failed to start: ${err.toString()}`)
server.log.error(err.stack)
try {
await server.close()
} catch (err2) {
server.log.error(`Failed to shutdown: ${err2.toString()}`)
server.log.error(err2.stack)
}
throw err
}
}

@@ -22,2 +22,3 @@ const { captureCheckIn, captureException } = require('@sentry/node')

const tasks = {}
const delayedStartupTasks = []

@@ -32,2 +33,5 @@ // Ensure we stop any scheduled tasks when the app is shutting down

})
delayedStartupTasks.forEach(startupTimeout => {
clearTimeout(startupTimeout)
})
})

@@ -103,28 +107,25 @@

// Startup tasks are run instantly
if (task.startup) {
await task.run(app).catch(err => {
app.log.error(`Error running task '${task.name}: ${err.toString()}`)
})
}
// If the task has a schedule (cron-string), setup the job
if (task.schedule) {
task.job = scheduleTask(task.schedule, (timestamp) => {
app.log.trace(`Running task '${task.name}'`)
task.job = scheduleTask(task.schedule, (timestamp) => { runTask(task) })
}
}
const checkInId = reportTask(task.name, task.schedule)
function runTask (task) {
app.log.trace(`Running task '${task.name}'`)
task
.run(app)
.then(reportTaskComplete.bind(this, checkInId, task.name))
.catch(err => {
const errorMessage = `Error running task '${task.name}: ${err.toString()}`
const checkInId = reportTask(task.name, task.schedule)
app.log.error(errorMessage)
return task
.run(app)
.then(reportTaskComplete.bind(this, checkInId, task.name))
.catch(err => {
const errorMessage = `Error running task '${task.name}: ${err.toString()}`
reportTaskFailure(checkInId, task.name, errorMessage)
})
app.log.error(errorMessage)
reportTaskFailure(checkInId, task.name, errorMessage)
}).then(() => {
app.log.trace(`Completed task '${task.name}'`)
return null
})
}
}

@@ -135,3 +136,17 @@

await registerTask(require('./tasks/licenseOverage'))
await registerTask(require('./tasks/telemetryMetrics'))
app.addHook('onReady', async () => {
let promise = Promise.resolve()
for (const task of Object.values(tasks)) {
if (task.startup === true) {
// Schedule startup run immediately (in queue with other tasks)
promise = promise.then(() => runTask(task))
} else if (typeof task.startup === 'number') {
// Schedule startup run after the specified delay
delayedStartupTasks.push(setTimeout(() => runTask(task), task.startup))
}
}
})
app.decorate('housekeeper', {

@@ -142,2 +157,2 @@ registerTask

next()
})
}, { name: 'app.housekeeper' })

@@ -24,3 +24,6 @@ module.exports = {

'httpNodeAuth_user',
'httpNodeAuth_pass'
'httpNodeAuth_pass',
'emailAlerts_crash',
'emailAlerts_safe',
'emailAlerts_recipients'
],

@@ -52,3 +55,6 @@ passwordTypes: [

httpNodeAuth_user: '',
httpNodeAuth_pass: ''
httpNodeAuth_pass: '',
emailAlerts_crash: false,
emailAlerts_safe: false,
emailAlerts_recipients: 'owners'
},

@@ -77,4 +83,7 @@ defaultTemplatePolicy: {

httpNodeAuth_user: true,
httpNodeAuth_pass: true
httpNodeAuth_pass: true,
emailAlerts_crash: true,
emailAlerts_safe: true,
emailAlerts_recipients: true
}
}

@@ -180,2 +180,2 @@ const fp = require('fastify-plugin')

}
})
}, { name: 'app.licensing' })

@@ -1,2 +0,2 @@

// This is a command-line tool used to generate valid FlowForge license files.
// This is a command-line tool used to generate valid FlowFuse license files.

@@ -14,3 +14,3 @@ // A license file is encoded as a JSON Web Token signed using ES256

// To generate a production license, you will need to access the Production private key
// file in the FlowForge 1Password vault
// file in the FlowFuse 1Password vault

@@ -36,3 +36,3 @@ // ref: https://www.scottbrady91.com/OpenSSL/Creating-Elliptical-Curve-Keys-using-OpenSSL

;(async () => {
console.info('FlowForge EE License Generator')
console.info('FlowFuse EE License Generator')
console.info('------------------------------')

@@ -39,0 +39,0 @@ try {

@@ -180,2 +180,2 @@ const fp = require('fastify-plugin')

next()
})
}, { name: 'app.postoffice' })

@@ -25,2 +25,3 @@ const { readFileSync, existsSync } = require('fs')

{ name: 'Applications', description: '' },
{ name: 'Application Device Groups', description: '' },
{ name: 'Instances', description: '' },

@@ -161,2 +162,2 @@ { name: 'Instance Types', description: '' },

done()
})
}, { name: 'app.routes.api-docs' })

@@ -343,3 +343,3 @@ module.exports = async function (app) {

const devices = await app.db.models.Device.getAll(paginationOptions, where, { includeInstanceApplication: false })
const devices = await app.db.models.Device.getAll(paginationOptions, where, { includeInstanceApplication: false, includeDeviceGroup: true })
devices.devices = devices.devices.map(d => app.db.views.Device.device(d, { statusOnly: paginationOptions.statusOnly }))

@@ -346,0 +346,0 @@

@@ -424,3 +424,3 @@ const { KEY_HOSTNAME, KEY_SETTINGS } = require('../../db/models/ProjectSettings')

if (teamType.properties.features?.teamHttpSecurity === false) {
reply.code(400).send({ code: 'invalid_request', error: 'FlowForge User Authentication not available for this team type' })
reply.code(400).send({ code: 'invalid_request', error: 'FlowFuse User Authentication not available for this team type' })
return

@@ -427,0 +427,0 @@ }

@@ -151,2 +151,6 @@ const { Roles } = require('../../lib/roles')

}
if (request.session.User.admin) {
result.billing.customer = subscription.customer
result.billing.subscription = subscription.subscription
}
} else {

@@ -484,9 +488,45 @@ result.billing.active = false

try {
const instanceCount = await request.team.instanceCount()
if (instanceCount > 0) {
// need to delete Instances
const instances = await app.db.models.Project.byTeam(request.team.hashid)
for (const instance of instances) {
try {
await app.containers.remove(instance)
} catch (err) {
if (err?.statusCode !== 404) {
throw err
}
}
await instance.destroy()
await app.auditLog.Team.project.deleted(request.session.User, null, request.team, instance)
await app.auditLog.Project.project.deleted(request.session.User, null, request.team, instance)
}
}
// Delete Applications
const applications = await app.db.models.Application.byTeam(request.team.hashid)
for (const application of applications) {
await application.destroy()
await app.auditLog.Team.application.deleted(request.session.User, null, request.team, application)
}
// Delete Devices
const where = {
TeamId: request.team.id
}
const devices = await app.db.models.Device.getAll({}, where, { includeInstanceApplication: true })
for (const device of devices.devices) {
await device.destroy()
await app.auditLog.Team.team.device.deleted(request.session.User, null, request.team, device)
}
if (app.license.active() && app.billing) {
const subscription = await request.team.getSubscription()
if (subscription && !subscription.isTrial()) {
// const subId = subscription.subscription
if (subscription && !subscription.isTrial() && !subscription.isUnmanaged()) {
await app.billing.closeSubscription(subscription)
}
}
await request.team.destroy()

@@ -493,0 +533,0 @@ await app.auditLog.Platform.platform.team.deleted(request.session.User, null, request.team)

@@ -78,5 +78,20 @@ const sharedUser = require('./shared/users')

await app.postoffice.send(request.session.User, 'PasswordChanged', { })
// Delete all existing sessions for this user
await app.db.controllers.Session.deleteAllUserSessions(request.session.User)
// Create new session
const sessionInfo = await app.createSessionCookie(request.session.User.username)
if (sessionInfo) {
reply.setCookie('sid', sessionInfo.session.sid, sessionInfo.cookieOptions)
}
// Clear any password reset tokens
await app.db.controllers.AccessToken.deleteAllUserPasswordResetTokens(request.session.User)
reply.send({ status: 'okay' })
} catch (err) {
const resp = { code: 'password_change_failed', error: 'password change failed' }
let resp
if (err.message === 'Password Too Weak') {
resp = { code: 'password_change_failed_too_weak', error: 'password too weak' }
} else {
resp = { code: 'password_change_failed', error: 'password change failed' }
}
await app.auditLog.User.user.updatedPassword(request.session.User, resp)

@@ -83,0 +98,0 @@ reply.code(400).send(resp)

@@ -40,3 +40,3 @@ /**

module.exports = fp(init)
module.exports = fp(init, { name: 'app.routes.auth' })

@@ -312,16 +312,3 @@ /**

userInfo = app.auditLog.formatters.userObject(user)
const sessions = await app.db.models.StorageSession.byUsername(user.username)
for (let index = 0; index < sessions.length; index++) {
const session = sessions[index]
const ProjectId = session.ProjectId
const project = await app.db.models.Project.byId(ProjectId)
for (let index = 0; index < session.sessions.length; index++) {
const token = session.sessions[index].accessToken
try {
await app.containers.revokeUserToken(project, token) // logout:nodered(step-2)
} catch (error) {
app.log.warn(`Failed to revoke token for Project ${ProjectId}: ${error.toString()}`) // log error but continue to delete session
}
}
}
await app.db.controllers.User.logout(user)
}

@@ -448,3 +435,3 @@ await app.db.controllers.Session.deleteSession(request.sid)

// For now we'll auto-accept any invites for this user
// See https://github.com/flowforge/flowforge/issues/275#issuecomment-1040113991
// See https://github.com/FlowFuse/flowfuse/issues/275#issuecomment-1040113991
await app.db.controllers.Invitation.acceptInvitation(invite, newUser)

@@ -548,3 +535,3 @@ // // If we go back to having the user be able to accept invites

// For now we'll auto-accept any invites for this user
// See https://github.com/flowforge/flowforge/issues/275#issuecomment-1040113991
// See https://github.com/FlowFuse/flowfuse/issues/275#issuecomment-1040113991
await app.db.controllers.Invitation.acceptInvitation(invite, verifiedUser)

@@ -792,2 +779,5 @@ // // If we go back to having the user be able to accept invites

await app.db.controllers.User.resetPassword(user, request.body.password)
// Clear any existing sessions to force a re-login
await app.db.controllers.Session.deleteAllUserSessions(user)
await app.db.controllers.AccessToken.deleteAllUserPasswordResetTokens(user)
success = true

@@ -794,0 +784,0 @@ } catch (err) {

@@ -160,3 +160,3 @@ const crypto = require('crypto')

if (requestObject.client_id === 'ff-plugin') {
// This is the FlowForge Node-RED plugin.
// This is the FlowFuse Node-RED plugin.
} else {

@@ -163,0 +163,0 @@ const authClient = await app.db.controllers.AuthClient.getAuthClient(requestObject.client_id)

@@ -104,2 +104,2 @@ const fp = require('fastify-plugin')

done()
})
}, { name: 'app.routes.auth.permissions' })

@@ -32,2 +32,2 @@ /**

done()
})
}, { name: 'app.routes' })

@@ -56,5 +56,12 @@ const { getLoggers } = require('../../auditLog/project')

await app.db.controllers.Project.removeProjectModule(request.project, auditEvent.module)
} else if (event === 'modules.install' && !error) {
await app.db.controllers.Project.addProjectModule(request.project, auditEvent.module, auditEvent.version || '*')
} else if (event === 'crashed' || event === 'safe-mode') {
if (app.config.features.enabled('emailAlerts')) {
await app.auditLog.alerts.generate(projectId, event)
}
}
response.status(200).send()
})
}

@@ -95,3 +95,3 @@ /**

app.log.info('****************************************************')
app.log.info('* FlowForge setup is complete. You can login at: *')
app.log.info('* FlowFuse setup is complete. You can login at: *')
app.log.info(`* ${app.config.base_url.padEnd(47, ' ')}*`)

@@ -98,0 +98,0 @@ app.log.info('****************************************************')

@@ -93,3 +93,2 @@ /**

}
await app.db.controllers.Project.mergeProjectModules(request.project, await app.db.controllers.StorageSettings.getProjectModules(request.project))
response.send(request.body)

@@ -96,0 +95,0 @@ })

@@ -97,3 +97,3 @@ /**

if (app.config.telemetry?.frontend?.plausible) {
app.log.warn('Configuration found for Plausible. Please note that support for Plausible will be deprecated after FlowForge 0.9')
app.log.warn('Configuration found for Plausible. Please note that support for Plausible will be deprecated after FlowFuse 0.9')
}

@@ -100,0 +100,0 @@ // check if we need to inject plausible

@@ -59,2 +59,2 @@ const fs = require('fs')

next()
})
}, { name: 'app.settings' })

@@ -1,358 +0,1 @@

/*
* ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (function() { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({});
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ id: moduleId,
/******/ loaded: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.loaded = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = __webpack_modules__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/chunk loaded */
/******/ !function() {
/******/ var deferred = [];
/******/ __webpack_require__.O = function(result, chunkIds, fn, priority) {
/******/ if(chunkIds) {
/******/ priority = priority || 0;
/******/ for(var i = deferred.length; i > 0 && deferred[i - 1][2] > priority; i--) deferred[i] = deferred[i - 1];
/******/ deferred[i] = [chunkIds, fn, priority];
/******/ return;
/******/ }
/******/ var notFulfilled = Infinity;
/******/ for (var i = 0; i < deferred.length; i++) {
/******/ var chunkIds = deferred[i][0];
/******/ var fn = deferred[i][1];
/******/ var priority = deferred[i][2];
/******/ var fulfilled = true;
/******/ for (var j = 0; j < chunkIds.length; j++) {
/******/ if ((priority & 1 === 0 || notFulfilled >= priority) && Object.keys(__webpack_require__.O).every(function(key) { return __webpack_require__.O[key](chunkIds[j]); })) {
/******/ chunkIds.splice(j--, 1);
/******/ } else {
/******/ fulfilled = false;
/******/ if(priority < notFulfilled) notFulfilled = priority;
/******/ }
/******/ }
/******/ if(fulfilled) {
/******/ deferred.splice(i--, 1)
/******/ var r = fn();
/******/ if (r !== undefined) result = r;
/******/ }
/******/ }
/******/ return result;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/compat get default export */
/******/ !function() {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function() { return module['default']; } :
/******/ function() { return module; };
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/create fake namespace object */
/******/ !function() {
/******/ var getProto = Object.getPrototypeOf ? function(obj) { return Object.getPrototypeOf(obj); } : function(obj) { return obj.__proto__; };
/******/ var leafPrototypes;
/******/ // create a fake namespace object
/******/ // mode & 1: value is a module id, require it
/******/ // mode & 2: merge all properties of value into the ns
/******/ // mode & 4: return value when already ns object
/******/ // mode & 16: return value when it's Promise-like
/******/ // mode & 8|1: behave like require
/******/ __webpack_require__.t = function(value, mode) {
/******/ if(mode & 1) value = this(value);
/******/ if(mode & 8) return value;
/******/ if(typeof value === 'object' && value) {
/******/ if((mode & 4) && value.__esModule) return value;
/******/ if((mode & 16) && typeof value.then === 'function') return value;
/******/ }
/******/ var ns = Object.create(null);
/******/ __webpack_require__.r(ns);
/******/ var def = {};
/******/ leafPrototypes = leafPrototypes || [null, getProto({}), getProto([]), getProto(getProto)];
/******/ for(var current = mode & 2 && value; typeof current == 'object' && !~leafPrototypes.indexOf(current); current = getProto(current)) {
/******/ Object.getOwnPropertyNames(current).forEach(function(key) { def[key] = function() { return value[key]; }; });
/******/ }
/******/ def['default'] = function() { return value; };
/******/ __webpack_require__.d(ns, def);
/******/ return ns;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ !function() {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = function(exports, definition) {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/ensure chunk */
/******/ !function() {
/******/ __webpack_require__.f = {};
/******/ // This file contains only the entry chunk.
/******/ // The chunk loading function for additional chunks
/******/ __webpack_require__.e = function(chunkId) {
/******/ return Promise.all(Object.keys(__webpack_require__.f).reduce(function(promises, key) {
/******/ __webpack_require__.f[key](chunkId, promises);
/******/ return promises;
/******/ }, []));
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/get javascript chunk filename */
/******/ !function() {
/******/ // This function allow to reference async chunks
/******/ __webpack_require__.u = function(chunkId) {
/******/ // return url for filenames based on template
/******/ return "" + chunkId + ".js";
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/get mini-css chunk filename */
/******/ !function() {
/******/ // This function allow to reference async chunks
/******/ __webpack_require__.miniCssF = function(chunkId) {
/******/ // return url for filenames based on template
/******/ return undefined;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/global */
/******/ !function() {
/******/ __webpack_require__.g = (function() {
/******/ if (typeof globalThis === 'object') return globalThis;
/******/ try {
/******/ return this || new Function('return this')();
/******/ } catch (e) {
/******/ if (typeof window === 'object') return window;
/******/ }
/******/ })();
/******/ }();
/******/
/******/ /* webpack/runtime/harmony module decorator */
/******/ !function() {
/******/ __webpack_require__.hmd = function(module) {
/******/ module = Object.create(module);
/******/ if (!module.children) module.children = [];
/******/ Object.defineProperty(module, 'exports', {
/******/ enumerable: true,
/******/ set: function() {
/******/ throw new Error('ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: ' + module.id);
/******/ }
/******/ });
/******/ return module;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ !function() {
/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
/******/ }();
/******/
/******/ /* webpack/runtime/load script */
/******/ !function() {
/******/ var inProgress = {};
/******/ var dataWebpackPrefix = "@flowfuse/flowfuse:";
/******/ // loadScript function to load a script via script tag
/******/ __webpack_require__.l = function(url, done, key, chunkId) {
/******/ if(inProgress[url]) { inProgress[url].push(done); return; }
/******/ var script, needAttach;
/******/ if(key !== undefined) {
/******/ var scripts = document.getElementsByTagName("script");
/******/ for(var i = 0; i < scripts.length; i++) {
/******/ var s = scripts[i];
/******/ if(s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; }
/******/ }
/******/ }
/******/ if(!script) {
/******/ needAttach = true;
/******/ script = document.createElement('script');
/******/
/******/ script.charset = 'utf-8';
/******/ script.timeout = 120;
/******/ if (__webpack_require__.nc) {
/******/ script.setAttribute("nonce", __webpack_require__.nc);
/******/ }
/******/ script.setAttribute("data-webpack", dataWebpackPrefix + key);
/******/
/******/ script.src = url;
/******/ }
/******/ inProgress[url] = [done];
/******/ var onScriptComplete = function(prev, event) {
/******/ // avoid mem leaks in IE.
/******/ script.onerror = script.onload = null;
/******/ clearTimeout(timeout);
/******/ var doneFns = inProgress[url];
/******/ delete inProgress[url];
/******/ script.parentNode && script.parentNode.removeChild(script);
/******/ doneFns && doneFns.forEach(function(fn) { return fn(event); });
/******/ if(prev) return prev(event);
/******/ }
/******/ var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);
/******/ script.onerror = onScriptComplete.bind(null, script.onerror);
/******/ script.onload = onScriptComplete.bind(null, script.onload);
/******/ needAttach && document.head.appendChild(script);
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ !function() {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = function(exports) {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/publicPath */
/******/ !function() {
/******/ __webpack_require__.p = "/app/";
/******/ }();
/******/
/******/ /* webpack/runtime/jsonp chunk loading */
/******/ !function() {
/******/ __webpack_require__.b = document.baseURI || self.location.href;
/******/
/******/ // object to store loaded and loading chunks
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ "runtime": 0
/******/ };
/******/
/******/ __webpack_require__.f.j = function(chunkId, promises) {
/******/ // JSONP chunk loading for javascript
/******/ var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
/******/ if(installedChunkData !== 0) { // 0 means "already installed".
/******/
/******/ // a Promise means "currently loading".
/******/ if(installedChunkData) {
/******/ promises.push(installedChunkData[2]);
/******/ } else {
/******/ if("runtime" != chunkId) {
/******/ // setup Promise in chunk cache
/******/ var promise = new Promise(function(resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; });
/******/ promises.push(installedChunkData[2] = promise);
/******/
/******/ // start chunk loading
/******/ var url = __webpack_require__.p + __webpack_require__.u(chunkId);
/******/ // create error before stack unwound to get useful stacktrace later
/******/ var error = new Error();
/******/ var loadingEnded = function(event) {
/******/ if(__webpack_require__.o(installedChunks, chunkId)) {
/******/ installedChunkData = installedChunks[chunkId];
/******/ if(installedChunkData !== 0) installedChunks[chunkId] = undefined;
/******/ if(installedChunkData) {
/******/ var errorType = event && (event.type === 'load' ? 'missing' : event.type);
/******/ var realSrc = event && event.target && event.target.src;
/******/ error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
/******/ error.name = 'ChunkLoadError';
/******/ error.type = errorType;
/******/ error.request = realSrc;
/******/ installedChunkData[1](error);
/******/ }
/******/ }
/******/ };
/******/ __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
/******/ } else installedChunks[chunkId] = 0;
/******/ }
/******/ }
/******/ };
/******/
/******/ // no prefetching
/******/
/******/ // no preloaded
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/
/******/ __webpack_require__.O.j = function(chunkId) { return installedChunks[chunkId] === 0; };
/******/
/******/ // install a JSONP callback for chunk loading
/******/ var webpackJsonpCallback = function(parentChunkLoadingFunction, data) {
/******/ var chunkIds = data[0];
/******/ var moreModules = data[1];
/******/ var runtime = data[2];
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId, chunkId, i = 0;
/******/ if(chunkIds.some(function(id) { return installedChunks[id] !== 0; })) {
/******/ for(moduleId in moreModules) {
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(runtime) var result = runtime(__webpack_require__);
/******/ }
/******/ if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
/******/ for(;i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
/******/ installedChunks[chunkId][0]();
/******/ }
/******/ installedChunks[chunkId] = 0;
/******/ }
/******/ return __webpack_require__.O(result);
/******/ }
/******/
/******/ var chunkLoadingGlobal = self["webpackChunk_flowfuse_flowfuse"] = self["webpackChunk_flowfuse_flowfuse"] || [];
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
/******/ }();
/******/
/******/ /* webpack/runtime/nonce */
/******/ !function() {
/******/ __webpack_require__.nc = undefined;
/******/ }();
/******/
/************************************************************************/
/******/
/******/
/******/ })()
;
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},t=(new Error).stack;t&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[t]="bcaf1993-55f0-41e5-981d-a47e57d80c87",e._sentryDebugIdIdentifier="sentry-dbid-bcaf1993-55f0-41e5-981d-a47e57d80c87")}catch(e){}}();var _global="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};_global.SENTRY_RELEASE={id:"2ec184600c64de6e6a35d0b1f18905b5a4ef1b17"},function(){"use strict";var e,t,n,r,o,u={},i={};function f(e){var t=i[e];if(void 0!==t)return t.exports;var n=i[e]={id:e,loaded:!1,exports:{}};return u[e](n,n.exports,f),n.loaded=!0,n.exports}f.m=u,e=[],f.O=function(t,n,r,o){if(!n){var u=1/0;for(d=0;d<e.length;d++){n=e[d][0],r=e[d][1],o=e[d][2];for(var i=!0,a=0;a<n.length;a++)(!1&o||u>=o)&&Object.keys(f.O).every((function(e){return f.O[e](n[a])}))?n.splice(a--,1):(i=!1,o<u&&(u=o));if(i){e.splice(d--,1);var c=r();void 0!==c&&(t=c)}}return t}o=o||0;for(var d=e.length;d>0&&e[d-1][2]>o;d--)e[d]=e[d-1];e[d]=[n,r,o]},f.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return f.d(t,{a:t}),t},n=Object.getPrototypeOf?function(e){return Object.getPrototypeOf(e)}:function(e){return e.__proto__},f.t=function(e,r){if(1&r&&(e=this(e)),8&r)return e;if("object"==typeof e&&e){if(4&r&&e.__esModule)return e;if(16&r&&"function"==typeof e.then)return e}var o=Object.create(null);f.r(o);var u={};t=t||[null,n({}),n([]),n(n)];for(var i=2&r&&e;"object"==typeof i&&!~t.indexOf(i);i=n(i))Object.getOwnPropertyNames(i).forEach((function(t){u[t]=function(){return e[t]}}));return u.default=function(){return e},f.d(o,u),o},f.d=function(e,t){for(var n in t)f.o(t,n)&&!f.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},f.f={},f.e=function(e){return Promise.all(Object.keys(f.f).reduce((function(t,n){return f.f[n](e,t),t}),[]))},f.u=function(e){return"async-vendors.js"},f.miniCssF=function(e){},f.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),f.hmd=function(e){return(e=Object.create(e)).children||(e.children=[]),Object.defineProperty(e,"exports",{enumerable:!0,set:function(){throw new Error("ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: "+e.id)}}),e},f.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r={},o="@flowfuse/flowfuse:",f.l=function(e,t,n,u){if(r[e])r[e].push(t);else{var i,a;if(void 0!==n)for(var c=document.getElementsByTagName("script"),d=0;d<c.length;d++){var l=c[d];if(l.getAttribute("src")==e||l.getAttribute("data-webpack")==o+n){i=l;break}}i||(a=!0,(i=document.createElement("script")).charset="utf-8",i.timeout=120,f.nc&&i.setAttribute("nonce",f.nc),i.setAttribute("data-webpack",o+n),i.src=e),r[e]=[t];var s=function(t,n){i.onerror=i.onload=null,clearTimeout(b);var o=r[e];if(delete r[e],i.parentNode&&i.parentNode.removeChild(i),o&&o.forEach((function(e){return e(n)})),t)return t(n)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=s.bind(null,i.onerror),i.onload=s.bind(null,i.onload),a&&document.head.appendChild(i)}},f.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},f.p="/app/",function(){f.b=document.baseURI||self.location.href;var e={666:0};f.f.j=function(t,n){var r=f.o(e,t)?e[t]:void 0;if(0!==r)if(r)n.push(r[2]);else if(666!=t){var o=new Promise((function(n,o){r=e[t]=[n,o]}));n.push(r[2]=o);var u=f.p+f.u(t),i=new Error;f.l(u,(function(n){if(f.o(e,t)&&(0!==(r=e[t])&&(e[t]=void 0),r)){var o=n&&("load"===n.type?"missing":n.type),u=n&&n.target&&n.target.src;i.message="Loading chunk "+t+" failed.\n("+o+": "+u+")",i.name="ChunkLoadError",i.type=o,i.request=u,r[1](i)}}),"chunk-"+t,t)}else e[t]=0},f.O.j=function(t){return 0===e[t]};var t=function(t,n){var r,o,u=n[0],i=n[1],a=n[2],c=0;if(u.some((function(t){return 0!==e[t]}))){for(r in i)f.o(i,r)&&(f.m[r]=i[r]);if(a)var d=a(f)}for(t&&t(n);c<u.length;c++)o=u[c],f.o(e,o)&&e[o]&&e[o][0](),e[o]=0;return f.O(d)},n=self.webpackChunk_flowfuse_flowfuse=self.webpackChunk_flowfuse_flowfuse||[];n.forEach(t.bind(null,0)),n.push=t.bind(null,n.push.bind(n))}(),f.nc=void 0}();
{
"name": "@flowfuse/flowfuse",
"version": "1.14.1",
"description": "An open source low-code development platform",
"homepage": "https://flowfuse.com",
"bugs": {
"url": "https://github.com/FlowFuse/flowfuse/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/FlowFuse/flowfuse.git"
},
"license": "SEE LICENSE IN ./LICENSE",
"author": {
"name": "FlowFuse Inc."
},
"bin": {
"ff-install-stack": "./scripts/install-stack.js",
"flowforge": "./forge/app.js"
},
"scripts": {
"start": "node forge/app.js",
"repl": "node forge/app.js --repl",
"build": "webpack -c ./config/webpack.config.js",
"serve": "npm-run-all --parallel build-watch start-watch",
"serve-repl": "npm-run-all --parallel build-watch start-watch-repl",
"start-watch": "cross-env NODE_ENV=development nodemon -w forge -w ee/forge -i forge/containers/localfs_root forge/app.js",
"start-watch-repl": "cross-env NODE_ENV=development nodemon -w forge -w ee/forge -i forge/containers/localfs_root forge/app.js --repl",
"build-watch": "webpack --mode=development -c ./config/webpack.config.js --watch",
"lint": "eslint -c .eslintrc \"forge/**/*.js\" \"frontend/**/*.js\" \"frontend/**/*.vue\" \"test/**/*.js\" --ignore-pattern \"frontend/dist/**\"",
"lint:fix": "eslint -c .eslintrc \"forge/**/*.js\" \"frontend/**/*.js\" \"frontend/**/*.vue\" \"test/**/*.js\" --ignore-pattern \"frontend/dist/**\" --fix",
"test": "npm-run-all --sequential lint test:unit test:system",
"test:unit": "npm-run-all --sequential test:unit:forge test:unit:frontend",
"test:unit:forge": "mocha 'test/unit/forge/**/*_spec.js' --timeout 10000 --node-option=unhandled-rejections=strict",
"test:unit:frontend": "vitest run --config ./config/vitest.config.ts",
"test:docs": "node test/e2e/docs/valid-links.js ./docs",
"test:system": "mocha 'test/system/**/*_spec.js' --timeout 10000 --node-option=unhandled-rejections=strict",
"cy:web-server": "npm-run-all --parallel cy:web-server:os cy:web-server:ee",
"cy:web-server:os": "node ./test/e2e/frontend/test_environment_os",
"cy:web-server:ee": "node ./test/e2e/frontend/test_environment_ee",
"cy:run": "npm-run-all --parallel cy:run:os cy:run:ee",
"cy:run:os": "cypress run --config-file ./config/cypress-os.config.js",
"cy:run:ee": "cypress run --config-file ./config/cypress-ee.config.js",
"cy:open:os": "cypress open --config-file ./config/cypress-os.config.js",
"cy:open:ee": "cypress open --config-file ./config/cypress-ee.config.js",
"cover": "npm-run-all --sequential cover:unit cover:system cover:report",
"cover:unit": "npm-run-all --sequential cover:unit:forge cover:unit:frontend",
"cover:unit:forge": "nyc --silent npm run test:unit:forge && nyc report --reporter=json --report-dir ./coverage/reports/forge/ && mv ./coverage/reports/forge/coverage-final.json ./coverage/reports/forge-coverage.json",
"cover:unit:frontend": "vitest --config ./config/vitest.config.ts run --coverage && mv ./coverage/reports/frontend/coverage-final.json ./coverage/reports/frontend-coverage.json",
"cover:system": "nyc --silent npm run test:system && nyc report --reporter=json --report-dir ./coverage/reports/system/ && mv ./coverage/reports/system/coverage-final.json ./coverage/reports/system-coverage.json",
"cover:report": "nyc report --reporter=html --reporter=json -t './coverage/reports'",
"install-stack": "node scripts/install-stack.js --"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.352.0",
"@aws-sdk/credential-provider-node": "^3.352.0",
"@fastify/cookie": "^9.1.0",
"@fastify/csrf-protection": "^6.3.0",
"@fastify/formbody": "^7.4.0",
"@fastify/helmet": "^11.1.1",
"@fastify/passport": "^2.3.0",
"@fastify/rate-limit": "^8.0.3",
"@fastify/routes": "^5.1.0",
"@fastify/static": "^6.10.2",
"@fastify/swagger": "^8.10.1",
"@fastify/swagger-ui": "^1.9.0",
"@fastify/websocket": "^8.1.0",
"@flowfuse/driver-localfs": "^1.14.0",
"@headlessui/vue": "1.7.16",
"@heroicons/vue": "1.0.6",
"@immobiliarelabs/fastify-sentry": "^7.1.1",
"@levminer/speakeasy": "^1.4.2",
"@node-saml/passport-saml": "^4.0.4",
"@sentry/node": "^7.73.0",
"@sentry/profiling-node": "^1.2.1",
"@sentry/vue": "^7.72.0",
"@sentry/webpack-plugin": "^2.7.1",
"axios": "^1.4.0",
"bcrypt": "^5.1.0",
"cronosjs": "^1.7.1",
"dotenv": "^16.3.1",
"fastify": "^4.24.0",
"fastify-metrics": "^10.3.2",
"fastify-plugin": "^4.5.0",
"handlebars": "^4.7.7",
"hashids": "^2.3.0",
"jsonwebtoken": "^9.0.0",
"lottie-web-vue": "^2.0.7",
"lru-cache": "^10.0.0",
"marked": "^10.0.0",
"mqtt": "^5.1.1",
"nodemailer": "^6.9.3",
"pg": "^8.11.3",
"pino": "^8.15.1",
"pino-pretty": "^10.0.0",
"qrcode": "^1.5.3",
"semver": "~7.5.4",
"sequelize": "^6.33.0",
"sqlite3": "^5.1.6",
"stripe": "^8.222.0",
"uuid": "^9.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
"vuex": "^4.1.0",
"yaml": "^2.3.1"
},
"devDependencies": {
"@babel/core": "^7.22.15",
"@babel/preset-env": "^7.22.15",
"@vitejs/plugin-vue": "^4.3.4",
"@vitest/coverage-istanbul": "^0.34.4",
"@vue/test-utils": "^2.4.1",
"autoprefixer": "^10.4.15",
"babel-loader": "^9.1.3",
"c8": "^8.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
"css-loader": "^6.8.1",
"cypress": "^13.2.0",
"dotenv-webpack": "^8.0.1",
"eslint": "^8.48.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-cypress": "2.15.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-n": "^16.0.2",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-vue": "^9.17.0",
"file-loader": "^6.2.0",
"html-link-extractor": "^1.0.5",
"html-webpack-plugin": "^5.5.3",
"jsdom": "^22.1.0",
"mini-css-extract-plugin": "^2.7.6",
"mocha": "^10.2.0",
"mocha-cli": "^1.0.1",
"mockdate": "^3.0.5",
"node-sass": "^9.0.0",
"nodemon": "^3.0.1",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"postcss": "^8.4.29",
"postcss-loader": "^7.3.3",
"postcss-preset-env": "^9.1.3",
"promptly": "^3.2.0",
"sass-loader": "^13.3.2",
"should": "^13.2.3",
"should-sinon": "^0.0.6",
"sinon": "^16.0.0",
"style-loader": "^3.3.3",
"tailwindcss": "^2.2.19",
"vitest": "^0.34.4",
"vue-loader": "^17.2.2",
"vue-template-compiler": "^2.7.14",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"ws": "^8.14.0"
},
"engines": {
"node": ">=16.x"
},
"keywords": [
"low-code-platform",
"low-code-development",
"low-code-development-platform",
"visual-programming",
"flow-based-programming",
"no-code",
"low-code",
"node-red"
]
"name": "@flowfuse/flowfuse",
"version": "1.14.2-2ec1846-202312211045.0",
"description": "An open source low-code development platform",
"homepage": "https://flowfuse.com",
"bugs": {
"url": "https://github.com/FlowFuse/flowfuse/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/FlowFuse/flowfuse.git"
},
"license": "SEE LICENSE IN ./LICENSE",
"author": {
"name": "FlowFuse Inc."
},
"bin": {
"ff-install-stack": "./scripts/install-stack.js",
"flowforge": "./forge/app.js",
"flowfuse": "./forge/app.js"
},
"scripts": {
"start": "node forge/app.js",
"repl": "node forge/app.js --repl",
"build": "webpack -c ./config/webpack.config.js",
"serve": "npm-run-all --parallel build-watch start-watch",
"serve-repl": "npm-run-all --parallel build-watch start-watch-repl",
"start-watch": "cross-env NODE_ENV=development nodemon -w forge -w ee/forge -i forge/containers/localfs_root forge/app.js",
"start-watch-repl": "cross-env NODE_ENV=development nodemon -w forge -w ee/forge -i forge/containers/localfs_root forge/app.js --repl",
"build-watch": "webpack --mode=development -c ./config/webpack.config.js --watch",
"lint": "eslint -c .eslintrc \"forge/**/*.js\" \"frontend/**/*.js\" \"frontend/**/*.vue\" \"test/**/*.js\" --ignore-pattern \"frontend/dist/**\"",
"lint:fix": "eslint -c .eslintrc \"forge/**/*.js\" \"frontend/**/*.js\" \"frontend/**/*.vue\" \"test/**/*.js\" --ignore-pattern \"frontend/dist/**\" --fix",
"test": "npm-run-all --sequential lint test:unit test:system",
"test:unit": "npm-run-all --sequential test:unit:forge test:unit:frontend",
"test:unit:forge": "mocha 'test/unit/forge/**/*_spec.js' --timeout 10000 --node-option=unhandled-rejections=strict",
"test:unit:frontend": "vitest run --config ./config/vitest.config.ts",
"test:docs": "node test/e2e/docs/valid-links.js ./docs",
"test:system": "mocha 'test/system/**/*_spec.js' --timeout 10000 --node-option=unhandled-rejections=strict",
"cy:web-server": "npm-run-all --parallel cy:web-server:os cy:web-server:ee",
"cy:web-server:os": "node ./test/e2e/frontend/test_environment_os",
"cy:web-server:ee": "node ./test/e2e/frontend/test_environment_ee",
"cy:run": "npm-run-all --parallel cy:run:os cy:run:ee",
"cy:run:os": "cypress run --config-file ./config/cypress-os.config.js",
"cy:run:ee": "cypress run --config-file ./config/cypress-ee.config.js",
"cy:open:os": "cypress open --config-file ./config/cypress-os.config.js",
"cy:open:ee": "cypress open --config-file ./config/cypress-ee.config.js",
"cover": "npm-run-all --sequential cover:unit cover:system cover:report",
"cover:unit": "npm-run-all --sequential cover:unit:forge cover:unit:frontend",
"cover:unit:forge": "nyc --silent npm run test:unit:forge && nyc report --reporter=json --report-dir ./coverage/reports/forge/ && mv ./coverage/reports/forge/coverage-final.json ./coverage/reports/forge-coverage.json",
"cover:unit:frontend": "vitest --config ./config/vitest.config.ts run --coverage && mv ./coverage/reports/frontend/coverage-final.json ./coverage/reports/frontend-coverage.json",
"cover:system": "nyc --silent npm run test:system && nyc report --reporter=json --report-dir ./coverage/reports/system/ && mv ./coverage/reports/system/coverage-final.json ./coverage/reports/system-coverage.json",
"cover:report": "nyc report --reporter=html --reporter=json -t './coverage/reports'",
"install-stack": "node scripts/install-stack.js --"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.352.0",
"@aws-sdk/credential-provider-node": "^3.352.0",
"@fastify/cookie": "^9.1.0",
"@fastify/csrf-protection": "^6.3.0",
"@fastify/formbody": "^7.4.0",
"@fastify/helmet": "^11.1.1",
"@fastify/passport": "^2.3.0",
"@fastify/rate-limit": "^8.0.3",
"@fastify/routes": "^5.1.0",
"@fastify/static": "^6.10.2",
"@fastify/swagger": "^8.10.1",
"@fastify/swagger-ui": "^1.9.0",
"@fastify/websocket": "^8.1.0",
"@flowfuse/driver-localfs": "nightly",
"@headlessui/vue": "1.7.16",
"@heroicons/vue": "1.0.6",
"@immobiliarelabs/fastify-sentry": "^7.1.1",
"@levminer/speakeasy": "^1.4.2",
"@node-saml/passport-saml": "^4.0.4",
"@sentry/node": "^7.73.0",
"@sentry/profiling-node": "^1.2.1",
"@sentry/vue": "^7.72.0",
"@sentry/webpack-plugin": "^2.7.1",
"axios": "^1.4.0",
"bcrypt": "^5.1.0",
"cronosjs": "^1.7.1",
"dotenv": "^16.3.1",
"fastify": "^4.24.0",
"fastify-metrics": "^10.3.2",
"fastify-plugin": "^4.5.0",
"handlebars": "^4.7.7",
"hashids": "^2.3.0",
"jsonwebtoken": "^9.0.0",
"lottie-web-vue": "^2.0.7",
"lru-cache": "^10.0.0",
"marked": "^10.0.0",
"mqtt": "^5.1.1",
"nodemailer": "^6.9.3",
"pg": "^8.11.3",
"pino": "^8.15.1",
"pino-pretty": "^10.0.0",
"qrcode": "^1.5.3",
"semver": "~7.5.4",
"sequelize": "^6.33.0",
"sqlite3": "^5.1.6",
"stripe": "^8.222.0",
"uuid": "^9.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
"vuex": "^4.1.0",
"yaml": "^2.3.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/core": "^7.22.15",
"@babel/preset-env": "^7.22.15",
"@vitejs/plugin-vue": "^4.3.4",
"@vitest/coverage-istanbul": "^0.34.4",
"@vue/test-utils": "^2.4.1",
"autoprefixer": "^10.4.15",
"babel-loader": "^9.1.3",
"c8": "^8.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
"css-loader": "^6.8.1",
"cypress": "^13.2.0",
"dotenv-webpack": "^8.0.1",
"eslint": "^8.48.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-cypress": "2.15.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-n": "^16.0.2",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-vue": "^9.17.0",
"file-loader": "^6.2.0",
"html-link-extractor": "^1.0.5",
"html-webpack-plugin": "^5.5.3",
"jsdom": "^22.1.0",
"mini-css-extract-plugin": "^2.7.6",
"mocha": "^10.2.0",
"mocha-cli": "^1.0.1",
"mockdate": "^3.0.5",
"node-sass": "^9.0.0",
"nodemon": "^3.0.1",
"npm-run-all": "^4.1.5",
"nyc": "^15.1.0",
"postcss": "^8.4.29",
"postcss-loader": "^7.3.3",
"postcss-preset-env": "^9.1.3",
"promptly": "^3.2.0",
"sass-loader": "^13.3.2",
"should": "^13.2.3",
"should-sinon": "^0.0.6",
"sinon": "^16.0.0",
"style-loader": "^3.3.3",
"tailwindcss": "^2.2.19",
"vitest": "^0.34.4",
"vue-loader": "^17.2.2",
"vue-template-compiler": "^2.7.14",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"ws": "^8.14.0"
},
"engines": {
"node": ">=16.x"
},
"keywords": [
"low-code-platform",
"low-code-development",
"low-code-development-platform",
"visual-programming",
"flow-based-programming",
"no-code",
"low-code",
"node-red"
]
}

@@ -18,3 +18,3 @@ <div align="center"> <a href="https://flowfuse.com/">

* FlowFuse simplifies the software development lifecycle of Node-RED applications. You can now set up DevOps delivery pipelines to support development, test and production environments for Node-RED application delivery.
* FlowFuse is available from [FlowFuse Cloud](https://app.flowforge.com/account/create), a managed cloud service, or a self-hosted solution.
* FlowFuse is available from [FlowFuse Cloud](https://app.flowfuse.com/account/create), a managed cloud service, or a self-hosted solution.
* FlowFuse offers professional technical support for FlowFuse and Node-RED.

@@ -25,6 +25,6 @@

- [Home Page](https://flowfuse.com/)
- [Documentation](https://github.com/flowforge/flowforge/blob/main/docs/README.md)
- [Contributing](https://github.com/flowforge/flowforge/blob/main/CONTRIBUTING.md)
- [Code of Conduct](https://github.com/flowforge/flowforge/blob/main/CODE_OF_CONDUCT.md)
- [Security](https://github.com/flowforge/flowforge/blob/main/SECURITY.md)
- [License](https://github.com/flowforge/flowforge/blob/main/LICENSE)
- [Documentation](https://flowfuse.com/docs)
- [Contributing](https://flowfuse.com/docs/contribute/introduction/)
- [Code of Conduct](https://github.com/FlowFuse/flowfuse/blob/main/CODE_OF_CONDUCT.md)
- [Security](https://github.com/FlowFuse/flowfuse/blob/main/SECURITY.md)
- [License](https://github.com/FlowFuse/flowfuse/blob/main/LICENSE)

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc