@jasonwch/nodebb-plugin-meilisearch-r 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
  EMBEDDER_TASK_TIMEOUT_MS, EMBEDDER_TASK_BG_TIMEOUT_MS, EMBEDDER_TASK_BG_INTERVAL_MS,
13
13
  MEILI_HTTP_TIMEOUT_MS,
14
14
  } = require('./constants');
15
+ const { redactSecrets } = require('./redact');
15
16
 
16
17
  async function ensureIndex(plugin, uid, primaryKey) {
17
18
  try {
@@ -69,15 +70,24 @@ function startEmbedderPoller(plugin, plan, taskUid) {
69
70
  });
70
71
  embedderPollers.delete(taskUid);
71
72
  if (task.status !== 'succeeded') {
72
- // Delayed failure: fingerprint was optimistically persisted at enqueue time
73
- // (see plugin.updateEmbedders catch-block). Clear it so the next save pushes
74
- // fresh config rather than silently skipping as "already applied".
73
+ // C1c fix: redact any apiKey that Meilisearch may echo in task.error.message before
74
+ // sending to admin toasts. Issue 2 admin-room filter already limits toast recipients,
75
+ // but defense-in-depth handles the case where Meilisearch wraps the apiKey in the error.
76
+ // A3-style inner try/catch: if redaction itself fails, fall back to raw reason rather
77
+ // than crashing the poller's failure-handling path.
78
+ const rawReason = task.error?.message || `task ${task.status}`;
79
+ let safeReason;
80
+ try {
81
+ safeReason = await redactSecrets(rawReason, plugin);
82
+ } catch {
83
+ safeReason = rawReason;
84
+ }
75
85
  await clearAppliedConfig(plugin, plan.indexName);
76
86
  plugin.healthy = false;
77
87
  plugin.notifyAdmins(`embedder:${plan.indexName}`, {
78
88
  type: 'danger',
79
89
  titleKey: '[[meilisearch:admin.semanticEmbedderDelayedFailed]]',
80
- message: `${plan.indexName}: ${task.error?.message || `task ${task.status}`}`,
90
+ message: `${plan.indexName}: ${safeReason}`,
81
91
  });
82
92
  } else {
83
93
  // Delayed success: mark taskUid as null (sync-verified) so reattachPollers
@@ -98,13 +108,30 @@ function startEmbedderPoller(plugin, plan, taskUid) {
98
108
  return;
99
109
  }
100
110
  // Network error or other non-timeout failure — task status is genuinely unknown.
101
- // Don't clear the fingerprint on a transient blip (would cause unnecessary re-push).
102
- // If the task actually failed, reattachPollers will catch it on the next restart
103
- // via the persisted taskUid → getTask(uid) → status: "failed" → clearAppliedConfig.
104
- winston.warn(`[plugin/meilisearch] Embedder poller for "${plan.indexName}" (task ${taskUid}) hit an error (non-fatal, fingerprint preserved): ${err.message}`);
111
+ // C1 fix: redact any apiKey echoed in err.message before logging.
112
+ // A3-style inner try/catch: fall back to raw err.message if redaction itself fails.
113
+ const rawPollerErr = err.message;
114
+ let safePollerErr;
115
+ try {
116
+ safePollerErr = await redactSecrets(rawPollerErr, plugin);
117
+ } catch {
118
+ safePollerErr = rawPollerErr;
119
+ }
120
+ winston.warn(`[plugin/meilisearch] Embedder poller for "${plan.indexName}" (task ${taskUid}) hit an error (non-fatal, fingerprint preserved): ${safePollerErr}`);
105
121
  }
106
122
  };
107
- poll().catch(err => winston.error(`[plugin/meilisearch] embedder poller crashed: ${err.message}`));
123
+ poll().catch(async err => {
124
+ // A3: inner try/catch makes the redaction layer airtight against future regressions
125
+ // where redactSecrets itself might throw (currently fully guarded internally, but
126
+ // defense-in-depth ensures the log path still runs).
127
+ let safeCrashErr;
128
+ try {
129
+ safeCrashErr = await redactSecrets(err.message, plugin);
130
+ } catch {
131
+ safeCrashErr = '<redaction failed>';
132
+ }
133
+ winston.error(`[plugin/meilisearch] embedder poller crashed: ${safeCrashErr}`);
134
+ });
108
135
  }
