Skip to content

feat(session): expose POST /sessions/:id/logout to unlink the device - #984

Merged
rmyndharis merged 3 commits into
rmyndharis:mainfrom
elwizard33:feat/session-logout-endpoint
Jul 29, 2026
Merged

feat(session): expose POST /sessions/:id/logout to unlink the device#984
rmyndharis merged 3 commits into
rmyndharis:mainfrom
elwizard33:feat/session-logout-endpoint

Conversation

@elwizard33

Copy link
Copy Markdown
Contributor

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: stop disconnects, 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. 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:

Layer State
IWhatsAppEngine logout(): Promise<void> declared
engine-capability-matrix.ts logout: { wwjs: supported, baileys: supported }
whatsapp-web-js.adapter.ts implemented — WAWebSocketModel.Socket.logout()
baileys.adapter.ts implemented — <remove-companion-device reason="user_initiated"/>
session.service.ts
session.controller.ts

Both adapters do a genuine protocol-level unlink, so this is wiring rather than new functionality. docs/17-dashboard-design.md also 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) 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 can't happen after destroy()/forceDestroy().

  • 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. The action column is a varchar, so no migration.

  • Extended the teardownEngineSafely label union with 'logout'.

Tests

Three service tests:

  • calls engine.logout() and not disconnect() — that's the whole distinction
  • still completes when logout() rejects (a socket that's already gone shouldn't wedge the call)
  • safe no-op when no engine is loaded (session already stopped)

tsc, eslint and the full session + audit suites (422 tests) pass locally.

Notes

  • Purely additive — stop, delete and force-kill semantics are untouched, so anything relying on "delete keeps the pairing" is unaffected.
  • Happy to add the dashboard button in this PR or a follow-up, and to rename the audit action if you'd prefer something else.

elwizard33 and others added 2 commits July 28, 2026 23:56
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.
@rmyndharis

Copy link
Copy Markdown
Owner

Hi @elwizard33 — nice work on this. The implementation is solid: logout() mirrors stop()'s lifecycle correctly, and I confirmed both adapters genuinely unlink at the protocol level (wwjs Socket.logout(), Baileys remove-companion-device), so this is real wiring rather than a best-effort approximation. The three tests cover the right distinctions.

I pushed two small repo-hygiene follow-ups directly to this branch so we don't bounce it back and forth:

  1. Regenerated openapi.json — the repo keeps a generated snapshot in sync with the Swagger decorators, and npm run openapi:check fails when they drift. The new /sessions/{id}/logout path is now captured (operationId, 200/404, SessionResponseDto).
  2. CHANGELOG.md entry under [Unreleased] → Added, following the repo's convention (CONTRIBUTING.md requires it for new endpoints). I modeled it on your PR description — feel free to adjust the wording if you'd phrase it differently.

tsc --noEmit, eslint, and the full session + audit suites (260 tests) pass on this branch. Whenever CI goes green this is good to merge from my side — I'd just ask that on squash-merge the trailer lines stay clean.

One thing worth your awareness, not a blocker: this removes an invariant we'd been relying on in the recent LOGOUT investigation (#982) — that engine.logout() has no caller, used as forensic evidence that the gateway can't initiate a logout. After this merges that becomes "logout is only reachable via an explicit OPERATOR API call, recorded as SESSION_LOGGED_OUT in the audit log", which is actually a better invariant for that same forensic question. Just flagging it so it's a conscious change rather than an implicit one.

@rmyndharis

Copy link
Copy Markdown
Owner

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
loaded, and that path is correct. I had not traced the path where one isn't, and that turns out to
be the case this endpoint most needs to get right. Sorry for the extra round trip.

The blocker: logging out a stopped session reports success without sending anything

session.service.ts:2163-2167 wraps the entire unlink in if (engine), while the tail at
:2169-2174 runs unconditionally:

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);

stop() removes the engine from the map on every teardown, so a stopped session deterministically
reaches the if (engine) false branch. Nothing is sent to WhatsApp; the on-disk credentials are
untouched, because the wipe lives inside the engines (baileys.adapter.ts:569 clearAuthState(),
and client.logout()LocalAuth.logout() on the whatsapp-web.js side) rather than in the
service — logout() has no purgeSessionData() call the way delete() does. The controller at
session.controller.ts:169-176 then returns 200 and writes a SESSION_LOGGED_OUT audit row, and a
later POST /sessions/:id/start reconnects with no QR.

That matters more than a normal edge case because it is the exact journey your PR description
opens with: the operator disconnects, notices the device is still under Linked Devices, and calls
logout. They get a 200, an audit row asserting an unlink, and a device that is still linked. It
also undercuts the audit action itself, which the PR adds specifically so an intentional unlink is
distinguishable from a plain stop — those rows can't carry that meaning if they're written on a
path where nothing was sent.

The fix matches what the rest of the file already does. Eight engine-requiring methods immediately
below logout()getQRCode, requestPairingCode, getGroups, getChats, sendSeen,
markUnread, deleteChat, sendChatState — open with:

