36 Commits

Author SHA1 Message Date
Yudhi Armyndharis 47399d9d81 fix(config): let the plugin registry be redirected off the shared data dir
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.
2026-08-19 17:09:24 +07:00
Yudhi Armyndharis 9270a3e3fb fix(webhook): bound the outbound delivery record
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.
2026-08-19 15:01:08 +07:00
Yudhi Armyndharis 5f3b0d8481 feat(webhook): make an outbound delivery survive a hard crash
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.
2026-08-19 13:56:13 +07:00
Giovanni Orciuolo 5b68034e50 fix(baileys): address maintainer review on version resolver tiers, timeouts, proxy and env registration 2026-08-18 22:25:22 +07:00
Yudhi Armyndharis 5b26fa4cff fix(security): bound the ingress route on the client IP too
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.
2026-08-17 23:36:56 +07:00
Yudhi Armyndharis 25bbe8a043 fix(config): forward every documented runtime knob through the bundled compose files
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.
2026-08-16 19:00:59 +07:00
Yudhi Armyndharis c352fcb268 fix(config): close three gaps this branch left in its own knobs
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.
2026-08-12 21:20:39 +07:00
Yudhi Armyndharis 12a834e6ec fix(docker): forward the inbound-media knobs through compose
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.
2026-08-12 21:00:08 +07:00
Yudhi Armyndharis 0aa77082c6 feat(chat-media): archive media this account sent, behind CHAT_MEDIA_ARCHIVE_OUTBOUND
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.
2026-08-09 16:29:27 +07:00
Yudhi Armyndharis 89b9cb8de6 fix(compose): forward the last six unreachable feature flags
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.
2026-08-07 16:00:41 +07:00
Yudhi Armyndharis d6a1873558 fix(compose): forward the flags a .env value could not otherwise reach
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.
2026-08-07 15:50:57 +07:00
Yudhi Armyndharis ec172289b6 fix: close the pre-release review's ownership races, breaker over-counting and backup gap
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.
2026-08-05 02:23:14 +07:00
Yudhi Armyndharis 536796cc32 fix(plugins): put installed plugin code in the same tree as the plugin registry (#1036)
* 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
2026-08-01 14:46:31 +07:00
Yudhi Armyndharis 1b60ad3939 fix(docker): forward plugin redirect policy 2026-07-30 13:57:31 +07:00
Yudhi Armyndharis 76664470e2 fix(engine): disclose the remote-HTML trust posture of the default WA Web pin (#917) 2026-07-27 08:28:00 +07:00
Yudhi Armyndharis 824bd522e1 fix(compose): forward BODY_SIZE_LIMIT into both containers (#832)
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.
2026-07-20 12:55:11 +07:00
Yudhi Armyndharis fa8d863b88 v0.9.0: message reliability, dashboard UX consolidation, and dead-code cleanup (#802)
* 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).
2026-07-19 16:25:49 +07:00
Yudhi Armyndharis c00cddf9a9 feat: harden runtime reliability and cross-layer contracts (#795)
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.
2026-07-18 14:05:58 +07:00
Yudhi Armyndharis 8f1343bb2c fix: reliability + config hardening batch (Docker quickstart #677 + bounded search/SSRF/auth + CI gate) (#680)
* 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
2026-07-10 11:19:34 +07:00
Yudhi Armyndharis 6e5891a9ad fix(docker): raise pids_limit default + make it configurable (#636) (#652)
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.
2026-07-07 08:06:16 +07:00
Yudhi Armyndharis a0a0fe91ab chore: remove internal review-reference codes from comments and docs (#481)
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.
2026-06-26 15:48:29 +07:00
Yudhi Armyndharis 9ff6ad0d58 fix(engine): honour an environment ENGINE_TYPE after the compose change (#460)
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)
2026-06-24 10:26:07 +07:00
MS-Jahan 96b4998fe7 fix(compose): make dev compose env vars overridable
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.
2026-06-24 01:43:23 +07:00
Yudhi Armyndharis 4b6bdc1c32 fix(dashboard): chat media, engine→Infrastructure, dashboard analytics, and config-modal polish
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.
2026-06-24 01:43:23 +07:00
Yudhi Armyndharis 90a8d95ec2 release: v0.7.0 — plugin contract, reliability, and dashboard polish (#448)
Release / Test Gate (push) Has been cancelled
Release / GitHub Release (push) Has been cancelled
Release / Docker Release (push) Has been cancelled
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.
2026-06-23 22:43:32 +07:00
aeyeio f9f8575750 fix(engine): reconcile whatsapp-web.js ready after auth (#415)
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.
2026-06-23 13:17:39 +07:00
Yudhi Armyndharis 23d4276a7f fix(engine): make WWEBJS_AUTH_TIMEOUT_MS effective in Docker and bound its parsing (#393)
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.
2026-06-21 13:36:21 +07:00
Tobias Strebitzer 83a90ff5a0 feat(dashboard): serve bundled SPA from NestJS on the API port
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.
2026-06-18 16:47:10 +07:00
Abhishekrajpurohit cc95f76a28 Merge remote-tracking branch 'origin/main' 2026-06-17 20:11:35 +05:30
Yudhi Armyndharis 5132784434 fix(docker): pass WWEBJS_WEB_VERSION through to the container (#273) (#274)
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.
2026-06-17 09:03:05 +07:00
Abhishekrajpurohit c531560913 fix(dashboard): mount local nginx.conf in dev compose to avoid rebuilding 2026-06-16 23:26:54 +05:30
Yudhi Armyndharis b90b4d139c chore(release): v0.2.6 (#258)
Release / Test Gate (push) Has been cancelled
Release / GitHub Release (push) Has been cancelled
Release / Docker Release (push) Has been cancelled
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.
2026-06-16 12:32:59 +07:00
Stanl3y 8888ec101e fix(docker): make dev bind host configurable via BIND_HOST (#245)
Dev compose binds 127.0.0.1 by default; set BIND_HOST=0.0.0.0 in .env for remote access. Thanks @Stanley-blik.
2026-06-15 22:26:02 +07:00
Yudhi Armyndharis 65022bc71e release: v0.2.2 — security hardening, reliability & metrics (#243)
Release / Test Gate (push) Has been cancelled
Release / GitHub Release (push) Has been cancelled
Release / Docker Release (push) Has been cancelled
Security-hardening + reliability release: SSRF guard (default on) on media/webhook fetches, secret/headers redaction, required datastore secrets, container hardening, /api/health/ready, optional Prometheus metrics, Docker non-root Chromium fix (closes #242), per-capability services, and a dependency refresh (@bull-board v8 et al.). See CHANGELOG.md. Closes #242.
2026-06-15 20:46:27 +07:00
Yudhi Armyndharis 04a7be6fe6 Release v0.2.0 — i18n, real-time chats, webhooks & delivery state, templates, security & container hardening (#229)
Release / Test Gate (push) Has been cancelled
Release / GitHub Release (push) Has been cancelled
Release / Docker Release (push) Has been cancelled
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
2026-06-15 09:09:00 +07:00
rmyndharis c5bd4b5fec Initial commit - OpenWA v0.1.0
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).
2026-02-05 15:56:02 +07:00