feat(plugins): per-session config overrides - #441
Merged
Conversation
A session-scoped plugin can now carry per-session config overrides on top of its
base ('*') config. ctx.config is the resolved slice for the firing session:
resolvePluginConfig(base, sessionConfig, sessionId, sessionScoped) shallow-merges
the override over the base (global plugins / non-session events get the base).
- ctx.config is now a getter. In-process: an AsyncLocalStorage carries the firing
sessionId across the hook handler so the getter resolves the right slice, even
when events for different sessions interleave (no shared-state race). Sandboxed:
the host ships the resolved slice on each hook dispatch (new optional protocol
field) and the worker scopes it via its own AsyncLocalStorage during the call;
ctx.config falls back to the base outside a hook (lifecycle/onConfigChange).
- Storage: PluginInstance/registry gain sessionConfig (keyed by sessionId),
persisted and reloaded across restart.
- API: PUT /plugins/:id/config/:sessionId (ADMIN) sets/clears a session override;
the DTO exposes sessionConfig with secrets redacted per slice. Secret restore
applies per slice (a sentinel keeps the stored per-session secret).
Tests: resolvePluginConfig unit; worker integration (slice delivery + interleave
race-safety); service (override store, per-slice redact/restore, clear). Full
gate green (tsc + lint + 1257 jest).
…00 for global per-session config Review of the per-session config backend found two issues: - HIGH: ensureRegistryEntry rebuilt the registry entry on every load preserving only config + installedAt, so it wiped activeSessions AND sessionConfig from disk each boot — an operator's per-session activation/config was silently lost after the second restart (the first still had the pre-wipe in-memory copy). Now carries both over. This also fixes the same pre-existing data loss for #438's per-session activation. Regression test exercises two reload cycles. - LOW: per-session config on a global (sessionScoped:false) plugin now returns 400 (mirrors PUT /:id/sessions) instead of 200 {success:false}. Full gate green (tsc + lint + 1259 jest).
rmyndharis
added a commit
that referenced
this pull request
Jun 23, 2026
…sions (#442) The first-party bundled extensions are superseded by the marketplace plugins chat-flow (interactive auto-reply) and group-translate (LibreTranslate group translation), which target the v0.7 contract. Removes src/plugins/extensions and unwires ExtensionsModule from the app. BREAKING: an operator with auto-reply or translation enabled must install the marketplace replacement (Plugins -> Catalog) and re-enter config. The ids auto-reply/translation stay reserved so an upload can't shadow them. Built-in engines (whatsapp-web.js, Baileys) are unaffected. Closes the v0.7 plugin-contract core work (#437-#441 + this removal).
rmyndharis
added a commit
that referenced
this pull request
Jun 23, 2026
* docs(spec): per-session plugin dashboard UI design (v0.7) * feat(plugins): per-session activation + config UI in the dashboard Surfaces the two per-session backends that shipped without UI (#438 activation, #441 per-session config) in a tabbed plugin config modal. - API client: Plugin type gains sessionScoped/activeSessions/sessionConfig; setSessions (PUT /plugins/:id/sessions) + updateSessionConfig (PUT /plugins/:id/config/:sessionId). - Config modal: session-scoped non-engine plugins get [Configuration] [Sessions] tabs. Configuration is unchanged. Sessions has: - Activation: All sessions vs a chosen set -> setSessions. - Per-session config override (when the plugin has a configSchema/configUi): a session picker -> the B-lite form (seeded from the resolved slice) or the sandboxed iframe (sessionId passed to the bridge). Save writes only non-secret keys that differ from base + all secret keys (sparse override: untouched keys inherit Global; the backend restores untouched secrets), so editing Global still propagates to a session's unchanged fields. Clear override resets to Global. - i18n: 13 new keys across all 9 locales (parity gate passes). CSS for tabs + Sessions sections. Dashboard-only; all backend APIs pre-existed. Build (tsc) + lint + i18n parity green. * fix(plugins): harden per-session config (deep-merge, sparse override, live modal state) Adversarial review of the per-session dashboard UI found 7 issues; fixes: - HIGH: resolvePluginConfig now DEEP-merges the override over the base (was shallow). A sparse override (e.g. an object whose nested secret the operator didn't touch) no longer drops the base's untouched nested keys at runtime; arrays/scalars still replace. (+unit tests for nested merge + array replace.) - The per-session override save is centralized in sparseSessionOverride(): only non-secret keys that differ from base, plus all secret keys; an untouched optional field with no Global value no longer creates a spurious empty override. Used by BOTH the B-lite form and the iframe per-session save (the iframe path was writing every resolved key as an override). - The config modal derives the open plugin from the LIVE plugins query instead of an open-time snapshot, so the Sessions tab reflects fresh activeSessions/sessionConfig after a save + invalidate. - The per-session form renders the localized schema (labels/descriptions), matching the Configuration tab. Backend tsc + 1231 jest; dashboard build + lint + i18n parity green. * fix(plugins): don't wipe in-progress per-session edits on a background refetch configPlugin is derived from the live query, so the plugin prop gets a new reference on every refetch (refetchOnWindowFocus). Key the override-seed effect on [selSession, plugin.id] instead of the plugin object so it reseeds only when the selected session/plugin changes — a window-focus refetch mid-edit no longer clobbers the operator's unsaved override. * docs(plugins): scope sparseSessionOverride inherit guarantee (array-of-rows secret caveat) Re-review noted the docstring overstated the 'untouched secret inherits' promise: it holds for top-level + object-nested secrets, but NOT a secret column inside an array-of-rows (arrays replace wholesale on resolve; the redacted dashboard can't resend untouched rows' real secrets). Documented the limitation; no bundled plugin ships that shape. Comment-only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A session-scoped plugin can carry per-session config overrides on top of its base (
'*') config. A hook for session S seesctx.config= the override shallow-merged over the base (resolvePluginConfig); global plugins and non-session events get the base unchanged.How
ctx.configbecomes per-session (race-safe)ctx.configis now a getter.AsyncLocalStoragecarries the firingsessionIdacross the hook handler, so the getter resolves the right slice even when events for different sessions interleave across anawait— no shared mutable state.configfield on thehookmessage); the worker scopes it via its ownAsyncLocalStoragefor the duration of the call, falling back to the base outside a hook (lifecycle /onConfigChange).Storage & API
PluginInstance/ registry gainsessionConfig(keyed bysessionId), persisted and reloaded across restart.PUT /plugins/:id/config/:sessionId(ADMIN) sets/clears a session override (empty = clear; global plugin → 400). The DTO exposessessionConfigwith secrets redacted per slice; secret restore applies per slice. The resolved slice handed to the worker carries the real secrets (the worker is the plugin's trusted execution context); the API/dashboard only ever see redacted values.Review
Built TDD; an adversarial review (3 dimensions → per-finding verify) found 2 issues, both fixed here:
ensureRegistryEntrywipedactiveSessions+sessionConfigfrom disk on every boot (override lost after the 2nd restart) — also a pre-existing bug for feat(plugins): per-session activation (system-wide or per-number) #438's activation. Now preserved; regression test runs two reload cycles.PUT /:id/sessions).Testing
resolvePluginConfigunit (merge/short-circuit/no-mutation); worker integration (slice delivery + interleave race-safety); service (override store, per-slice redact/restore, clear, restart-persistence, global→400).tsc --noEmit -p tsconfig.build.json+ lint + 1259 jest; dashboard build + lint.Part of v0.7 plugin-contract. The dashboard per-session editor follows as a separate PR.