Skip to content

feat(plugins): per-session config overrides - #441

Merged
rmyndharis merged 2 commits into
mainfrom
feat/plugin-per-session-config
Jun 23, 2026
Merged

feat(plugins): per-session config overrides#441
rmyndharis merged 2 commits into
mainfrom
feat/plugin-per-session-config

Conversation

@rmyndharis

Copy link
Copy Markdown
Owner

What

A session-scoped plugin can carry per-session config overrides on top of its base ('*') config. A hook for session S sees ctx.config = the override shallow-merged over the base (resolvePluginConfig); global plugins and non-session events get the base unchanged.

How ctx.config becomes per-session (race-safe)

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 across an await — no shared mutable state.
  • Sandboxed worker: the host resolves the slice and ships it on each hook dispatch (new optional config field on the hook message); the worker scopes it via its own AsyncLocalStorage for the duration of the call, falling back to the base outside a hook (lifecycle / onConfigChange).

Storage & API

  • PluginInstance / registry gain sessionConfig (keyed by sessionId), persisted and reloaded across restart.
  • PUT /plugins/:id/config/:sessionId (ADMIN) sets/clears a session override (empty = clear; global plugin → 400). The DTO exposes sessionConfig with 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:

  • HIGH: ensureRegistryEntry wiped activeSessions + sessionConfig from 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.
  • LOW: global-plugin rejection now returns 400 (consistent with PUT /:id/sessions).

Testing

  • resolvePluginConfig unit (merge/short-circuit/no-mutation); worker integration (slice delivery + interleave race-safety); service (override store, per-slice redact/restore, clear, restart-persistence, global→400).
  • Full gate green: backend 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.

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
rmyndharis merged commit 6a4e760 into main Jun 23, 2026
5 checks passed
@rmyndharis
rmyndharis deleted the feat/plugin-per-session-config branch June 23, 2026 03:56
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant