diff --git a/docs/user/assistant.md b/docs/user/assistant.md index 2e87c067a6..7fd20dcc07 100644 --- a/docs/user/assistant.md +++ b/docs/user/assistant.md @@ -49,3 +49,10 @@ to generate code directly within the editor. This is useful when you want to quickly add code to an existing function node without having to generate a full function node from scratch. +### In-line JSON Generation + +The FlowFuse Assistant plugin also adds a new code lens to the JSON editor that allows you +to generate JSON directly within the monaco editor. + +This is useful when you want to quickly generate JSON in a template node, change node, inject node or +any node that the TypedInput offers the JSON editor. diff --git a/forge/lib/permissions.js b/forge/lib/permissions.js index a431db417b..f49d9a6251 100644 --- a/forge/lib/permissions.js +++ b/forge/lib/permissions.js @@ -112,7 +112,7 @@ const Permissions = { 'platform:audit-log': { description: 'View platform audit log', role: Roles.Admin }, // assistant - 'assistant:function': { description: 'Access the assistant function endpoint', role: Roles.Member } + 'assistant:method': { description: 'Access the assistant method endpoint', role: Roles.Member } } module.exports = { diff --git a/forge/routes/api/assistant.js b/forge/routes/api/assistant.js index 04200ad02a..658159cac0 100644 --- a/forge/routes/api/assistant.js +++ b/forge/routes/api/assistant.js @@ -12,13 +12,13 @@ module.exports = async function (app) { app.addHook('preHandler', app.verifySession) /** - * Endpoint for assistant functions + * Endpoint for assistant methods * For now, this is simply a relay to an external assistant service * In the future, we may decide to bring that service inside the core or * use an alternative means of accessing it. */ - app.post('/function', { - preHandler: app.needsPermission('assistant:function'), + app.post('/:method', { + preHandler: app.needsPermission('assistant:method'), schema: { hide: true, // dont show in swagger body: { @@ -45,8 +45,10 @@ module.exports = async function (app) { } }, async (request, reply) => { - // const method = request.params.method // FUTURE: allow for different methods - const method = 'function' // for now, only function node/code generation is supported + const method = request.params.method // the method to call at the assistant service + if (/^[a-z0-9_-]+$/.test(method) === false) { + return reply.code(400).send({ code: 'invalid_method', error: 'Invalid method name' }) + } const serviceUrl = app.config.assistant?.service?.url const serviceToken = app.config.assistant?.service?.token diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index 7a463d47ab..7abf8e1b58 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -10,7 +10,7 @@ const IMPLICIT_TOKEN_SCOPES = { 'team:projects:list', // permit a device being edited via a tunnel in developer mode to list projects 'library:entry:create', // permit a device being edited via a tunnel in developer mode to create library entries 'library:entry:list', // permit a device being edited via a tunnel in developer mode to list library entries - 'assistant:function' // permit calls to the assistant endpoint for function node/code creation + 'assistant:method' // permit calls to the assistant endpoint for method node/code/json/etc creation ], project: [ 'user:read', @@ -19,7 +19,7 @@ const IMPLICIT_TOKEN_SCOPES = { 'team:projects:list', 'library:entry:create', 'library:entry:list', - 'assistant:function' + 'assistant:method' ] } diff --git a/test/unit/forge/routes/api/assistant_spec.js b/test/unit/forge/routes/api/assistant_spec.js index 3e715fb06b..8afd479020 100644 --- a/test/unit/forge/routes/api/assistant_spec.js +++ b/test/unit/forge/routes/api/assistant_spec.js @@ -92,73 +92,112 @@ describe('Assistant API', async function () { response.statusCode.should.equal(501) }) }) - describe('function service', async function () { - it('anonymous cannot access /function', async function () { - const response = await app.inject({ - method: 'GET', - url: '/api/v1/assistant/function' - }) - response.statusCode.should.equal(401) - }) - it('random token cannot access /function', async function () { - const response = await app.inject({ - method: 'GET', - url: '/api/v1/assistant/function', - headers: { authorization: 'Bearer blah-blah' } - }) - response.statusCode.should.equal(401) - }) - it('device token can access /function', async function () { - // const device = await createDevice({ name: 'Ad1', type: 'Ad1_type', team: TestObjects.ATeam.hashid, as: TestObjects.tokens.alice }) - const deviceCreateResponse = await app.inject({ - method: 'POST', - url: '/api/v1/devices', - body: { - name: 'Ad1', - type: 'Ad1_type', - team: TestObjects.ATeam.hashid - }, - cookies: { sid: TestObjects.tokens.alice } - }) - const device = deviceCreateResponse.json() - sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } }) + + describe('method constraints', async function () { + it('should return 400 if method contains capital letters', async function () { const response = await app.inject({ method: 'POST', - url: '/api/v1/assistant/function', - headers: { authorization: 'Bearer ' + device.credentials.token }, + url: '/api/v1/assistant/InvalidMethod', + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }, payload: { prompt: 'multiply by 5', transactionId: '1234' } }) - axios.post.calledOnce.should.be.true() - response.statusCode.should.equal(200) + response.statusCode.should.equal(400) }) - it('instance token can access /function', async function () { - sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } }) + it('should return 400 if method contains invalid characters', async function () { const response = await app.inject({ method: 'POST', - url: '/api/v1/assistant/function', + url: '/api/v1/assistant/inv@lid', headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }, payload: { prompt: 'multiply by 5', transactionId: '1234' } }) - axios.post.calledOnce.should.be.true() - response.statusCode.should.equal(200) + response.statusCode.should.equal(400) }) - it('fails when prompt is not supplied', async function () { + it('should return 400 if method contains escaped characters', async function () { const response = await app.inject({ method: 'POST', - url: '/api/v1/assistant/function', + url: '/api/v1/assistant/method%2Fwith%2Fslashes', headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }, - payload: { transactionId: '1234' } + payload: { prompt: 'multiply by 5', transactionId: '1234' } }) response.statusCode.should.equal(400) }) - it('fails when transactionId is not supplied', async function () { - const response = await app.inject({ - method: 'POST', - url: '/api/v1/assistant/function', - headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }, - payload: { prompt: 'multiply by 5' } + }) + + describe('service tests', async function () { + function serviceTests (serviceName) { + it('anonymous cannot access', async function () { + const response = await app.inject({ + method: 'GET', + url: `/api/v1/assistant/${serviceName}` + }) + response.statusCode.should.equal(401) }) - response.statusCode.should.equal(400) + it('random token cannot access', async function () { + const response = await app.inject({ + method: 'GET', + url: `/api/v1/assistant/${serviceName}`, + headers: { authorization: 'Bearer blah-blah' } + }) + response.statusCode.should.equal(401) + }) + it('device token can access', async function () { + // const device = await createDevice({ name: 'Ad1', type: 'Ad1_type', team: TestObjects.ATeam.hashid, as: TestObjects.tokens.alice }) + const deviceCreateResponse = await app.inject({ + method: 'POST', + url: '/api/v1/devices', + body: { + name: 'Ad1', + type: 'Ad1_type', + team: TestObjects.ATeam.hashid + }, + cookies: { sid: TestObjects.tokens.alice } + }) + const device = deviceCreateResponse.json() + sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } }) + const response = await app.inject({ + method: 'POST', + url: `/api/v1/assistant/${serviceName}`, + headers: { authorization: 'Bearer ' + device.credentials.token }, + payload: { prompt: 'multiply by 5', transactionId: '1234' } + }) + axios.post.calledOnce.should.be.true() + response.statusCode.should.equal(200) + }) + it('instance token can access', async function () { + sinon.stub(axios, 'post').resolves({ data: { status: 'ok' } }) + const response = await app.inject({ + method: 'POST', + url: `/api/v1/assistant/${serviceName}`, + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }, + payload: { prompt: 'multiply by 5', transactionId: '1234' } + }) + axios.post.calledOnce.should.be.true() + response.statusCode.should.equal(200) + }) + it('fails when prompt is not supplied', async function () { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/assistant/${serviceName}`, + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }, + payload: { transactionId: '1234' } + }) + response.statusCode.should.equal(400) + }) + it('fails when transactionId is not supplied', async function () { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/assistant/${serviceName}`, + headers: { authorization: 'Bearer ' + TestObjects.tokens.instance }, + payload: { prompt: 'multiply by 5' } + }) + response.statusCode.should.equal(400) + }) + } + describe('function service', async function () { + serviceTests('function') + }) + describe('json service', async function () { + serviceTests('json') }) }) })