Added
- ⚠️ Breaking (config).
NODE_ENVis checked at boot, so a value outsideproduction,developmentandtestfails startup with a named error instead of silently selecting the permissive branch of every production hardening — CORS, Swagger, DTO error detail, the default-secret guard and theALLOW_DEV_API_KEYrejection that stops the publicdev-admin-keybeing seeded as an ADMIN credential. The value is compared verbatim, so one padded with surrounding whitespace is rejected too rather than accepted and then treated as non-production: the hardening readers compare the raw variable. (The default-secret guard is the exception, and in the safe direction — it runs before boot validation and trims, so a paddedproductionis enforced as production there and then refused by the check below either way.) Action required: a deployment runningNODE_ENV=staging(or any other value) now refuses to boot where it previously started with development-grade security. Setproduction, or leave the variable unset. Unset remains legal and unchanged. A blankNODE_ENV=counts as unset for the default-secret guard too, so both halves of the rule agree about the same string.
Fixed
-
The Baileys message-store round-trip test now exercises the serialization it names. Its fixture carried no binary field, so the assertions held under any replacer/reviver pair — including identity — and a regression in the BufferJSON round-trip could not fail CI, on rows that reply, forward, react and delete-by-id resolve against. The fixture now carries bytes, a new spec pins the encoded wire form, and both are checked against an identity codec so neither can go vacuous again.
-
⚠️ Breaking (Go SDK). The Go client can now send the values that clear a field.
UpdateWebhookRequest.Secret,.HeadersandUpdateTemplateRequest.Header/.Footerbecome pointers, and aClearFiltersflag emits the explicitnullthe server reads. They carriedomitemptyover value types, so the values the server reads as "remove this" marshalled away to nothing: the request body came out{}and the stored values survived while the call reported success. Action required: Go code assigning these fields directly no longer compiles — take the address of a variable (s := ""; req.Secret = &s) or leave the field nil to mean "leave it alone". The four other clients can already express both values, so their request types are unchanged. One narrower gap remains and is not fixed here: the Java client serialises with a Gson that drops nulls, so it can clearsecret,headers,headerandfooterby sending an empty value, but cannot emit the explicitfilters: nullthat removes a webhook's filters — passnew WebhookFilters(List.of()), which stores an empty condition set and stops filtering, until that client gains a clear flag of its own. -
The addressbook write guard now checks the id, not just its domain.
parseWaId('NOT A USER@c.us').kindis'user', so free text, letters and an empty user-part cleared the guard, reached the engine and were reported to the caller as a saved contact — for an entry keyed by something that is not a phone number. It now appliesisIndividualWid, the same predicate the group-participant, channel-admin and message-mention surfaces already use on these very shapes. -
POST /api/infra/import-datanow answers 400 for a malformed archive instead of 500. A table value that is not an array — a hand-edited or truncated backup — was dereferenced with.map()before anything checked it, so the operator was told the server had broken when their file was simply wrong. Checked for every table, not onlysessions: the rest are read inside the transaction, where the same mistake would fail mid-restore rather than before it opens. A row that is not an object —[null], a bare string, a nested array — is rejected the same way, instead of passing the array check and dying on its first property read. -
README no longer inverts the shipped MCP posture. It described
MCP_ENABLED=trueas exposing 51 tools including messaging, when the surface is read-only unlessMCP_READONLY=false— a secure default the code chose deliberately — so an operator wired a client, saw no send or group tools, and had nothing in README pointing at the knob. It now states the 25 read-only tools mounted by default and names the opt-in for all 51. A gate derives both counts from the tool sources, so a tier change fails instead of making the prose wrong again. -
README's Ports table no longer publishes Swagger unconditionally. The documented production command produces a deployment where
/api/docsreturns 404, and SECURITY.md separately tells operators to reviewENABLE_SWAGGER, so the two published documents disagreed. The row now states the condition. -
POST /api/infra/storage/importnow publishes its request body as a schema instead of{"type":"string"}. A description-only@ApiBodyhas nothing to infer the DTO from, so the contract told every generated client the body was a bare string while the handler took an object — the only one of the document's 83 JSON request bodies in that state, andopenapi:checkpassed on it because the snapshot was self-consistent, just wrong. A gate now rejects any JSON request body published as a bare primitive. -
Baileys group metadata now uses the phone-dialect twins the payload already carries (
ownerPn,participants[].phoneNumber). Reading only the LID leftownerandparticipants[].idas<lid>@lid, madenumberthe LID digits presented as a phone number, and reportedisAdmin: falsefor groups the account actually administers — ids that then failed to join against the@c.usthe same session's contacts and message webhooks emit for the same person. It was only correct once the lid→phone mapping had been learned; the twin is available immediately. Where WhatsApp withholds the twin, as it does for non-contacts, the previous behaviour stands. -
On the Baileys engine,
isBlockedon a contact read is now the account's real blocklist state rather than a literalfalse. Nothing derived it, while the same session's/contacts/blockedendpoint returned the real ids — so automation that skips blocked contacts before sending messaged people the account had explicitly blocked. A blocklist query that fails leaves the field at its default and logs a warning rather than failing the contact read or selling a transport failure as "nobody is blocked".isMyContactis unchanged: Baileys has no addressbook signal to derive it from, and inventing one would repeat the mistake. The blocklist answer is memoised when it ARRIVES rather than when the query started — stamping the start wrote an already-expired memo for any query slower than the window, so contact reads, which used to come out of the in-memory store, paid the engine deadline again per read on exactly the slow links the memo was added for — and one in-flight query is now shared by every caller waiting on it. -
Five whatsapp-web.js chat operations — mark-as-read, clear, archive, mark-unread and delete — no longer report a dead browser as a refusal. Each caught every error and resolved
false, so the caller got200 {"success": false}("WhatsApp declined a valid request") while the session's page was actually dead, and retried against it instead of receiving the documented 503. The early death signal was lost too, so the session kept reporting READY until the slower watchdog noticed. They now split transport failure out intoEngineTransportErrorand report it, exactly asgetChatsand the other sibling reads in the same adapter already did. An ordinary page-side refusal still resolvesfalse, unchanged. -
POST /sessions/:id/chats/muteand/pinanswer 503 rather than 400 when the whatsapp-web.js page is dead. Both resolve the chat first, and that resolution swallowed every rejection into "no such chat" — so a dead page, the state in which nothing resolves at all, was reported as400 Chat <id> does not exist on this session, naming the caller's input as the fault and telling a retry loop to stop during the outage a retry is for. 503 was already a published response on both routes; what changes is that a transport failure now reaches it instead of being relabelled. An id the page genuinely does not know still answers the documented 400. -
The cross-node takeover sweep no longer adopts sessions while the process is shutting down.
onModuleDestroycleared the interval, which stops the next sweep but does nothing about one already running — it is neither aborted nor awaited, and nothing on the start path consulted a shutting-down signal. On a rolling restart a sweep mid-flight could construct and register an engine after the shutdown path had emptied the registry, and claim the ownership lease for a process about to exit, pinning that session to a dead node until the lease lapsed. The signal is re-checked per adoption, because each one costs a browser launch and the loop spans much of the sweep interval. -
GET /infra/storage/exportnow enumerates the whole store. It listed withlistFiles(), which stops atSTORAGE_LIST_MAX_FILESand returns without logging or throwing, so an operator following the documented local→S3 migration — export, repointSTORAGE_TYPE, import — silently left media behind on the backend they were about to decommission, and thefiles/countpre-check was truncated by the same code path, so their own consistency check could not reveal it. The export now walks the uncappediterateFiles(), which exists for exactly this: the cap is a per-call guard, not a completeness contract. Thefiles/countpre-check an operator runs before that migration walks the same uncapped list, so it can no longer hide the gap it exists to reveal. -
An ingress route whose manifest omits
maxBodyBytesis no longer unbounded by its own cap. The check compared a byte length againstundefined, which is always false, so the per-route 413 the published contract promises was silently inert — and every accepted delivery is persisted with the body stored twice and carried into the queue, turning one forgotten manifest field into a write amplifier with no load-time error and no runtime signal. A route with no usable value now falls back to the process-wide body limit (what it was already bounded by in practice, so nothing accepted today starts failing) and the gap is logged once per route instead of passing in silence. -
A
message:sendingplugin handler that returns a payload without a usableinputno longer turns every outbound send on that session into a 500. The gate read.inputoff the hook chain's reply unchecked, so a malformed envelope handedundefinedto each caller — or threw inside the gate for anull— with nothing in the error naming a plugin. The send is now refused with a message that names the hook, and refused rather than sent: this is a moderation chokepoint, and a handler whose reply cannot be read may have been redacting something. A chain that replies with nothing at all is unchanged and keeps the caller's own input. The refusal is also logged with the session and call site, because the 400 reaches the API caller while the person who can fix the plugin sees only that sends stopped. -
The
openwa_sessions_restrictedgauge now follows a restriction that lapses on its own. It was republished only when a restriction was recorded or cleared, and an expiry is neither — so after a reachout timelock passed its stated end, the gauge kept reporting the pre-expiry count while every read path already reported the session as unrestricted, leaving an alert on> 0firing indefinitely and disagreeing withGET /api/sessions. -
Both compose files now forward the inbound-media knobs —
MEDIA_DOWNLOAD_ENABLED,MEDIA_DOWNLOAD_MAX_BYTES,MEDIA_DOWNLOAD_TIMEOUT_MSandINBOUND_MEDIA_CONCURRENCY. None of the four reached the container, so an operator running the bundled stack who setMEDIA_DOWNLOAD_ENABLED=falsein.envgot no error and no log line while the gateway kept decrypting every inbound media blob and base64-inlining it into every message row at up to 50 MiB apiece. The cap and the timeout were equally unreachable. A gate now binds all four, in both files. -
GET /api/metricsno longer fails the whole scrape when the data database is unreachable.render()awaited a stats query unguarded, so a statement timeout, pool exhaustion, aSQLITE_BUSYunder load or a genuine outage answered every scrape with a 500 once the 30-second stats memo lapsed — costing Prometheus the process, HTTP and webhook series too, and makingupconflate a dead process with an unreachable database, during the exact incident the endpoint exists to describe. The database-derived series are now omitted rather than reported as zero (a zero would fire an alert claiming every session had dropped), and a newopenwa_stats_availablegauge says which of the two happened. -
The metrics reference now lists every series
GET /api/metricsemits. It introduced its table as "the complete set — nothing else is emitted" while naming 9 of 16, so the webhook-failure, session-reconnect, restricted-session and pacing-refusal counters an operator would alert on were absent from the document they alert from. A gate compares the table against the renderer, and the text now says which series are conditional, since omitting one is not the same as reporting zero. -
An authorization denial now records WHICH credential was denied. Both post-authentication 403s — an insufficient role, and a session-scoped key on a route that forbids one — threw before the resolved key was stamped into the request context, so every such audit row carried
apiKeyIdandapiKeyNameas null. Behind NAT or a reverse proxy withoutTRUSTED_PROXIESthe recorded IP is common to every tenant, leaving an operator able to see that a key had been denied but not which one to revoke. The same stamp was added to the queue-dashboard mount, which has the same shape. A denial that happens before any key resolves is unchanged: the IP really is all there is. -
The three group-picture routes now refuse an id that does not name a group. They reuse the account's own profile-picture primitives, and Baileys omits the
targetattribute whenever the jid is the account's own — soPUTorDELETE /sessions/:id/groups/<own-jid>/picturereplaced or permanently deleted the WhatsApp account's own avatar and answered{success: true}, while whatsapp-web.js refused the same input with a 404. Both engines now reject it with a 400 before the id reaches either adapter. The published responses were corrected to match: the 404 no longer claims the 1:1-chat case the 400 now owns, andPUTdocuments the new 400 alongside its existing ones. -
GET /sessions/:sessionId/messagesand the MCPMessageListtool now bound the inline media they return, viaMESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES(8 MiB of encoded base64 by default). The row count was clamped to 1..100, but a row is not a bounded object: each carries its base64 inmetadata.media.data, so a hundred media rows serialised to well over a hundred megabytes, and a large enough page pushedJSON.stringifypast its ceiling and failed the read outright — permanently for the dashboard, which requests the maximum page size with no way to ask for less. The budget is spent newest-first; past it a payload is replaced with the{ omitted: true, sizeBytes }marker the engine already emits for skipped inbound media, and remains fetchable from the per-message media route. In the dashboard a trimmed message shows the same 📎 Media placeholder it already uses for media the engine skipped on the way in, and that placeholder now downloads the media when clicked — the thread requests the largest page size and caches it withstaleTime: Infinity, so without a fetch of its own the marker was terminal and an older attachment in a media-heavy chat had no route to it at all.MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTESraises the budget for a deployment that would rather pay the bytes. The knob is checked at boot like its export-side sibling —8MiBwould otherwise parse to 8 bytes and omit everything — and is forwarded by both compose files. The newest payload is always let through even when it alone exceeds the budget: omitting it left a single large photo or video permanently unreadable through this route, since the dashboard thread has no other media source and caches withstaleTime: Infinity. A budget of 0 still means "do not inline" and grants no allowance. -
Bulk send now caps its rendered result at
TEMPLATE_RENDER_MAX_CHARS(default 64 KiB), the same limit the single-send template path already enforced.content.textis length-validated before substitution, so caller-supplied variables inflated each item without bound while the request body stayed far below the in-flight body budget — a few hundred KiB of variables became hundreds of megabytes of heap per item, then went to the engine and themessages.bodycolumn. An over-cap render now fails that item with a message naming the limit, exactly as a pacing refusal does, rather than being truncated silently or sent. -
The published image's drop from root to the
openwauser is now verified in CI. The Dockerfile carries noUSERdirective by design —docker-entrypoint.shstarts as root to fix named-volume ownership and then runsexec gosu openwa— andscripts/smoke-test-non-root.shwas the only check of that drop, but no workflow ran it: its sole appearance inci.ymlwas inside a comment. A change leaving the Node process on uid 0 passed lint, every test job, the multi-arch build and the image scan, and was promoted tolatest. The Docker job now loads a single-arch image from the cache the multi-arch build already populated and runs the smoke test against it. The release path checks it too, since that is the path that promotes tolatest. -
The dashboard's dependency tree is now audited on both the PR and the tag path. It went unscanned by every job:
npm auditran against the root tree only, and the image scan sees OS packages and the built bundle rather than the npm graph behind it — so a HIGH advisory in a dependency that reaches an operator's browser could ride a release undetected. Two were present at this baseline:socket.io-parser(bundled intodashboard/distviasocket.io-client) andbrace-expansion(dev-only, via ESLint), the latter pinned to a vulnerable version by an override that had not been bumped. Both are now overridden to patched releases. -
.env.exampleshipped five keys the Infrastructure dashboard owns —POSTGRES_BUILTIN,DATABASE_SSL,DATABASE_SSL_REJECT_UNAUTHORIZED,REDIS_BUILTINandMINIO_BUILTIN— uncommented, so an operator who followed the documentedcp .env.example .envpinned them: the dashboard control still saved and reported success while the running value never changed, most consequentially leaving a Postgres connection unencrypted after the operator enabled SSL. The file's own header already promised a gate against this, but that gate only reached keys docker-compose forwards blank; it now covers every key the dashboard writes..env.minimal, the other documented starting point, no longer pins the three built-in datastore toggles either; its deliberate development pins are left alone. -
Two enabled instances of one integration plugin that share a session scope no longer collapse onto a single config, so an ingress delivery authenticated with one instance's secret is no longer handled with the credentials of whichever instance was provisioned last. Dispatch layers the instance's own config over the base config, and consults the per-session override only while it can be attributed to that instance — with one enabled instance on the scope, which is every single-instance deployment, it still applies exactly as before. With siblings it does not: the override shares its storage with the per-scope projection of whichever instance was provisioned last, so on a shared scope it is not the operator's value but the other tenant's. Action required: an operator who has set a per-session config for a plugin and then provisions a second enabled instance on that same session should move the shared keys onto each instance instead, because the override stops reaching either of them. Retiring an instance clears that scope's config slice too, so the surviving tenant falls back to the plugin's own defaults rather than inheriting the endpoint and credentials of the instance just disabled or deleted — the same collapse, reachable by turning one of the two off.