Skip to content

v0.18.0

Latest

Choose a tag to compare

@rmyndharis rmyndharis released this 13 Aug 02:44
d91c735

Added

  • ⚠️ Breaking (config). NODE_ENV is checked at boot, so a value outside production, development and test fails 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 the ALLOW_DEV_API_KEY rejection that stops the public dev-admin-key being 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 padded production is enforced as production there and then refused by the check below either way.) Action required: a deployment running NODE_ENV=staging (or any other value) now refuses to boot where it previously started with development-grade security. Set production, or leave the variable unset. Unset remains legal and unchanged. A blank NODE_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, .Headers and UpdateTemplateRequest.Header/.Footer become pointers, and a ClearFilters flag emits the explicit null the server reads. They carried omitempty over 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 clear secret, headers, header and footer by sending an empty value, but cannot emit the explicit filters: null that removes a webhook's filters — pass new 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').kind is '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 applies isIndividualWid, the same predicate the group-participant, channel-admin and message-mention surfaces already use on these very shapes.

  • POST /api/infra/import-data now 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 only sessions: 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=true as exposing 51 tools including messaging, when the surface is read-only unless MCP_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/docs returns 404, and SECURITY.md separately tells operators to review ENABLE_SWAGGER, so the two published documents disagreed. The row now states the condition.

  • POST /api/infra/storage/import now publishes its request body as a schema instead of {"type":"string"}. A description-only @ApiBody has 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, and openapi:check passed 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 left owner and participants[].id as <lid>@lid, made number the LID digits presented as a phone number, and reported isAdmin: false for groups the account actually administers — ids that then failed to join against the @c.us the 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, isBlocked on a contact read is now the account's real blocklist state rather than a literal false. Nothing derived it, while the same session's /contacts/blocked endpoint 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". isMyContact is 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 got 200 {"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 into EngineTransportError and report it, exactly as getChats and the other sibling reads in the same adapter already did. An ordinary page-side refusal still resolves false, unchanged.

  • POST /sessions/:id/chats/mute and /pin answer 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 as 400 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. onModuleDestroy cleared 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/export now enumerates the whole store. It listed with listFiles(), which stops at STORAGE_LIST_MAX_FILES and returns without logging or throwing, so an operator following the documented local→S3 migration — export, repoint STORAGE_TYPE, import — silently left media behind on the backend they were about to decommission, and the files/count pre-check was truncated by the same code path, so their own consistency check could not reveal it. The export now walks the uncapped iterateFiles(), which exists for exactly this: the cap is a per-call guard, not a completeness contract. The files/count pre-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 maxBodyBytes is no longer unbounded by its own cap. The check compared a byte length against undefined, 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:sending plugin handler that returns a payload without a usable input no longer turns every outbound send on that session into a 500. The gate read .input off the hook chain's reply unchecked, so a malformed envelope handed undefined to each caller — or threw inside the gate for a null — 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_restricted gauge 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 > 0 firing indefinitely and disagreeing with GET /api/sessions.

  • Both compose files now forward the inbound-media knobs — MEDIA_DOWNLOAD_ENABLED, MEDIA_DOWNLOAD_MAX_BYTES, MEDIA_DOWNLOAD_TIMEOUT_MS and INBOUND_MEDIA_CONCURRENCY. None of the four reached the container, so an operator running the bundled stack who set MEDIA_DOWNLOAD_ENABLED=false in .env got 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/metrics no longer fails the whole scrape when the data database is unreachable. render() awaited a stats query unguarded, so a statement timeout, pool exhaustion, a SQLITE_BUSY under 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 making up conflate 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 new openwa_stats_available gauge says which of the two happened.

  • The metrics reference now lists every series GET /api/metrics emits. 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 apiKeyId and apiKeyName as null. Behind NAT or a reverse proxy without TRUSTED_PROXIES the 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 target attribute whenever the jid is the account's own — so PUT or DELETE /sessions/:id/groups/<own-jid>/picture replaced 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, and PUT documents the new 400 alongside its existing ones.

  • GET /sessions/:sessionId/messages and the MCP MessageList tool now bound the inline media they return, via MESSAGE_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 in metadata.media.data, so a hundred media rows serialised to well over a hundred megabytes, and a large enough page pushed JSON.stringify past 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 with staleTime: 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_BYTES raises the budget for a deployment that would rather pay the bytes. The knob is checked at boot like its export-side sibling — 8MiB would 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 with staleTime: 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.text is 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 the messages.body column. 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 openwa user is now verified in CI. The Dockerfile carries no USER directive by design — docker-entrypoint.sh starts as root to fix named-volume ownership and then runs exec gosu openwa — and scripts/smoke-test-non-root.sh was the only check of that drop, but no workflow ran it: its sole appearance in ci.yml was 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 to latest. 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 to latest.

  • The dashboard's dependency tree is now audited on both the PR and the tag path. It went unscanned by every job: npm audit ran 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 into dashboard/dist via socket.io-client) and brace-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.example shipped five keys the Infrastructure dashboard owns — POSTGRES_BUILTIN, DATABASE_SSL, DATABASE_SSL_REJECT_UNAUTHORIZED, REDIS_BUILTIN and MINIO_BUILTIN — uncommented, so an operator who followed the documented cp .env.example .env pinned 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.