109
136
 
110
137
  // Recover embedder pollers after process restart. embedderPollers (line 42) is module-level
@@ -135,13 +162,22 @@ async function reattachPollers(plugin) {
135
162
  continue;
136
163
  }
137
164
  // Task failed during downtime — clear stale fingerprint + alert
138
- winston.warn(`[plugin/meilisearch] Embedder task ${taskUid} for "${indexName}" failed during downtime: ${task.error?.message || task.status}`);
165
+ // C1b fix: redact any apiKey from task.error.message before logging + alerting.
166
+ // A3-style inner try/catch: fall back to raw reason if redaction itself fails.
167
+ const rawFailReason = task.error?.message || `task ${task.status}`;
168
+ let safeFailReason;
169
+ try {
170
+ safeFailReason = await redactSecrets(rawFailReason, plugin);
171
+ } catch {
172
+ safeFailReason = rawFailReason;
173
+ }
174
+ winston.warn(`[plugin/meilisearch] Embedder task ${taskUid} for "${indexName}" failed during downtime: ${safeFailReason}`);
139
175
  await clearAppliedConfig(plugin, indexName);
140
176
  plugin.healthy = false;
141
177
  plugin.notifyAdmins(`embedder:${indexName}`, {
142
178
  type: 'danger',
143
179
  titleKey: '[[meilisearch:admin.semanticEmbedderDelayedFailed]]',
144
- message: `${indexName}: ${task.error?.message || `task ${task.status}`}`,
180
+ message: `${indexName}: ${safeFailReason}`,
145
181
  });
146
182
  } catch (err) {
147
183
  // Non-fatal: taskUid may be 404 (purged from Meilisearch's ~24h history), or
@@ -198,6 +234,10 @@ module.exports = function attachClient(plugin) {
198
234
  if (!plugin.healthy) {
199
235
  winston.warn('[plugin/meilisearch] MeiliSearch host is unhealthy');
200
236
  } else {
237
+ // B2b: SDK write/health error path — drainPending replay errors may contain
238
+ // document content snippets from already-indexed posts (rare; only if
239
+ // Meilisearch rejects a batch quoting the bad doc). Accepted risk for
240
+ // low-frequency replay failures; document content is already-in-DB data.
201
241
  drainPending(plugin).catch(err => winston.error(`[plugin/meilisearch] drain failed: ${err.message}`));
202
242
  }
203
243
  return plugin.healthy;
@@ -219,6 +259,8 @@ module.exports = function attachClient(plugin) {
219
259
  await ensureIndex(plugin, 'chat_message', 'mid');
220
260
  await plugin.client.index('chat_message').updateFilterableAttributes(['roomId', 'uid', 'timestamp']);
221
261
  } catch (err) {
262
+ // B2b: SDK settings error path — updateFilterableAttributes errors don't typically
263
+ // echo credentials or document content; accepted risk for low-frequency settings failures.
222
264
  winston.error(`[plugin/meilisearch] ensureChatMessageIndex failed: ${err.message}`);
223
265
  plugin.healthy = false;
224
266
  }
@@ -423,7 +465,17 @@ module.exports = function attachClient(plugin) {
423
465
  }
424
466
  return { ...plan, taskUid: null }; // sync-verified success — no reconciliation needed
425
467
  } catch (err) {
426
- const reason = err.message;
468
+ // C1a fix: redact any apiKey from err.message before logging + alerting.
469
+ // Meilisearch may echo the configured apiKey in 401/403 error responses.
470
+ // A3-style inner try/catch: fall back to raw err.message if redaction itself fails,
471
+ // preserving the catch block's responsibility (alert + return null).
472
+ const rawReason = err.message;
473
+ let reason;
474
+ try {
475
+ reason = await redactSecrets(rawReason, plugin);
476
+ } catch {
477
+ reason = rawReason;
478
+ }
427
479
  winston.error(`[plugin/meilisearch] Failed to apply "${plan.indexName}" embedder (semantic search): ${reason}`);
428
480
  plugin.notifyAdmins(`embedder:${plan.indexName}`, {
429
481
  type: 'danger',
@@ -146,8 +146,11 @@ module.exports = function attachPostHooks(plugin) {
146
146
  },
147
147
  { batch: REINDEX_BATCH_SIZE },
148
148
  );
149
- } catch (err) {
150
- winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`);
149
+ } catch (err) {
150
+ // B2b: SDK write error path — may contain document content snippets from already-indexed
151
+ // posts (rare; only if Meilisearch rejects a batch quoting the bad doc). Accepted risk
152
+ // for low-frequency write failures; document content is already-in-DB data.
153
+ winston.error(`[plugin/meilisearch] reindexTopicPosts failed: ${err.message}`);
151
154
  plugin.healthy = false;
152
155
  await enqueuePending('reindexTopicPosts', { tid });
153
156
  }
@@ -172,7 +175,10 @@ module.exports = function attachPostHooks(plugin) {
172
175
  await postIndex.deleteDocuments(mainPidArr);
173
176
  }
174
177
  } catch (err) {
175
- winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`);
178
+ // B2b: SDK write error path — may contain document content snippets from already-indexed
179
+ // posts (rare; only if Meilisearch rejects a batch quoting the bad doc). Accepted risk
180
+ // for low-frequency write failures; document content is already-in-DB data.
181
+ winston.error(`[plugin/meilisearch] deindexTopicPosts failed: ${err.message}`);
176
182
  plugin.healthy = false;
177
183
  await enqueuePending('deindexTopicPosts', { tid });
178
184
  }
@@ -147,6 +147,10 @@ module.exports = function attachTopicHooks(plugin) {
147
147
  postIndex.deleteDocuments(orphanPids));
148
148
  }