const engine = this.engines.get(id);
if (!engine) {
  throw new BadRequestException('Session is not started. Call POST /sessions/:id/start first.');
}

logout() is the only one that doesn't. Adopting it needs three things: the guard, an
@ApiResponse({ status: 400, description: 'Session is not started' }) on the route, and the third
test flipped to assert the rejection instead of the silent success. I'll regenerate openapi.json
again afterwards so you don't have to.

Worth noting about that third test as it stands: await expect(service.logout(id)).resolves.toBeDefined()
passes for any implementation, including return this.findOne(id) with the body deleted, since
findOne is mocked to always resolve. Asserting the 400 gives it something real to hold.

Description wording

The @ApiOperation text (and the copy of it I put in openapi.json) says reconnecting after a
logout always requires a fresh QR scan or pairing code. That's true when the unlink actually
happens, but not on the path above. Once the guard is in, "always" becomes accurate for every case
the endpoint accepts — so this resolves itself, but it's worth a re-read to confirm the wording
still matches the behaviour. I'll align the CHANGELOG entry to whatever we land on.

Not your problem — I'll handle these

  • docs/06-api-specification.md has one section per session route and is currently exhaustive at
    16/16; this endpoint would be the first gap. Same for the AuditAction enumeration in
    docs/05-database-design.md, the curl collection in docs/07-api-collection.md, and the
    deliberate-omission list in docs/24.
  • The five SDKs and the dashboard's API client expose every sibling lifecycle operation and would
    now lag the REST API by one. That's a mechanical follow-up on my side.

Two follow-ups I'm filing separately

Both predate this PR in origin and shouldn't hold it up, but they're worth recording because
logout() is the first caller that makes them consequential:

  1. teardownEngineSafely returns whether the teardown completed, and its own doc comment says a
    caller with an operator-facing outcome must surface that. logout() discards it — though the
    boolean wouldn't help yet, because both adapters catch their failure and resolve, so it reads
    true on the realistic failure paths. The two engines then fail differently and both silently:
    whatsapp-web.js keeps the credentials and stays linked, while Baileys wipes the local
    credentials at baileys.adapter.ts:569 but stays linked, leaving an entry in Linked Devices
    with no local way to clear it. Making failure observable needs a change in the adapters first.

  2. teardownEngineSafely races the teardown against a 10s deadline but doesn't cancel the losing
    promise. For logout that promise ends in LocalAuth.logout()'s fs.rm of
    <dataPath>/session-<id> — the same deterministic path a subsequent start() re-creates. A
    pupBrowser.close() that wedges past the deadline can therefore land that rm on a freshly
    re-paired profile. stop() and force-kill() share the deadline but never touch disk, so this
    only becomes reachable with logout in the mix.

CI

Nothing was wrong with your branch — the runs are held by the repo's first-time-contributor
approval policy, so they never started. I've approved them; you should see 15 checks (8 from CI, 7
from SDK CI, which your changes to session.controller.ts and session.service.ts match on).
That was on me to notice sooner.

The branch is three commits behind main but merges cleanly — CHANGELOG is the only shared file and
the two insertions don't overlap — so no rebase needed. On squash merge you stay the author of the
resulting commit, so the attribution is safe.

Thanks for the care in the original write-up — the capability table and the reasoning about why
logout has to run before teardown made this quick to review.

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.
@rmyndharis

Copy link
Copy Markdown
Owner

Fix pushed directly to this branch as 3897d53, applying the guard from my earlier comment:

  • session.service.logout() now opens with the same guard as the eight other engine-requiring methods: if (!engine) throw new BadRequestException('Session is not started. Call POST /sessions/:id/start first.'). It runs right after findOne(id), before the stop-mark and reconnect cancel, so a rejected request leaves no teardown side effects behind — no 200, no SESSION_LOGGED_OUT audit row, for an unlink that never happened.
  • Route documents @ApiResponse({ status: 400, description: 'Session is not started' }), and openapi.json is regenerated so npm run openapi:check stays green.
  • Third test flipped to assert the rejection (rejects.toBeInstanceOf(BadRequestException)) and that no stop-mark leaks on the 400 path, instead of the previous resolves.toBeDefined() that passed for any implementation.
  • CHANGELOG entry now notes the started-session requirement and how it differs from stop()'s successful no-op.

With the guard in place, the @ApiOperation wording — reconnecting after a logout always requires a fresh QR scan or pairing code — is now accurate for every case the endpoint accepts, so I left the description as written.

npm run build, eslint, prettier --check, and the full session + audit suites (306 tests) pass locally. Once CI is green this is good to merge from my side; the two follow-ups from my earlier comment stay tracked separately and don't hold this up.

Thanks again — the original implementation was right on the path it covered; this just closes the one journey your own PR description opens with.

@rmyndharis

Copy link
Copy Markdown
Owner

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.

rmyndharis added a commit that referenced this pull request Jul 29, 2026
… 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.
@rmyndharis

Copy link
Copy Markdown
Owner

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants