From 78c4d03f5360daf114a92e57897317c215d5902b Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Thu, 6 Aug 2026 13:06:05 -0700 Subject: [PATCH] Publish instance lifecycle events from the create/update/delete routes --- forge/comms/aclManager.js | 6 ++- forge/routes/api/project.js | 11 ++++ test/unit/forge/comms/authRoutesV2_spec.js | 40 ++++++++++++++ test/unit/forge/routes/api/project_spec.js | 61 ++++++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index 6e23f60eca..2ca38dc37c 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -493,6 +493,8 @@ module.exports = function (app) { { topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/state$/ }, // - ff/v1//a//created|updated|deleted { topic: /^ff\/v1\/[^/]+\/a\/[^/]+\/(created|updated|deleted)$/ }, + // - ff/v1//p//created|updated|deleted + { topic: /^ff\/v1\/[^/]+\/p\/[^/]+\/(created|updated|deleted)$/ }, // ff/v1/platform/sync { topic: /^ff\/v1\/platform\/sync$/ }, // ff/v1/platform/leader @@ -564,7 +566,9 @@ module.exports = function (app) { // - ff/v1//d/+/state { topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/state$/, verify: 'checkTeamStateSub' }, // - ff/v1//a/+/created|updated|deleted - { topic: /^ff\/v1\/([^/]+)\/a\/([^/]+)\/(created|updated|deleted)$/, verify: 'checkTeamStateSub' } + { topic: /^ff\/v1\/([^/]+)\/a\/([^/]+)\/(created|updated|deleted)$/, verify: 'checkTeamStateSub' }, + // - ff/v1//p/+/created|updated|deleted + { topic: /^ff\/v1\/([^/]+)\/p\/([^/]+)\/(created|updated|deleted)$/, verify: 'checkTeamStateSub' } ], pub: [] }, diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index c88028e1e3..a4acd14bbe 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -233,6 +233,8 @@ module.exports = async function (app) { const projectViewPromise = app.db.views.Project.project(project) const projectStatePromise = project.liveState() + app.comms?.team?.notifyEntityLifecycle(team.hashid, 'p', project.id, 'created', await app.db.views.Project.project(project, { includeSettings: false })) + reply.send({ ...await projectViewPromise, ...await projectStatePromise }) }) /** @@ -281,9 +283,16 @@ module.exports = async function (app) { }) } + const teamHash = request.project.Team?.hashid + const instanceId = request.project.id + await request.project.destroy() await app.auditLog.Team.project.deleted(request.session.User, null, request.project.Team, request.project) await app.auditLog.Project.project.deleted(request.session.User, null, request.project.Team, request.project) + + if (teamHash) { + app.comms?.team?.notifyEntityLifecycle(teamHash, 'p', instanceId, 'deleted') + } reply.send({ status: 'okay' }) } catch (err) { reply.code(500).send({ code: 'unexpected_error', error: err.toString() }) @@ -604,6 +613,8 @@ module.exports = async function (app) { if (changesToProjectDefinition) { await unSuspendProject(resumeProject, targetState) } + + app.comms?.team?.notifyEntityLifecycle(request.project.Team.hashid, 'p', request.project.id, 'updated', await app.db.views.Project.project(request.project, { includeSettings: false })) } catch (error) { app.log.error('Error while updating project:') app.log.error(error) diff --git a/test/unit/forge/comms/authRoutesV2_spec.js b/test/unit/forge/comms/authRoutesV2_spec.js index 9f9b8e2d39..ba2bd972cb 100644 --- a/test/unit/forge/comms/authRoutesV2_spec.js +++ b/test/unit/forge/comms/authRoutesV2_spec.js @@ -1510,6 +1510,46 @@ describe('Broker Auth v2 API', async function () { topic: `ff/v1/${TestObjects.ATeam.hashid}/a/an-application/deleted` }) }) + it('allows a team member to subscribe to the instance lifecycle wildcards', async function () { + await allowRead({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/p/+/created` + }) + await allowRead({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/p/+/updated` + }) + await allowRead({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/p/+/deleted` + }) + }) + it('denies subscribe to another team\'s instance lifecycle wildcard', async function () { + await denyRead({ + username: teamFrontendUsername, + topic: `ff/v1/${otherTeam.hashid}/p/+/created` + }) + }) + it('denies fe-team from publishing instance lifecycle topics (read-only client)', async function () { + await denyWrite({ + username: teamFrontendUsername, + topic: `ff/v1/${TestObjects.ATeam.hashid}/p/+/created` + }) + }) + it('allows forge_platform to publish instance lifecycle topics', async function () { + await allowWrite({ + username: 'forge_platform', + topic: `ff/v1/${TestObjects.ATeam.hashid}/p/an-instance/created` + }) + await allowWrite({ + username: 'forge_platform', + topic: `ff/v1/${TestObjects.ATeam.hashid}/p/an-instance/updated` + }) + await allowWrite({ + username: 'forge_platform', + topic: `ff/v1/${TestObjects.ATeam.hashid}/p/an-instance/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/project_spec.js b/test/unit/forge/routes/api/project_spec.js index dda3340b32..92e8aa6701 100644 --- a/test/unit/forge/routes/api/project_spec.js +++ b/test/unit/forge/routes/api/project_spec.js @@ -1244,6 +1244,67 @@ describe('Project API', function () { }) }) + describe('Lifecycle publishing', function () { + let notifySpy + + beforeEach(function () { + notifySpy = sinon.spy(app.comms.team, 'notifyEntityLifecycle') + }) + + afterEach(function () { + notifySpy.restore() + }) + + it('publishes created on create', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/projects', + payload: { + name: generateProjectName(), + applicationId: TestObjects.ApplicationA.hashid, + projectType: TestObjects.projectType1.hashid, + template: TestObjects.template1.hashid, + stack: TestObjects.stack1.hashid + }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + const result = response.json() + + notifySpy.calledWith(TestObjects.ATeam.hashid, 'p', result.id, 'created').should.be.true() + const data = notifySpy.getCall(0).args[4] + data.should.have.property('id', result.id) + data.should.not.have.property('settings') + }) + + it('publishes updated on a synchronous update without leaking settings', async function () { + const project = await createInstance() + const response = await app.inject({ + method: 'PUT', + url: `/api/v1/projects/${project.id}`, + payload: { launcherSettings: { disableAutoSafeMode: true } }, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + + notifySpy.calledWith(TestObjects.ATeam.hashid, 'p', project.id, 'updated').should.be.true() + notifySpy.getCall(0).args[4].should.not.have.property('settings') + }) + + it('publishes deleted on delete without a data payload', async function () { + const project = await createInstance({ start: true }) + const response = await app.inject({ + method: 'DELETE', + url: `/api/v1/projects/${project.id}`, + cookies: { sid: TestObjects.tokens.alice } + }) + response.statusCode.should.equal(200) + + notifySpy.calledWith(TestObjects.ATeam.hashid, 'p', project.id, 'deleted').should.be.true() + should(notifySpy.getCall(0).args[4]).be.undefined() + }) + }) + describe('Update Project', function () { describe('Change project type', function () { it('Changes the type, stack, and restores the project to original state', async function () {