149
149
  } catch (err) {
150
+ // B2b: SDK write error path — may contain document content snippets from
151
+ // already-indexed posts (rare; only if Meilisearch rejects a batch quoting
152
+ // the bad doc). Accepted risk for low-frequency write failures; document
153
+ // content is already-in-DB data.
150
154
  winston.error(`[plugin/meilisearch] onTopicMerge orphan cleanup for tid ${tid} failed: ${err.message}`);
151
155
  plugin.healthy = false;
152
156
  // Don't enqueue — orphans are cleaned by next reindex. Continue to next tid.
@@ -202,6 +206,10 @@ module.exports = function attachTopicHooks(plugin) {
202
206
  );
203
207
  }));
204
208
  } catch (err) {
209
+ // B2b: SDK write error path — may contain document content snippets from
210
+ // already-indexed posts (rare; only if Meilisearch rejects a batch quoting
211
+ // the bad doc). Accepted risk for low-frequency write failures; document
212
+ // content is already-in-DB data.
205
213
  winston.error(`[plugin/meilisearch] deindexTopicsPurge failed: ${err.message}`);
206
214
  plugin.healthy = false;
207
215
  await enqueuePending('deindexTopicsPurge', reducedPayload);
package/lib/redact.js ADDED
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // Credential redaction for log/alert paths.
4
+ //
5
+ // Background: M5 + C1 fix. Meilisearch task error messages and settings diff logs may
6
+ // echo the configured apiKey (e.g. "Unauthorized: key sk-abc123" or nested in JSON).
7
+ // Without redaction, these flow to:
8
+ // - winston transports (stdout, logs/output.log, /admin/developer/logs)
9
+ // - log aggregators (Loki/ELK/Datadog) which sit OUTSIDE the admin trust boundary
10
+ // - admin ACP toasts (15s transient — already mitigated by Issue 2 admin-room filter)
11
+ //
12
+ // Strategy: dual-layer redaction
13
+ // 1. Pattern-based regex: catches known API key shapes (OpenAI sk-*, Bearer tokens)
14
+ // even when we can't read the configured value (e.g. message logged before init)
15
+ // 2. Value-based substitution: reads semanticSearchApiKey from settings and string-
16
+ // replaces any literal occurrence — catches custom REST tokens, future OpenAI
17
+ // key formats, and exotic auth schemes that don't match the regex.
18
+ //
19
+ // Value-based redaction requires a settings.getOne DB read on first call; cached for
20
+ // 5 min so throttled error paths (notifyAdmins throttles 60s/key) incur ≤1 DB read/min.
21
+
22
+ const settings = nodebb.require('./src/meta/settings');
23
+
24
+ // Fields whose VALUES must never appear in winston diff logs (lib/settings.js isBreaking).
25
+ // Listed by ACP field name. semanticSearchUrl is intentionally NOT included — it's an
26
+ // internal hostname, not a credential, and admins need it for debugging connectivity.
27
+ const SECRET_SETTINGS = new Set([
28
+ 'semanticSearchApiKey',
29
+ 'semanticSearchRestRequest',
30
+ 'semanticSearchRestResponse',
31
+ ]);
32
+
33
+ const REDACTION_PLACEHOLDER = '<redacted>';
34
+
35
+ // Patterns to catch common API key formats in free-form error strings.
36
+ // OpenAI keys: sk-*, sk-proj-*, sk-org-*, sk-svcacct-* — all start with "sk-"
37
+ // and are at least 20 chars long (real keys are 40+ but we redact aggressively at 20).
38
+ const KEY_PATTERNS = [
39
+ /\bsk-[a-zA-Z0-9_-]{20,}\b/g,
40
+ /\bBearer\s+[a-zA-Z0-9_-]{8,}\b/gi,
41
+ ];
42
+
43
+ // Min length for value-based redaction — avoids replacing short strings that might
44
+ // legitimately appear in error messages (e.g. an apiKey of "ab" would corrupt many
45
+ // error strings). 8 chars is safely above any plausible short substring.
46
+ const MIN_VALUE_REDACT_LENGTH = 8;
47
+
48
+ // Cached configured semanticSearchApiKey to avoid hitting DB on every redact call.
49
+ // TTL ensures we pick up rotations within 5 min. Cached as the raw string value
50
+ // (or '' when no key configured) so we can distinguish "cached empty" from "not cached".
51
+ let _cachedApiKey = null;
52
+ let _cachedAt = 0;
53
+ const CACHE_TTL_MS = 5 * 60 * 1000;
54
+
55
+ async function getCachedApiKey(plugin) {
56
+ const now = Date.now();
57
+ if (_cachedApiKey !== null && now - _cachedAt < CACHE_TTL_MS) {
58
+ return _cachedApiKey;
59
+ }
60
+ try {
61
+ // A1 fix: trim the cached value to match the on-wire key (embedder.js get() trims
62
+ // before sending to Meilisearch, so any echo in errors is the trimmed form).
63
+ // Without this, a padded stored key (" sk-abc ") would defeat value-based redaction
64
+ // when Meilisearch echoes the trimmed key ("sk-abc") in a 401 error.
65
+ _cachedApiKey = ((await settings.getOne(plugin.id, 'semanticSearchApiKey')) || '').trim();
66
+ _cachedAt = now;
67
+ } catch (e) {
68
+ // A2 fix: don't set _cachedAt on the failure path — let the next call retry immediately
69
+ // instead of cycling through the full 5-min TTL with a stale empty cache. Pattern-based
70
+ // redaction still runs, so sk-* and Bearer-shaped keys stay protected during the blip.
71
+ _cachedApiKey = '';
72
+ }
73
+ return _cachedApiKey;
74
+ }
75
+
76
+ // Test-only hooks (exported for the test sandbox; not consumed by production code paths).
77
+ function _resetCache() {
78
+ _cachedApiKey = null;
79
+ _cachedAt = 0;
80
+ }
81
+
82
+ function _setCachedApiKey(value) {
83
+ _cachedApiKey = (value || '').trim();
84
+ _cachedAt = Date.now();
85
+ }
86
+
87
+ // Redact known secret patterns + the configured apiKey value from a free-form string.
88
+ // Used by winston log calls and notifyAdmins payloads so credentials don't leak
89
+ // when Meilisearch echoes them in error messages.
90
+ //
91
+ // Returns the redacted string. If `plugin` is omitted, only pattern-based redaction
92
+ // runs (used in early-init paths before plugin object is fully wired).
93
+ async function redactSecrets(message, plugin) {
94
+ if (typeof message !== 'string' || !message) return message;
95
+ let redacted = message;
96
+
97
+ // Layer 1: pattern-based — catches known formats even without DB access
98
+ for (const pattern of KEY_PATTERNS) {
99
+ redacted = redacted.replace(pattern, REDACTION_PLACEHOLDER);
100
+ }
101
+
102
+ // Layer 2: value-based — catches the exact configured apiKey regardless of format
103
+ if (plugin) {
104
+ const configuredKey = await getCachedApiKey(plugin);
105
+ if (configuredKey && configuredKey.length >= MIN_VALUE_REDACT_LENGTH) {
106
+ // Escape regex special chars in the key (keys can contain +, /, = for base64)
107
+ const escapedKey = configuredKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
108
+ redacted = redacted.replace(new RegExp(escapedKey, 'g'), REDACTION_PLACEHOLDER);
109
+ }
110
+ }
111
+
112
+ return redacted;
113
+ }
114
+
115
+ // Whole-value redaction for winston diff logs (lib/settings.js isBreaking).
116
+ // Returns '<redacted>' for credential-bearing fields, JSON.stringify for others.
117
+ // Preserves the "did it change?" audit signal without leaking the credential value.
118
+ function redactSettingForLog(setting, value) {
119
+ if (SECRET_SETTINGS.has(setting)) return REDACTION_PLACEHOLDER;
120
+ return JSON.stringify(value);
121
+ }
122
+
123
+ module.exports = {
124
+ redactSecrets,
125
+ redactSettingForLog,
126
+ SECRET_SETTINGS,
127
+ REDACTION_PLACEHOLDER,
128
+ // Test-only exports (not consumed by production code paths):
129
+ _resetCache,
130
+ _setCachedApiKey,
131
+ };
package/lib/search.js CHANGED
@@ -5,6 +5,7 @@ const settings = nodebb.require('./src/meta/settings');
5
5
  const plugins = nodebb.require('./src/plugins');
6
6
  const { resolveMinTermLength } = require('./settings-helpers');
7
7
  const { EMBEDDER_NAME, isSemanticSearchEnabled, getSemanticRatio, getSemanticScoreThreshold } = require('./embedder');
8
+ const { redactSecrets } = require('./redact');
8
9
 
9
10
  // rankingScoreThreshold is a top-level search parameter (a sibling of "hybrid", not nested
10
11
  // inside it) - Meilisearch defaults it to 0 (no filtering) when omitted, which is why
@@ -29,12 +30,23 @@ async function safeSearch(plugin, indexName, term, opts) {
29
30
  return await plugin.client.index(indexName).search(term, opts);
30
31
  } catch (err) {
31
32
  const reason = err?.message || String(err);
33
+ // B1 fix: redact any apiKey Meilisearch may echo in search error responses.
34
+ // Search errors typically return code+message rather than echoing credentials,
35
+ // but uniform posture with the embedder-task paths is cheap defense-in-depth.
36
+ // A3-style inner try/catch preserves safeSearch's "never throw" contract: if
37
+ // redaction itself fails, fall back to the raw reason rather than rejecting.
38
+ let safeReason;
39
+ try {
40
+ safeReason = await redactSecrets(reason, plugin);
41
+ } catch {
42
+ safeReason = reason;
43
+ }
32
44
  if (opts.hybrid) {
33
- winston.error(`[plugin/meilisearch] Hybrid search on "${indexName}" failed (${reason}); falling back to keyword-only search`);
45
+ winston.error(`[plugin/meilisearch] Hybrid search on "${indexName}" failed (${safeReason}); falling back to keyword-only search`);
34
46
  plugin.notifyAdmins(`search:${indexName}`, {
35
47
  type: 'danger',
36
48
  titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
37
- message: `${indexName}: ${reason}`,
49
+ message: `${indexName}: ${safeReason}`,
38
50
  });
39
51
  // rankingScoreThreshold is tuned for hybrid/vector scores - drop it too, or a
40
52
  // keyword-only fallback could wrongly filter out valid BM25-style matches.
@@ -42,15 +54,22 @@ async function safeSearch(plugin, indexName, term, opts) {
42
54
  try {
43
55
  return await plugin.client.index(indexName).search(term, keywordOnly);
44
56
  } catch (fallbackErr) {
45
- winston.error(`[plugin/meilisearch] Keyword-only fallback search on "${indexName}" also failed: ${fallbackErr.message}`);
57
+ const fallbackReason = fallbackErr?.message || String(fallbackErr);
58
+ let safeFallbackReason;
59
+ try {
60
+ safeFallbackReason = await redactSecrets(fallbackReason, plugin);
61
+ } catch {
62
+ safeFallbackReason = fallbackReason;
63
+ }
64
+ winston.error(`[plugin/meilisearch] Keyword-only fallback search on "${indexName}" also failed: ${safeFallbackReason}`);
46
65
  return null;
47
66
  }
48
67
  }
49
- winston.error(`[plugin/meilisearch] Search on "${indexName}" failed: ${reason}`);
68
+ winston.error(`[plugin/meilisearch] Search on "${indexName}" failed: ${safeReason}`);
50
69
  plugin.notifyAdmins(`search:${indexName}`, {
51
70
  type: 'danger',
52
71
  titleKey: '[[meilisearch:admin.semanticSearchDegraded]]',
53
- message: `${indexName}: ${reason}`,
72
+ message: `${indexName}: ${safeReason}`,
54
73
  });
55
74
  return null;
56
75
  }
package/lib/settings.js CHANGED
@@ -4,6 +4,7 @@ const winston = nodebb.require('winston');
4
4
  const settings = nodebb.require('./src/meta/settings');
5
5
  const { clampMinTermLength } = require('./settings-helpers');
6
6
  const { SEMANTIC_SETTING_KEYS, resetAppliedConfigs } = require('./embedder');
7
+ const { redactSettingForLog, redactSecrets } = require('./redact');
7
8
 
8
9
  function deepCompare(a, b) {
9
10
  if (a === null || b === null) return a === b;
@@ -47,7 +48,10 @@ module.exports = function attachSettings(plugin) {
47
48
  const stored = await settings.getOne(plugin.id, setting);
48
49
  const changed = !deepCompare(stored, value);
49
50
  if (changed) {
50
- winston.info(`[plugin/meilisearch] ${setting} changed: ${JSON.stringify(stored)} -> ${JSON.stringify(value)}`);
51
+ // M5 fix: redact credential-bearing fields (apiKey, REST templates) from winston diff log
52
+ // so admin key rotation doesn't leak old+new paid OpenAI keys to output.log/aggregators
53
+ // via the ACP Logs page and any streaming log aggregator (Loki/ELK/Datadog).
54
+ winston.info(`[plugin/meilisearch] ${setting} changed: ${redactSettingForLog(setting, stored)} -> ${redactSettingForLog(setting, value)}`);
51
55
  }
52
56
  return changed;
53
57
  }
@@ -100,17 +104,30 @@ module.exports = function attachSettings(plugin) {
100
104
  }
101
105
  } catch (err) {
102
106
  // #7: Don't throw — let settings persist to DB even when Meili is unreachable.
103
- winston.error(`[plugin/meilisearch] Error while saving settings: ${err.message}`);
107
+ // C1 fix: redact any apiKey that Meilisearch may echo in its error message before
108
+ // logging. Falls back to pattern-only redaction if settings DB is unreachable.
109
+ // A3-style inner try/catch: fall back to raw err.message if redaction itself fails,
110
+ // so the error still surfaces (just unredacted) rather than silently swallowing it.
111
+ const rawReason = err.message;
112
+ let safeReason;
113
+ try {
114
+ safeReason = await redactSecrets(rawReason, plugin);
115
+ } catch {
116
+ safeReason = rawReason;
117
+ }
118
+ winston.error(`[plugin/meilisearch] Error while saving settings: ${safeReason}`);
104
119
  plugin.healthy = false;
105
120
  // Surface the failure to admins in the ACP — without this, the client sees the
106
121
  // "Settings Saved" success toast even when Meilisearch silently rejected the
107
122
  // settings/embedder update. Client-side validation (admin.js) is the primary
108
123
  // UX path for invalid configs; this catches connection-level failures (e.g.
109
124
  // Meilisearch host unreachable after Fix B's 30s HTTP timeout fires).
125
+ // C1 fix: surface the failure to admins without leaking credentials. The error
126
+ // message may contain the apiKey if Meilisearch echoes it in 401 responses.
110
127
  plugin.notifyAdmins('settings:save', {
111
128
  type: 'danger',
112
129
  titleKey: '[[meilisearch:admin.meilisearchError]]',
113
- message: err.message,
130
+ message: safeReason,
114
131
  });
115
132
  }
116
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonwch/nodebb-plugin-meilisearch-r",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Enhanced plugin for integrating MeiliSearch with NodeBB",
5
5
  "author": {
6
6
  "name": "JasonCH",