feat(session): expose POST /sessions/:id/logout to unlink the device - #984
Conversation
Both engines already implement a real WhatsApp unlink, and the engine interface and capability matrix both declare it, but nothing ever calls it — there is no service method and no route, so logout() is unreachable over the API. The practical effect is that there is currently no way to unlink a number from the account: stop() disconnects and delete() additionally purges the on-disk auth dirs, but neither tells WhatsApp anything, so the device stays in the account holder's Linked Devices list until they remove it by hand on the phone. This wires the existing capability up: - session.service.logout(id) mirrors stop()'s lifecycle exactly — stop-mark before teardown, cancelReconnect, bounded/isolated teardownEngineSafely, map reconciliation, status update — but calls engine.logout() instead of engine.disconnect(). It has to run while the engine is still live, since logout is a network round-trip to WhatsApp and cannot happen after destroy(). - POST /sessions/:id/logout, OPERATOR role, mirroring the stop route. - SESSION_LOGGED_OUT audit action, so an intentional unlink is distinguishable from a plain stop in the audit trail. The action column is a varchar, so no migration is needed. - Extended the teardownEngineSafely label union with 'logout'. Three service tests cover it: that it calls engine.logout() and not disconnect(), that it still completes when logout() rejects (a socket that is already gone must not wedge the call), and that it is a safe no-op when no engine is loaded. tsc, eslint and the full session + audit suites (422 tests) pass.
|
Hi @elwizard33 — nice work on this. The implementation is solid: I pushed two small repo-hygiene follow-ups directly to this branch so we don't bounce it back and forth:
One thing worth your awareness, not a blocker: this removes an invariant we'd been relying on in the recent |
|
Following up on my earlier comment — I need to walk part of it back. When I said this was good to merge once CI went green, I had traced the path where an engine is The blocker: logging out a stopped session reports success without sending anything
const engine = this.engines.get(id);
if (engine) {
await this.teardownEngineSafely(id, engine, e => e.logout(), 'logout');
if (this.isLiveEngine(id, engine)) this.engines.delete(id);
}
this.logger.log(`Session logged out: ${session.name}`, { sessionId: id, action: 'logout' });
await this.updateStatus(id, SessionStatus.DISCONNECTED);
return this.findOne(id);
That matters more than a normal edge case because it is the exact journey your PR description The fix matches what the rest of the file already does. Eight engine-requiring methods immediately const engine = this.engines.get(id);
if (!engine) {
throw new BadRequestException('Session is not started. Call POST /sessions/:id/start first.');
}
Worth noting about that third test as it stands: Description wordingThe Not your problem — I'll handle these
Two follow-ups I'm filing separatelyBoth predate this PR in origin and shouldn't hold it up, but they're worth recording because
CINothing was wrong with your branch — the runs are held by the repo's first-time-contributor The branch is three commits behind main but merges cleanly — CHANGELOG is the only shared file and Thanks for the care in the original write-up — the capability table and the reasoning about why |
With no engine loaded there is nothing to send the unlink through; reporting success recorded a SESSION_LOGGED_OUT audit row for an unlink that never happened and left the on-disk credentials in place, so a later start() reconnected with no QR. Match the other engine-requiring methods: throw BadRequestException before any teardown side effects, document the 400 on the route, and flip the no-engine test to assert the rejection.
|
Fix pushed directly to this branch as 3897d53, applying the guard from my earlier comment:
With the guard in place, the
Thanks again — the original implementation was right on the path it covered; this just closes the one journey your own PR description opens with. |
|
Merged — thanks @elwizard33! The two follow-ups from my review are now filed: #993 (adapter logout failures are not observable; wwjs keeps the link, Baileys wipes local credentials while staying linked) and #994 (the 10s teardown race can delete a freshly re-paired profile). Neither blocks this change; they're next in the queue. The docs/SDK sync I mentioned is on my side as a mechanical follow-up. |
… SDKs (#995) Follow-up to #984. POST /sessions/:id/logout was reachable over REST but absent from the hand-written reference docs and from every client. Adds the route to docs/06 (with the 400 for a session that is not running), docs/07, the AuditAction enumeration in docs/05, the omission list in docs/24, and the method tables in docs/18 and sdk/README.md; adds a logout method mirroring forceKill to the Go, Java, JavaScript, PHP, and Python SDKs plus a routing test per language; adds sessionApi.logout(id) to the dashboard client; records the client-side additions in the CHANGELOG.
|
Closing the loop on this: the follow-ups around your endpoint are now all in.
Thanks again for the clean write-up and for offering the follow-ups — the capability table in your description made all of this much easier to review. |
What this adds
POST /sessions/:id/logout— logs out of WhatsApp and unlinks the device, then tears the session down.Why
I hit this building an integration on top of OpenWA. There's currently no way to unlink a number from the API:
stopdisconnects,deleteadditionally purges the on-disk auth dirs, but neither tells WhatsApp anything — so the device stays in the account holder's Linked Devices list until they remove it by hand on the phone. From a user's point of view they clicked "disconnect" and it's still there, which is a confusing place to leave them.The capability is already fully present, just unreachable:
IWhatsAppEnginelogout(): Promise<void>declaredengine-capability-matrix.tslogout: { wwjs: supported, baileys: supported }whatsapp-web-js.adapter.tsWAWebSocketModel.Socket.logout()baileys.adapter.ts<remove-companion-device reason="user_initiated"/>session.service.tssession.controller.tsBoth adapters do a genuine protocol-level unlink, so this is wiring rather than new functionality.
docs/17-dashboard-design.mdalso already mocks up a[Logout]button next to Restart and Delete, so I think this was the intent anyway.The change
session.service.logout(id)mirrorsstop()'s lifecycle exactly — stop-mark before teardown,cancelReconnect, bounded/isolatedteardownEngineSafely, map reconciliation, status update — but callsengine.logout()instead ofengine.disconnect().It has to run while the engine is still live, since logout is a network round-trip to WhatsApp and can't happen after
destroy()/forceDestroy().POST /sessions/:id/logout,OPERATORrole, mirroring the stop route.SESSION_LOGGED_OUTaudit action, so an intentional unlink is distinguishable from a plain stop. Theactioncolumn is avarchar, so no migration.Extended the
teardownEngineSafelylabel union with'logout'.Tests
Three service tests:
engine.logout()and notdisconnect()— that's the whole distinctionlogout()rejects (a socket that's already gone shouldn't wedge the call)tsc,eslintand the full session + audit suites (422 tests) pass locally.Notes
stop,deleteandforce-killsemantics are untouched, so anything relying on "delete keeps the pairing" is unaffected.