Closing an application disables every enabled plugin, which writes into the
plugin registry. In the e2e lane that registry was the one piece of state a
suite could not redirect, so each suite booted against whatever the previous
suite's shutdown left there, and the same write landed in the developer's own
`./data/plugins/registry.json`. Measured directly: set the whatsapp-web.js
engine to `enabled`, run one AppModule-booting suite, and it comes back
`disabled`.
`configuration.ts` argued against a DATA_DIR knob because none of the other data
paths would follow it, so it would move part of the state while looking like it
moved all of it. That objection is answered rather than repeated here: this
value reaches exactly one consumer, PluginStorageService, so the knob is named
for the registry and per-plugin storage it actually moves.
The e2e lane points it at a throwaway directory, alongside the databases and the
bootstrap key file it already redirects. The developer's registry is now
untouched by a run, verified by flipping it to `enabled` and confirming the
write lands in the temp tree instead.
This does NOT make the lane deterministic on its own. Measured after the change:
one failure in six runs, down a different path (a serve-static asset answering
401), which is clean in four consecutive runs of that suite alone. The note in
setup-e2e.ts no longer claims serial execution closes the hole.
The record was written and settled but never removed, so the table grew by one
row per webhook per event for the life of the deployment. The inbound twin has
carried a retention sweep since it was introduced; this is the outbound half.
- Settled rows older than WEBHOOK_OUTBOX_RETENTION_DAYS (default 7) are pruned
daily. A 'pending' row is never pruned on age: it is a delivery that can still
be replayed, which is the whole point of the table.
- The window is deliberately independent of the delivery-failure retention
switch, and a non-positive value falls back to the default with a warning. A
settled row carries no payload and no audit value, so opting into unbounded
growth buys nothing, which is the same call the inbound dedup log makes.
- The reconciler records what it does NOT do: it takes no row claim, so two
nodes on one database can both replay a delivery. The replay carries the
stored idempotency key, so the cost is a duplicate the contract already tells
consumers to expect, rather than a lock whose holder can die mid-flight.
- docs/06 qualifies the failure table: a capacity-shed or drain-refused delivery
keeps its record AND is replayed, so a row there can belong to an event that
was later delivered.
Fan-out is fire-and-forget from the projector, so a crash between persisting a
message and completing its POST lost the delivery with no record in either mode,
while the documented contract promises at-least-once. The in-memory
inFlightDeliveries map already tracked exactly that set; this gives it a durable
twin, mirroring ingress_events and its reconciler on the inbound side.
- webhook_outbox_events records one delivery per (webhookId, idempotencyKey),
written before the attempt. The payload is retired the moment an outcome is
recorded, so only a replayable row carries a message body.
- The record is closed as 'dispatched' once a durable owner has it: handed to
BullMQ, or completed inline. Retiring on the ENQUEUE rather than on the POST is
what stops the sweep duplicating work the queue already holds. A limiter
rejection (capacity, shutdown drain) deliberately leaves the row pending, which
is the case that used to vanish.
- WebhookReconcilerService sweeps stranded rows on an unref'd interval, replaying
each through the stored idempotency key so the retry is deduplicable at the
receiver. A fresh delivery id is issued, because that names the attempt rather
than the event. Overlap-guarded, batched, and bounded by a per-row budget: a
stuck delivery goes terminal instead of looping.
- Four knobs, mirroring the ingress ones, documented and forwarded through both
compose files and the blank-shadow list.
- docs/06 no longer claims a hard crash simply loses in-flight deliveries, and
names the window that is still open: the record and the message insert are not
yet one transaction.
The new table joins the backup registry, which pulls it through the row type,
both response DTOs, the importer and the FK-safe import order.
The ingress route is public (providers cannot present an API key) and
skips the global per-IP tiers, because their 100/min medium window sits
below the per-instance limit and shed a provider fanning every tenant's
webhooks through one egress IP. That left InstanceThrottlerGuard as the
route's only limiter, and its bucket key is built from the
`:pluginId/:instanceId` path segments the caller supplies: varying them
mints a fresh bucket per request, so an unauthenticated client had no
bound it could not walk around, and grew the throttler key space while
doing it.
The guard now evaluates a second tier on the same window, keyed on the
client through the inherited proxy-aware tracker. It is sized at ten
times the per-instance default (1200, `INGRESS_IP_LIMIT`) so it never
binds before the per-instance limit for legitimate traffic.
Verified with an e2e that floods varying path segments from one client
and asserts the 429 plus its `Retry-After-ingress-ip` header; removing
the tier turns that 429 back into a 404.
The compose environment list is the only path into the container, and ~75
knobs documented in .env.example had no line in it, so setting them in .env
silently did nothing. That included both v0.20.0 breaking-change opt-outs
(WEBHOOK_SSRF_REDIRECTS, PLUGIN_INSTALL_REQUIRE_PIN) and PLUGIN trust knobs
like SSRF_ALLOWED_HOSTS and API_KEY_PEPPER.
Forward every documented knob the app reads in both compose files, blank by
default so a real host value pins and data/.env.generated keeps applying.
The compose parity spec now derives the required set from .env.example
instead of per-family prefixes and the strict-boolean list, so a new knob
cannot ship unreachable again. Keys equal to their defaults that shipped
uncommented in .env.example (CORS_ORIGINS=*, WEBHOOK_SSRF_REDIRECTS=false,
the DB/Redis timeouts, and friends) are now commented out, matching the
file's own pin-avoidance rule and keeping copied-file behavior identical.
1. MESSAGE_LIST_INLINE_MEDIA_BUDGET_BYTES had no boot check, while the sibling it
was modelled on (EXPORT_INLINE_MEDIA_BUDGET_BYTES) has one. It is read with
parseInt, so '8MiB' silently means EIGHT BYTES and omits every payload — the
exact trap the sibling is checked for.
2. The two NODE_ENV validators disagreed about the empty string: boot validation
treats '' as unset and lets it through, while the default-secret guard treated
it as production and enforced. So NODE_ENV= booted clean and then got
production-grade enforcement, from two halves of one rule.
3. The new budget knob was not forwarded by either compose file, so the only
escape hatch from the new default was unreachable on the bundled stack —
which is the failure this branch already fixed for the media-download family.
Forwarded, with the BLANK_SHADOWED_ENV_KEYS entry a blank forward requires.
All three were found by reviewing this branch rather than by the work that
created them. TEMPLATE_RENDER_MAX_CHARS and EXPORT_INLINE_MEDIA_BUDGET_BYTES are
also unforwarded, but that predates this branch and is left alone.
Neither compose file forwarded MEDIA_DOWNLOAD_ENABLED, MEDIA_DOWNLOAD_MAX_BYTES,
MEDIA_DOWNLOAD_TIMEOUT_MS or INBOUND_MEDIA_CONCURRENCY, so an operator on the
bundled stack could neither disable nor bound what env.validation.ts itself calls
"the most expensive behaviour the gateway has": decrypting every inbound media
blob and base64-inlining it into every message row at up to 50 MiB apiece. The
value sat in .env, produced no error and no log line, and never entered the
container. The only remaining path was hand-editing data/.env.generated inside
the mounted volume, which .env.example does not mention for these keys. The
sibling MEDIA_CONVERSION_* family was forwarded all along.
Forwarded with the same ${VAR:-} pattern, which means each also needs a
BLANK_SHADOWED_ENV_KEYS entry — a blank forward otherwise shadows .env and
data/.env.generated with an empty value, the very failure the existing
'every blank forward is cleared' gate exists to catch.
A new gate binds all four in both files, with a positive control on a key that
was already forwarded so it cannot pass vacuously.
The chat-media archive only ever wrote inbound media: archive() had a single
call site on the inbound persist path, so a sent attachment had no durable
copy, no S3 portability and no TTL retention. It now runs for outbound rows
too when CHAT_MEDIA_ARCHIVE_OUTBOUND is set alongside CHAT_MEDIA_ARCHIVE_ENABLED
(off by default, and a sub-flag rather than a mode of its own so the retention
purge and orphan sweep are guaranteed to be maintaining whatever it writes).
Three guards make that safe, all inside archive() so every caller inherits them:
- A URL-based send stores the URL STRING, not bytes. Buffer.from(url,'base64')
does not throw — it yields ~18 bytes of noise — and the read endpoint consults
the archive BEFORE the inline copy, so archiving one would have served garbage
in place of the correct 404, in a file the orphan sweep treats as referenced.
- The same row can reach archive() from two writers, so a row already pointing
at a file is left alone rather than orphaning the first one.
- getMedia now matches the caller's chatId dialects: an outbound row stores the
literal or the neutral form depending on which writer won the persist race,
the same duality the inline fallback already resolves.
Separately, merging a URL-pointer payload onto the engine's own-send echo
replaced bytes the gateway had already downloaded (wwjs enriches its echo with
the real payload), so those two merge sites now keep the richer copy.
Closes the remainder of the compose allow-list gap. Each has a real read site, no
dashboard route, and no reason to be unsettable under Docker:
- MCP_ENABLED gates the whole MCP module behind `=== 'true'`, so the server could not be
turned on at all in a compose deployment.
- SEARCH_ENABLED is documented in .env.example as "set to false to disable the /search
route + module entirely" — which it did not do.
- MAIN_DATABASE_SYNCHRONIZE is the documented way to put api_keys/audit_logs under the
main-owned migration instead of synchronize; app.module derives migrationsRun from its
negation, so the choice silently never applied.
- SERVE_DASHBOARD, CACHE_ENABLED and DATABASE_LOGGING are the same class.
All six are blank-forwarded, so an operator who sets nothing keeps today's behaviour
exactly; only an explicit value changes anything. The three that shipped uncommented in
.env.example are commented out with their defaults still visible, as env-precedence.spec.ts
requires of any blank forward.
The allowlist in the compose-parity gate now holds only entries with a standing reason —
the six "pending review" placeholders are gone, so the gate no longer records an open
question as if it were a decision.
Compose declares its environment as an explicit allow-list and has no env_file, so a
variable absent from that list never reaches the container. Seven behaviour flags with no
dashboard route were absent, meaning an operator who set them in .env got silence:
BAILEYS_MARK_ONLINE_ON_CONNECT is the one that bites, because its default suppresses push
notifications on the paired phone for as long as the gateway is connected (#871), and
.env.example shipped it uncommented as though setting it worked.
They are blank-forwarded and registered in BLANK_SHADOWED_ENV_KEYS so the forward itself
cannot pin them off — the failure mode the AUTO_START_SESSIONS forward was fixed for. The
two Baileys lines in .env.example are commented out with their defaults still visible,
which env-precedence.spec.ts requires of any blank forward.
Six other unforwarded flags are deliberately left alone: they are owned by the dashboard's
Infrastructure page, which writes data/.env.generated on the mounted volume, so they are
settable — just not through .env. Forwarding those would have shadowed saved settings with
an empty string.
The gate that should have caught this did not exist. env-precedence.spec.ts derives its
expectations FROM compose, so it is structurally blind to a flag compose omits, and the
compose-parity family check covered three hardcoded prefixes. The new assertion derives the
set from env.validation.ts's checkBool list and requires every entry to be either forwarded
or named in an allowlist with a reason. It found three flags this change had missed.
A deep source-traced pass over the whole release delta turned up 19 defects
this range introduced. The load-bearing ones:
- Ownership: stop() released the claim unconditionally, so a stop that
landed while a start was mid-launch unclaimed a live engine — no
heartbeat renewed it and any peer would open the account a second time.
stop/logout/forceKill now use the same engine-liveness guard the failure
paths do. A failed start left a dormant reconnect entry that
isEngineActive read as liveness, pinning the session to the failing node
forever; the entry is dropped at the source and liveness now requires a
pending attempt. And release() clears a LAPSED foreign claim on claim()'s
own predicate, so stopping a session whose crashed owner's lease expired
is not re-adopted by the takeover sweep minutes later.
- The send breaker counted deterministic client faults raised inside the
engine call (SSRF-blocked URL, unsupported capability, disconnected
socket, a status post missing recipients), so a handful of bad requests
opened it and 429'd every send on a healthy session for the cooldown. A
shared predicate now gates the single, bulk and status paths.
- automation_rules was in neither export-data nor import-data while the
import's session wipe cascade-deletes it: the documented backup→restore
destroyed every autoreply rule and reported success.
Plus: media-convert URL failures answer 400 not 500; muteChannel refuses a
non-channel id (it muted ordinary chats forever); wwjs label-chats and
group join-info answer 404 not 500; setGroupEphemeral joins the refusal
mapping; the restriction gauge and size() honour the expiry filter reads
already apply; the cold-reachout aggregate normalises id dialects like its
probes; label upsert treats null fields as empty; a bare-number contact id
is qualified before Baileys keys app-state by it; the dev compose forwards
the release's new variables; a successful forward no longer logs an
EmptyError; and Baileys link previews are opt-in again, restoring the
documented default and un-stalling bulk sends.
Every fix is spec-locked, and each behavioural one was mutation-tested.
* fix(plugins): default the plugin package dir to <dataDir>/plugins
The plugin package directory and the plugin registry described the same install
from two unrelated defaults: `plugins.dir` fell back to `./plugins`, while
PluginStorageService keeps registry.json (status, operator config, secrets,
enabledByOperator) under `<dataDir>/plugins`. There was no `dataDir` key at all,
so `get('dataDir')` never resolved and the registry always landed in `./data`.
With PLUGINS_DIR unset the two disagreed silently in both directions: the loader
scanned a directory that did not exist and reported "Loaded 0 plugins" while the
registry still listed every plugin as installed, and an install wrote plugin code
to `./plugins` — inside the ephemeral container layer in Docker, where the data
volume is mounted at /app/data — so the code was destroyed on the next container
recreate while its registry entry survived, pointing at a directory that no
longer existed.
Add `dataDir` as a real key and derive the package dir from it, so both halves of
an install come from one value. `plugins.legacyDir` carries the historical
default for the loader's compatibility scan and is null whenever PLUGINS_DIR is
set explicitly.
* fix(plugins): keep loading plugins left at the old package directory
Moving the default package dir under <dataDir> would take their plugins away
from hosts that installed on the old default: ./plugins was self-consistent
while the loader and the installer both used it. The loader now also scans that
location, in addition to the configured one so a host part-way through migrating
keeps both halves, and warns with both paths and the two ways to settle it. The
scan is keyed on finding a readable manifest.json, not on the directory
existing: <dataDir>/plugins/<id> is also the plugin's ctx.storage directory, so
directories with no code in them are routine there.
Boot now also reports what used to be invisible. A missing package directory was
skipped without a word, leaving "Loaded 0 plugins" — indistinguishable from a
host with nothing installed — while the dashboard read the registry and listed
every plugin as installed and enabled. Registry entries with no loaded code are
named at boot, alongside the directory that was actually scanned.
The manifest-less directory warning was equally ambiguous: it is the routine case
for a built-in's storage directory and the only symptom of an installed plugin
whose code is gone. The registry tells those apart, so each now says which it is.
* docs(plugins): document the plugin directory default and its data-dir tie
The default moved to <dataDir>/plugins, so anything that described the old
./plugins fallback — the loader walkthrough, the compose annotations warning that
plugins land outside the data volume, and .env.example — no longer matched the
code. The backup and restore scripts defaulted PLUGINS_DIR to ./plugins too,
which meant a run without that variable set silently archived no plugin packages
at all; they now derive it from the data directory like the app does.
* test(plugins): type the boot-directory warning spy
Neither docker-compose.yml nor docker-compose.dev.yml forwarded
BODY_SIZE_LIMIT into the container, so an operator setting it in .env
(as .env.example documents) had no effect — base64 media sends 413'd
under the app default. Both files enumerate tunable runtime envs
explicitly and simply missed this one.
Fixes#831, reported in discussion #540.
* fix(dashboard): render sent media reliably, fix chat scroll, harden stats/search/audit
Bug A: the byType stats aggregation now excludes content-less rows
(empty body AND no metadata — e.g. @lid privacy-user events the engine
maps to 'unknown'), so the Messages-by-type pie no longer shows a
misleading 'Unknown' slice. Verified against live data: the predicate
excludes exactly those rows; media rows with empty captions survive.
Bug B: a sent image no longer vanishes from the chat thread. The live
message.sent echo carries no media payload (engine parity marker), and
mergeOrAppend replaced metadata wholesale, wiping the optimistic
bubble's base64. Metadata now merges per field (payload always beats a
payload-less marker; quote/reactions survive too), and the send race
guard folds the optimistic copy into the echo row instead of dropping it.
Chats: the thread now stays pinned to the bottom while images/video
decode (media has no layout box pre-decode, so the restore-to-bottom
was silently stranded near the top on media-rich chats); the pin
releases on user scroll and re-arms at the bottom.
Search: BuiltInFtsProvider self-heals the FTS schema at boot
(DATABASE_SYNCHRONIZE=true skips migrations, so search 501'd on fresh
boxes), and SQLite FTS5 queries are sanitized per-token — phone
numbers / chatIds / quotes / parens no longer 400 as malformed.
Audit: the resolved API key + client IP are stamped into the request
ALS by ApiKeyGuard and auto-filled into audit rows (explicit context
still wins), so apiKeyName/ipAddress columns are populated for every
call site, including deep service calls that never had the key.
* feat: persist phone-composed outgoing messages, enrich own-send media echo
onMessageCreate now mirrors the outgoing message to the messages table
(best-effort): message_create is the ONLY event a phone-composed send
produces, so local history previously reflected API sends + inbound
only. The UNIQUE(sessionId, waMessageId) index is the atomic dedup
oracle against the REST send path; a unique loss skips the insert
quietly, other errors fail open, and the webhook/WS message.sent
contract is unchanged either way. persistSentState closes the reverse
race: on unique collision it merges status/timestamp/metadata (the
actual media payload) onto the echo row BEFORE deleting its redundant
PENDING row — a live test proved the echo can win, which without the
merge would have deleted the payload-bearing row.
Media-less echoes (wwjs own-send has no media field; media-free
history sync) get an omitted marker synthesized at both persistence
chokepoints, so rows render the 📎 placeholder instead of an empty
bubble and stay countable in by-type stats.
whatsapp-web.js: the message_create echo now downloads media through
the same capped inbound path as incoming messages (declared-size
pre-gate, timeout, concurrency limiter), so phone-composed images
persist and render with their real payload. A download failure is
contained at the call site — the echo still fires, media-less.
Dev compose defaults AUTO_START_SESSIONS=true so previously
authenticated sessions come back by themselves after a container
restart (the #798 watchdog only covers a running container's lifetime;
the app-level default stays off).
* refactor(dashboard): simplify theming, consolidate UI quick wins, remove dead code
Theming: the palette picker is removed — it was hard to maintain
(three tokens x two themes x every surface per accent) and read as
childish/off-brand. Light/dark/system selection is now a single
direct toggle button (no popup); the stored theme is applied before
first paint so standalone routes (Login) no longer flash the OS theme.
Legacy localStorage palette key and data-palette attribute are cleaned
up on load.
Readability: the chat send button is a real 48px primary circle with a
24px icon (disabled state stays a dimmed primary instead of
gray-on-gray); stat-card watermark icons are restored on the
Dashboard; API Keys Active/Revoked badges render on desktop (rules
were stranded in a mobile-only media query); Dashboard disconnected
badge styled; Templates page gets real .btn-primary/.btn-secondary
rules (both buttons fell back to browser defaults).
Behavior: Message Analytics defaults to 24h; enabling a plugin with
unset required config opens its config modal with a warning instead of
failing inside the sandbox (after-hours, faq-bot, catalog plugins);
enable {success:false} responses are surfaced; QR fetch is driven by
the session.qr WS push and gated to qr_ready, silencing the expected
400 console noise on connect; Sessions modals regain the 90vh cap +
scrolling body; fourteen dead [data-theme='dark'] descendant selectors
are flipped to the ancestor form so dark mode actually applies.
Dead code removed (verified zero-usage): engine-card leftovers in
Plugins.css, per-page header-content rules, the Infrastructure
migrations block, settingsApi, replaceMessageById, vite.svg/react.svg,
39 dead i18n keys across all 11 locales, ghost CSS vars
(--color-text-muted/--surface-2/--swatch-color), and the
white-shrink typo.
* fix(dashboard): keep the chat scroll pin-state listener attached across renders
The pin-tracking effect had no dependency array, so React ran its
cleanup (listener removal) before every re-run — while the element ref
guard then refused to re-attach. Net effect: the scroll listener was
permanently gone after the second render, pinnedRef stuck at true, and
every late-decoding image yanked the thread back to the bottom even
after the user scrolled away. Attach unconditionally each run (React's
cleanup keeps it single).
* fix(dashboard): restore the real scroll position when returning to a chat
The switch-time save read el.scrollTop AFTER React had already swapped
the container to the new chat's content, capturing the new content's
(clamped) value — a round trip A -> B -> A restored A to the top
(first-open was fine, hiding the bug). The scroll listener now saves
the visible chat's live scrollTop continuously, so the per-chat map
always holds the last real user position; decideRestoreTarget loses
its (broken) save step and prevLoaded input.
* fix(dashboard): re-apply a saved chat scroll position as media decodes
A 'saved' restore writes scrollTop before images decode, so the browser
clamps the write to the still-short scrollHeight and the thread lands
at the top (the saved value was captured with decoded, tall content).
The saved value now lives in a pendingRestore slot and is re-applied on
every media decode until a genuine user scroll cancels it; our own
writes are flagged so the scroll listener neither records them nor
mistakes them for the user (a no-op write clears the flag immediately
so it can't swallow the next real scroll).
* docs: changelog for the stabilization batch, dashboard theming doc update
Also fixes the CI lint failures from the previous commits (prettier
formatting + eslint-disable for test-only mock member accesses).
* style: suppress test-only unsafe member access flagged by CI lint
* style: apply Prettier formatting across PR-touched files, void floating promises
Brings the changed files in line with the CONTRIBUTING style gate
(npm run format) and clears the four no-floating-promises warnings in
the Baileys adapter (socket teardown paths are intentionally
fire-and-forget; now marked void explicitly).
Strengthen authentication lifecycle, bound resource use, align cross-layer contracts, and harden release and recovery tooling.
Validated across the backend, dashboard, five SDKs, Compose configurations, backup and restore paths, OpenAPI, and multi-architecture container builds.
* fix(config): reject bare SQLite DATABASE_NAME to prevent read-only-rootfs boot-loop (#677)
* fix(config): reject postgres × DATABASE_SYNCHRONIZE=true (silently breaks /search)
* fix(search): cap limit/offset and host-side re-filter plugin results by session scope
* fix(security): redact resolved internal IPs from SSRF-block error messages
* fix(search): preserve plugin search total when no session-scope leaks
The host-side session re-filter overwrote total with the filtered page count
even when no hits were out of scope, so 'Load More' (hits.length < total) never
fired for scoped keys on a plugin provider. Preserve the plugin's total when no
leak was stripped; fall back to the page count only when one was.
* fix(security): redact SSRF IP from webhook dispatch webhook:error payloads
WebhookService.dispatch() stringified the re-thrown SsrfBlockedError into the
webhook:error payload on both the queue-fallback and direct-delivery paths
(the processor path was already redacted). Wrap both with redactSsrfError so
the resolved internal IP never reaches a plugin consuming webhook:error.
* fix(plugins): bound extraction + return 400 on corrupt/oversized plugin archives
* fix(plugins): bound aggregate extracted bytes (multi-entry zip-bomb)
* feat(integration): prune ingress events + delivery failures past retention
ingress_events (inbound dedup/event log) and integration_delivery_failures
(the DLQ) are append-only and previously grew without bound. Add a scheduled
retention prune mirroring the existing AuditService/WebhookService pattern:
OnModuleInit/OnModuleDestroy + a raw unref'd daily setInterval, gated by
INGRESS_RETENTION_DAYS (default 90; <= 0 disables, no startup prune, no timer).
The prune is an age-bounded createdAt < cutoff delete on both tables and runs
once at startup then daily, logging the removed count.
* feat(integration): warn on unauthenticated ingress routes + support {id} HMAC placeholder
* feat(security): audit MCP auth failures + throttle Bull Board login by IP
The MCP mount (raw Express, outside the Nest guard pipeline) and the Bull
Board UI middleware previously lagged the REST guard chain:
- MCP tool calls authenticated inside invokeTool but rejected credentials
(bad/missing/revoked key, wrong role, IP/session not allowed) left no
audit trail, unlike ApiKeyGuard which records a WARN API_KEY_AUTH_FAILED
per denied attempt. AuditService is now injected into McpModule and an
onAuthFailure callback (symmetric to the existing onAuthenticated hook)
fires at invokeTool's auth boundary, recording the same WARN record with
the resolved client IP / method / path. The hook lives at the auth phase
only, so handler-thrown 403s and successful calls are not mislabeled.
- Bull Board's auth middleware had no pre-auth rate limit, so a flood of
login attempts reached the validateApiKey DB lookup unthrottled. A
per-IP KeyRateLimiter (reusing MCP's createIpThrottle mechanism and
readIpRateLimitConfig policy — 120/60s by default) now runs before the
credential check and surfaces overruns as a standard 429. The limiter is
injectable for tests; production takes the MCP-matching default.
* fix: misc hardening (main DB path, secret-file chmod log, IPv4-mapped SSRF, async storage traversal, http baseUrl warning)
* fix(session): honor STORE_EPHEMERAL_MESSAGES on Baileys history backfill
* fix(events): enforce WS IP allowlist + evict sockets on API-key revoke
* ci: type-check specs + strengthen release gate; fix spec type errors + vacuous search test
* test(sdk-php): use https in client fixtures to avoid the insecure-http warning
The bundled compose files shipped pids_limit: 512 since the #243
hardening pass, chosen as a fork-bomb guard without accounting for
Chromium's multi-process model. whatsapp-web.js runs a full Chromium
instance per session (browser + renderer + GPU + zygote + utilities)
and WhatsApp Web is itself process-heavy (service workers, iframes),
so ~4 concurrent sessions already approach 512 — the next session's
Chromium gets killed mid-spawn when a fork() returns EAGAIN, surfacing
in the API as `Failed to launch the browser process: Code: null`.
Parameterize the ceiling via OPENWA_PIDS_LIMIT (default 2048, fits
~8-10 sessions with startup-spike headroom) and document it. The limit
is a cgroup pids.max ceiling, not an allocation, so raising it is a
no-op for light containers — Baileys (no Chromium, single-process)
uses only a handful of PIDs regardless. The fork-bomb guard stays
finite; -1 (unlimited) is explicitly discouraged.
Also adds a troubleshooting entry distinguishing the three causes of
`Code: null` (PID exhaustion vs OOM-kill vs the XDG/crashpad crash
already fixed in #254), since the dbus/crashpad noise in the log is
non-fatal and the real cause isn't visible without docker stats /
dmesg.
Several inline comments and docs carried short internal tracking codes (in the
compose files, .env.example, the queue/baileys modules, the eslint config, and a
few docs pages) that mean nothing to outside readers. Dropped the codes; the
explanatory text they annotated is unchanged. No runtime behaviour changes.
Forward ENGINE_TYPE into the container again (- ENGINE_TYPE=${ENGINE_TYPE:-}) and treat a blank value as unset at boot, so an .env/host ENGINE_TYPE=baileys is honoured while the dashboard's engine selection still wins when none is pinned. Stop shipping ENGINE_TYPE pre-pinned in .env.example; add 3-layer precedence tests. (#453)
The dev compose hardcoded most environment variables to fixed values,
so operators couldn't override them from the environment / .env without
editing the file. Convert user-facing settings to the ${VAR:-default}
pattern (keeping the same defaults), matching the production compose.
This includes the data-path settings that are documented in .env.example
(SESSION_DATA_PATH, STORAGE_LOCAL_PATH) — defaults stay under /app/data
(the ./data bind mount) so persistence is unchanged out of the box.
Truly container-internal values (HOME, XDG_*, PORT) stay fixed: they
must match the image/entrypoint, so they are intentionally not
parameterized — same convention as docker-compose.yml.
Chats: add CSP media-src so voice notes/videos play; fetch history with media so stickers/images/videos/documents render instead of empty bubbles; fix the mobile back-button icon (zero-width from inherited button padding).
Engine: relocate engine selection from the Plugins page to an Infrastructure > Engine tile; persist ENGINE_TYPE on save; fix /infra/status reading non-existent flat keys instead of engine.puppeteer.*; docker-compose no longer pins ENGINE_TYPE so the dashboard governs the active engine (.env.generated).
Dashboard: populate Messages Today and a new Total Messages card from /stats/overview; add a Message Analytics section (period selector + messages-over-time/by-type/top-chats charts, lazy-loaded so the charting bundle stays off the login path).
Misc: sidebar version read live from /health; plugin config modal uses centered segmented-pill tabs, a height-capped scrollable body with a pinned footer, and a wider layout; Sessions-tab radios no longer stretch full-width; Plugins subtitle reflects extensions-only.
Plugin platform: richer config schema + sandboxed-iframe config editor,
per-session activation and config, SSRF-guarded ctx.net.fetch, and removal of
the bundled reference extensions in favour of the marketplace.
Engine & reliability: whatsapp-web.js stuck-auth self-heal + WWEBJS_WEB_VERSION
pin; inbound media size-capped before buffering with bounded concurrency on
both engines.
Dashboard: chat history backfill (engine + DB merge), single-pane mobile chat
flow with a back control, page stylesheets scoped per page (no cross-page CSS
collisions, with a regression guard), consistent keyboard focus, searchable
plugin catalog, full audit-log CSV export, header layout fix, and copy /
empty-state refinements.
Hardening: per-session scope enforced on the stats overview and plugin
activation; composite secret config fields fully masked; owner-only plugin
storage; assorted sandbox/installer robustness fixes.
Bumps version to 0.7.0; CHANGELOG updated.
Probe-based reconcile promotes the engine to READY when whatsapp-web.js misses the 'ready' event after authentication (#251/#273); version pin stays opt-in. Includes maintainer fold-in hardening: timeout-safe probe (hung getState can't stall the 90s deadline), single reconcile window on repeated 'authenticated', and a teardown-safe authenticated guard.
The configurable first-boot init timeout (#353) shipped in 0.4.7 was not
forwarded into the container by either compose file, so an operator
following .env.example / the troubleshooting docs saw no effect — the
engine kept the 30000ms default in the recommended Docker path. Pass it
through in docker-compose.yml and docker-compose.dev.yml (unset = empty =
default, mirroring the WWEBJS_WEB_VERSION passthrough).
Also tighten resolveAuthTimeoutMs() to require a positive safe integer: a
huge digit string coerces to Infinity and MAX_SAFE_INTEGER+1 is unsafe;
both passed the /^\d+$/ shape check and would have made whatsapp-web.js's
inject loop wait effectively forever. They now fall back to the default.
Serve the built dashboard from NestJS via @nestjs/serve-static so a single
container/port (2785) serves both the API and the UI. ServeStaticModule is
registered only when dashboard/dist/index.html exists (opt out with
SERVE_DASHBOARD=false) and excludes /api and /socket.io so they keep returning
real API/socket responses. main.ts logs a clear status line (served / disabled
/ build missing) so a missing build is obvious instead of a silent 404.
The Dockerfile builds the dashboard in its builder stage (npm ci, reproducible)
and copies dist into the runtime image. Remove the separate dashboard nginx
container and its files; Traefik now routes everything to the API.
Dev is unchanged: the Vite dev server on :2886 with HMR proxies /api and
/socket.io to the API on :2785. Split-origin hosting still works via
VITE_API_URL. Add build:all and prod scripts for non-Docker production runs, a
serve-static regression test, a CHANGELOG entry with migration notes, and
updated docs.
Sessions stuck at "authenticating" after scanning the QR (whatsapp-web.js 1.34.x
auto-selecting an incompatible WA-Web version, #251) are fixed by pinning
WWEBJS_WEB_VERSION — but the Compose files enumerate env vars explicitly and have
no env_file, so the var set in .env never reached the container. The documented
workaround was therefore a no-op for every Docker Compose user (the most common
deployment), which is why the issue keeps recurring (#273).
Add WWEBJS_WEB_VERSION + WWEBJS_WEB_VERSION_REMOTE_PATH passthrough to both
docker-compose.yml and docker-compose.dev.yml. Empty default = auto-select, so no
behavior change when unset; a comment points to the troubleshooting FAQ.
Fixes Chromium failing to launch on hardened containers and the dark-mode
Login language selector.
fix(docker): give Chromium writable XDG config/cache dirs so it launches on
hardened read_only containers (#254)
Chromium resolves its home dir via glibc getpwuid() and ignores $HOME, so the
home-less openwa user pointed it at a nonexistent /home/openwa, causing a hard
SIGTRAP/int3 at launch (logged as "chrome_crashpad_handler: --database is
required"). Set XDG_CONFIG_HOME/XDG_CACHE_HOME to writable dirs that the
entrypoint pre-creates owned by openwa. Verified on the reporter's host. Removes
the ineffective --crash-dumps-dir approach from 0.2.5 (a confirmed no-op for the
crashpad database on Debian/Ubuntu system Chromium).
fix(dashboard): make the Login language <select> popup legible in dark mode (#249)
The login route never sets data-theme, so it relied solely on the
prefers-color-scheme media block, which set dark colors but left color-scheme
ambiguous, rendering the native option popup light with light text.
Consolidates 15 workstreams for v0.2.0. Full notes in CHANGELOG.md; contributors credited there and on the 16 superseded PRs. 292 backend tests, tsc/eslint/dashboard all green; deep adversarial review completed and findings fixed.
Closes#226, #222, #221, #220, #219, #168, #162, #155, #100, #93, #69
OpenWA: Open Source WhatsApp API Gateway
Features:
- Multi-session WhatsApp management
- RESTful API with full messaging support
- Web Dashboard for session management
- PostgreSQL/SQLite database support
- Redis cache and Bull job queue
- Webhook system with retry mechanism
- Plugin architecture for extensibility
- Docker-ready deployment
- n8n community node integration
This is the initial public release (v0.1.0).