From 4d41fa54fd08e42ff39a2e565ae3ccec24335281 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Fri, 31 Jul 2026 08:58:11 -0700 Subject: [PATCH 1/9] Publish application lifecycle events over the team channel --- forge/comms/aclManager.js | 6 +- forge/comms/index.js | 6 ++ forge/routes/api/application.js | 19 ++++++- test/unit/forge/comms/authRoutesV2_spec.js | 48 ++++++++++++++++ .../unit/forge/routes/api/application_spec.js | 56 +++++++++++++++++++ 5 files changed, 132 insertions(+), 3 deletions(-) diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index a7d91da6eb..6e23f60eca 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -491,6 +491,8 @@ module.exports = function (app) { { topic: /^ff\/v1\/[^/]+\/p\/[^/]+\/state$/ }, // - ff/v1//d//state { topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/state$/ }, + // - ff/v1//a//created|updated|deleted + { topic: /^ff\/v1\/[^/]+\/a\/[^/]+\/(created|updated|deleted)$/ }, // ff/v1/platform/sync { topic: /^ff\/v1\/platform\/sync$/ }, // ff/v1/platform/leader @@ -560,7 +562,9 @@ module.exports = function (app) { // - ff/v1//p/+/state { topic: /^ff\/v1\/([^/]+)\/p\/([^/]+)\/state$/, verify: 'checkTeamStateSub' }, // - ff/v1//d/+/state - { topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/state$/, verify: 'checkTeamStateSub' } + { topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/state$/, verify: 'checkTeamStateSub' }, + // - ff/v1//a/+/created|updated|deleted + { topic: /^ff\/v1\/([^/]+)\/a\/([^/]+)\/(created|updated|deleted)$/, verify: 'checkTeamStateSub' } ], pub: [] }, diff --git a/forge/comms/index.js b/forge/comms/index.js index 00dc08ce1b..fed0e78bf2 100644 --- a/forge/comms/index.js +++ b/forge/comms/index.js @@ -92,6 +92,12 @@ module.exports = fp(async function (app, _opts) { const meta = { state } if (versions) meta.versions = versions client.publish(`ff/v1/${teamHash}/p/${id}/state`, JSON.stringify({ id, meta })) + }, + notifyEntityLifecycle: function (teamHash, type, id, action, data) { + if (!teamHash || !type || !id || !action) return + const msg = { id, action } + if (data !== undefined) msg.data = data + client.publish(`ff/v1/${teamHash}/${type}/${id}/${action}`, JSON.stringify(msg)) } } }) diff --git a/forge/routes/api/application.js b/forge/routes/api/application.js index ca7063f853..ec730896bd 100644 --- a/forge/routes/api/application.js +++ b/forge/routes/api/application.js @@ -78,7 +78,10 @@ module.exports = async function (app) { await app.auditLog.Team.application.created(request.session.User, null, team, application) await app.auditLog.Application.application.created(request.session.User, null, application) - reply.send(app.db.views.Application.application(application)) + const applicationView = app.db.views.Application.application(application) + app.comms?.team?.notifyEntityLifecycle(team.hashid, 'a', application.hashid, 'created', applicationView) + + reply.send(applicationView) }) /** @@ -178,7 +181,12 @@ module.exports = async function (app) { } await app.auditLog.Application.application.updated(request.session.User, null, request.application, updates) - reply.send(app.db.views.Application.application(request.application)) + const applicationView = app.db.views.Application.application(request.application) + if (team) { + app.comms?.team?.notifyEntityLifecycle(team.hashid, 'a', request.application.hashid, 'updated', applicationView) + } + + reply.send(applicationView) }) /** @@ -217,9 +225,16 @@ module.exports = async function (app) { return reply.code(422).send({ code: 'invalid_application', error: 'Please delete the instances within the application first' }) } + const teamHash = request.application.Team?.hashid + const applicationHash = request.application.hashid + await request.application.destroy() await app.auditLog.Team.application.deleted(request.session.User, null, request.application.Team, request.application) + if (teamHash) { + app.comms?.team?.notifyEntityLifecycle(teamHash, 'a', applicationHash, 'deleted') + } + reply.send({ status: 'okay' }) } catch (err) { reply.code(500).send({ code: 'unexpected_error', error: err.toString() }) diff --git a/test/unit/forge/comms/authRoutesV2_spec.js b/test/unit/forge/comms/authRoutesV2_spec.js index 5877bff938..9f9b8e2d39 100644 --- a/test/unit/forge/comms/authRoutesV2_spec.js +++ b/test/unit/forge/comms/authRoutesV2_spec.js @@ -1462,6 +1462,54 @@ describe('Broker Auth v2 API', async function () { topic: `ff/v1/${TestObjects.ATeam.hashid}/p/+/state` }) }) + it('allows a team member to subscribe to the application lifecycle wildcards', async function () { + await allowRead({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/+/created` + }) + await allowRead({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/+/updated` + }) + await allowRead({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/+/deleted` + }) + }) + it('denies subscribe to another team\'s application lifecycle wildcard', async function () { + await denyRead({ + username: teamFrontendUsername, + topic: `ff/v1/${otherTeam.hashid}/a/+/created` + }) + }) + it('denies application lifecycle subscribe for a user who is not a member of the team', async function () { + const erin = await factory.createUser({ username: 'erin', name: 'Erin', email: 'erin@example.com', password: 'eePassword1!' }) + const erinUsername = `fe-team:${erin.hashid}:${TestObjects.ATeam.hashid}:session-1234567890` + await denyRead({ + username: erinUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/+/created` + }) + }) + it('denies fe-team from publishing application lifecycle topics (read-only client)', async function () { + await denyWrite({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/+/created` + }) + }) + it('allows forge_platform to publish application lifecycle topics', async function () { + await allowWrite({ + username: 'forge_platform', + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/an-application/created` + }) + await allowWrite({ + username: 'forge_platform', + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/an-application/updated` + }) + await allowWrite({ + username: 'forge_platform', + topic: `ff/v1/${TestObjects.ATeam.hashid}/a/an-application/deleted` + }) + }) it('denies fe-team from publishing to state (read-only client)', async function () { await denyWrite({ username: teamFrontendUsername, diff --git a/test/unit/forge/routes/api/application_spec.js b/test/unit/forge/routes/api/application_spec.js index 6ae0cbf0fa..19d2e9155a 100644 --- a/test/unit/forge/routes/api/application_spec.js +++ b/test/unit/forge/routes/api/application_spec.js @@ -434,6 +434,62 @@ describe('Application API', function () { }) }) + describe('Lifecycle publishing', async function () { + let notifySpy + + beforeEach(function () { + notifySpy = sinon.spy(app.comms.team, 'notifyEntityLifecycle') + }) + + afterEach(function () { + notifySpy.restore() + }) + + it('publishes created on create', async function () { + const sid = await login('bob', 'bbPassword') + const response = await app.inject({ + method: 'POST', + url: '/api/v1/applications', + cookies: { sid }, + payload: { name: generateName('lifecycle-app'), teamId: TestObjects.BTeam.hashid } + }) + response.statusCode.should.equal(200) + const result = response.json() + + notifySpy.calledWith(TestObjects.BTeam.hashid, 'a', result.id, 'created').should.be.true() + const data = notifySpy.getCall(0).args[4] + data.should.have.property('id', result.id) + }) + + it('publishes updated on update', async function () { + const sid = await login('bob', 'bbPassword') + const application = await app.factory.createApplication({ name: generateName('lifecycle-app') }, TestObjects.BTeam) + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/applications/${application.hashid}`, + cookies: { sid }, + payload: { name: 'Renamed' } + }) + response.statusCode.should.equal(200) + + notifySpy.calledWith(TestObjects.BTeam.hashid, 'a', application.hashid, 'updated').should.be.true() + }) + + it('publishes deleted on delete without a data payload', async function () { + const sid = await login('bob', 'bbPassword') + const application = await app.factory.createApplication({ name: generateName('lifecycle-app') }, TestObjects.BTeam) + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/applications/${application.hashid}`, + cookies: { sid } + }) + response.statusCode.should.equal(200) + + notifySpy.calledWith(TestObjects.BTeam.hashid, 'a', application.hashid, 'deleted').should.be.true() + should(notifySpy.getCall(0).args[4]).be.undefined() + }) + }) + describe('List instances', async function () { it('Returns application instances - empty list', async function () { const sid = await login('bob', 'bbPassword') From 8469acbf07d920b968a0fa8c38d2fae336e6d1f7 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Fri, 31 Jul 2026 11:41:05 -0700 Subject: [PATCH 2/9] Centralize team applications in a data-farm store, migrate the list + mutations --- frontend/src/components/SkeletonBlock.vue | 29 ++++ .../device/MultiStepDeviceForm.vue | 7 +- .../MultiStepApplicationsInstanceForm.vue | 7 +- frontend/src/mixins/Application.js | 4 +- .../pages/application/Settings/General.vue | 9 +- .../components/ApplicationsListSkeleton.vue | 77 ++++++++ .../src/pages/team/Applications/index.vue | 59 +++---- frontend/src/stores/account-auth.js | 2 + frontend/src/stores/account.js | 2 + frontend/src/stores/data-farm-applications.ts | 96 ++++++++++ .../stores/data-farm-applications.spec.js | 164 ++++++++++++++++++ 11 files changed, 409 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/SkeletonBlock.vue create mode 100644 frontend/src/pages/team/Applications/components/ApplicationsListSkeleton.vue create mode 100644 frontend/src/stores/data-farm-applications.ts create mode 100644 test/unit/frontend/stores/data-farm-applications.spec.js diff --git a/frontend/src/components/SkeletonBlock.vue b/frontend/src/components/SkeletonBlock.vue new file mode 100644 index 0000000000..89ab8d7c04 --- /dev/null +++ b/frontend/src/components/SkeletonBlock.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue b/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue index 949822c8f3..89d694b62a 100644 --- a/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue +++ b/frontend/src/components/multi-step-forms/device/MultiStepDeviceForm.vue @@ -17,9 +17,8 @@ + + diff --git a/frontend/src/pages/team/Applications/index.vue b/frontend/src/pages/team/Applications/index.vue index d98e84c02c..61e1dba3a9 100644 --- a/frontend/src/pages/team/Applications/index.vue +++ b/frontend/src/pages/team/Applications/index.vue @@ -33,9 +33,9 @@
- + -