Added
-
AuditActionemit-coverage gate. A structural test now fails the build when anAuditAction
enum value is neither emitted at a real call site nor registered (with a reason) in a new
intentionally-unemitted registry. A declared audit event can no longer silently exist with no
emission site, and a new action cannot land without either wiring it or documenting why it is held
back. The registry is also checked for stale entries (an action that is in fact emitted) and empty
reasons, so it cannot decay into a dumping ground. -
Operator-tunable HTTP server timeouts. The gateway now pins
requestTimeout,
headersTimeout, andkeepAliveTimeouton its HTTP server explicitly (previously Node's
implicit defaults), exposed viaREQUEST_TIMEOUT_MS/HEADERS_TIMEOUT_MS/
KEEPALIVE_TIMEOUT_MSand logged at boot. Defaults match Node 22 (300s / 65s / 5s);
headersTimeoutis normalized to stay abovekeepAliveTimeout(Node requires it), and the three
are validated as positive integers at boot. -
Committed OpenAPI snapshot + CI sync gate. The gateway's OpenAPI document is now committed as
openapi.json(generated from the NestJS Swagger decorators vianpm run openapi:export) and a CI
check (npm run openapi:check) fails when a controller/DTO change lands without regenerating it, so
the machine-readable contract can never silently drift from the code. SDK/API consumers now have a
versioned artifact at the repo root. -
Pre-release boot smoke on amd64 + arm64. Cutting a release tag now runs the just-published
image on bothlinux/amd64andlinux/arm64(via QEMU) and polls the dependency-free
/api/health/liveendpoint before the GitHub Release is created — so a runtime-only boot regression
on one architecture (a clean build can still produce a native-dep/Chromium SIGTRAP on arm64) cannot
ship under a release. The Release job waits on the new boot-smoke job. -
SBOM attestation on published images. Each image built by CI and on release now carries an
in-toto SBOM attestation alongside the SLSA provenance thatdocker/build-push-actionalready
generates by default. Both are verifiable with
docker buildx imagetools inspect ghcr.io/rmyndharis/openwa:<tag>. Provenance is now also pinned
explicitly (provenance: true) so the attestation pair is self-documenting rather than reliant on
the action default. -
HTTP RED metrics. The
/api/metricsendpoint now exposes
http_requests_total{method,route,status}and anhttp_request_duration_secondshistogram (per
route), recorded by a global interceptor. Route labels use the Express route pattern (bounded —
/api/sessions/:id, not the raw URL) with aController#handlerfallback, and/api/health+
/api/metricsare not counted. Conventional unprefixed names so a generic RED dashboard or a 5xx
error-rate alert matches them directly. -
Request correlation ids (
X-Request-ID). Every inbound request now carries an id that
propagates through the whole request via AsyncLocalStorage, so each JSON log line and each
audit-log metadata blob stamps it — a request can be traced end-to-end. A valid client-supplied
X-Request-ID(alphanumeric + dash, ≤128 chars) is echoed; anything else (including a CRLF
header-injection attempt) is replaced with a generated UUID. The id is also set on the response. -
Engine capability matrix + drift gate. A committed, source-verified matrix
(src/engine/engine-capability-matrix.ts) records, for everyIWhatsAppEnginemethod on each
engine (whatsapp-web.js default, Baileys), whether it issupportedornot-available— and for
the not-available ones, the root cause:adapter-gap(the underlying library supports it, OpenWA
just hasn't wired it — fixable) vslibrary-limitation(no first-class library API), with the
cited library symbol as evidence. A drift gate fails when a method's throw-availability changes.
docs/engine-capability-matrix.mdinventories the unwired adapter-gaps as a prioritized capability
backlog. -
Delete-for-me on the Baileys engine.
deleteMessage(…, forEveryone=false)now performs a
delete-for-me via Baileys'chatModify({ deleteForMe })instead of returning 501. Revoke-for-
everyone (forEveryone=true) was already wired; this completesdeleteMessageon the Baileys
engine for the most common delete mode. -
Status posts on the whatsapp-web.js engine.
postTextStatus,postImageStatus, and
postVideoStatusnow work on whatsapp-web.js (the default engine) — they route through
sendMessage('status@broadcast', …)(text styling viaextra: { backgroundColor, fontStyle };
media viaMessageMedia+caption). Previously these returned 501 on the default engine despite
the library supporting them; the stale "blocked upstream, #455" guard is removed (#455 is a closed
feature request, and whatsapp-web.js 1.34.7 ships a real status-send path). Caveat: the library has
no status-recipient arg, soStatusPostOptions.recipientsis not honored on this engine (it
broadcasts to the account's status-privacy audience; a one-time warning is logged). The Baileys
engine continues to honorrecipients. -
Chat labels on the Baileys engine.
addLabelToChatandremoveLabelFromChatnow work on
the Baileys engine — 1:1 tosock.addChatLabel(chatId, labelId)/sock.removeChatLabel(chatId, labelId)instead of returning 501. WhatsApp-Business-only (rejects on personal accounts). Label
listing (getLabels/getLabelById/getChatLabels) remains unavailable on Baileys (no
first-class library API — seedocs/engine-capability-matrix.md). -
Status delete on the whatsapp-web.js engine.
deleteStatus(statusId)now works on
whatsapp-web.js viaclient.revokeStatusMessage(statusId)instead of returning 501 — completing
the status lifecycle (post + delete) on the default engine. Own-status only (the library revokes
the caller's own status posts). -
Read contact stories on the whatsapp-web.js engine.
getContactStatuses()and
getContactStatus(contactId)now return contact "stories" (24h status posts) via
whatsapp-web.jsgetBroadcasts()/getBroadcastById()flattened toStatus[](contact via
broadcast.getContact(), type fromMessageTypes, 24h TTL) instead of stubbing to[]. The
Baileys engine still cannot read stories —fetchStatusreturns the about text, not stories
(documented as a library limitation). -
Channel lookup / subscribe / unsubscribe on the Baileys engine.
getChannelById(id),
subscribeToChannel(inviteCode), andunsubscribeFromChannel(id)now work via Baileys
newsletterMetadata(mapped toChannelwith optional fields),newsletterFollow(subscribe,
resolving invite→jid first), andnewsletterUnfollow(unsubscribe, 1:1).getChannelByIdon
Baileys resolves ANY channel by jid (richer than the whatsapp-web.js subscribed-list lookup).
getChannelMessagesremains unsupported —newsletterFetchMessagesreturns a raw BinaryNode with
no library parser, so it stays a documented gap rather than an unverified walk. -
Bounded webhook fan-out. An event matching N webhooks now delivers at most
WEBHOOK_DISPATCH_CONCURRENCY(default 16) concurrently instead of opening N outbound sockets at
once; the rest queue and run as slots free. Per-webhook isolation (Promise.allSettled) is
unchanged. The sharedConcurrencyLimiterwas also promoted fromengine/adaptersto
common/utils(it has no engine-specific logic), so the webhook module no longer imports across the
engine boundary. -
Optional Redis-backed rate-limit storage. When
REDIS_ENABLED=true, API rate-limit counters
now persist to Redis (a newRedisThrottlerStorageimplementing @nestjs/throttler v6's
ThrottlerStorage), so limits aggregate across replicas behind a load balancer instead of being
per-process. Default off (single-node deployments gain nothing and it adds a connection dependency).
Fail-OPEN on Redis error — rate limiting is a secondary control, and fail-closed would self-DoS the
API.
Fixed
-
Silent delivery failure for 1:1 Baileys sends to LID-migrated contacts (ack 463). On the
Baileys engine, a 1:1 send addressed by phone (<phone>@c.us/<phone>@s.whatsapp.net) to a
contact WhatsApp has migrated to LID addressing was rejected server-side with ack error 463
(NackCallerReachoutTimelocked/ "missing tctoken" — the privacy token is stored and honored
under the LID), while the same send addressed to the contact's<lid>@liddelivered. Because
Baileys generates the message id locally, the API still returned amessageId, so the
non-delivery was silent to the caller. The adapter now resolves phone-dialect 1:1 chat ids to
the contact's LID at the send boundary viasock.signalRepository.lidMapping.getLIDForPN(the
same mapping the Baileys send path consults), applied insendTextMessage,sendContent(all
media/location/contact/poll sends), andsendChatState. Groups, broadcast, already-@lid, and
unmapped ids pass through unchanged (non-migrated contacts behave identically), and resolution
is best-effort: any lookup error falls back to the phone jid. The disappearing-timer lookup
still resolves under the LID, sincegetEphemeralExpirationalready keys on the raw, engine,
and neutral forms of the jid. Thanks @isaacmendes. [#717] -
Diagnosable failure for a stale browser profile after a binary-changing upgrade. Upgrading
across the v0.8.12 amd64 browser-binary switch (Debian Chromium → Chrome for Testing, #663) — or any
later change to the Chromium/Chrome binary — can leave an already-authenticatedwhatsapp-web.js
session's persistent browser profile incompatible with the new binary: on the next start the page
context is destroyed during injection and the engine fails with Puppeteer's opaque
Execution context was destroyed, which reads like a Puppeteer bug and gave no hint that the stale
profile was the cause. Thewhatsapp-web.jsadapter now detects that error in itsinitialize()
catch and logs an advisory pointing the operator at the remedy (delete the session profile dir and
re-scan); the error still propagates unchanged, so existing failure handling is unaffected. The
profile is not auto-recovered — a tainted profile is not safely portable across Chromium major
versions (clearing only the cache subdirs is insufficient), so a one-time re-authentication is
required. [#708] -
OpenAPI export script under current env validation.
scripts/export-openapi.tshad been broken
since the SQLiteDATABASE_NAMEfile-path validation tightened (it pinned an in-memory data DB,
which that rule rejects). The data connection now uses a temp-dir SQLite file that is removed on
exit, so the snapshot generator runs hermetically again.