nodebb-plugin-integration 0.4.2 → 0.4.4

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.
@@ -1,133 +1,176 @@
1
- "use strict";
2
-
3
- /**
4
- * NodeBB Integration Layer
5
- *
6
- * Configuration provider
7
- *
8
- * Settings are stored in the NodeBB database
9
- * using meta.settings.
10
- */
11
-
12
- let meta = null;
13
-
14
- try {
15
-
16
- meta = require.main.require("./src/meta");
17
-
18
- } catch (err) {
19
-
20
- // Standalone mode (tests, npm, CLI)
21
-
22
- }
23
-
24
- const DEFAULTS = {
25
-
26
- aiProvider: "fake",
27
-
28
- openaiApiKey: "",
29
-
30
- openaiModel: "text-embedding-3-small",
31
-
32
- qdrantUrl: "http://localhost:6333",
33
-
34
- collection: "nodebb_knowledge",
35
-
36
- mcpEnabled: false,
37
-
38
- mcpPort: 3001
39
-
40
- };
41
-
42
- async function get() {
43
-
44
- if (!meta) {
45
- return { ...DEFAULTS };
46
- }
47
-
48
- try {
49
-
50
- return await meta.settings.get(
51
- "nodebb-plugin-integration",
52
- DEFAULTS
53
- );
54
-
55
- } catch (err) {
56
-
57
- console.error(
58
- "[Config] unable to load settings:",
59
- err.message
60
- );
61
-
62
- return { ...DEFAULTS };
63
-
64
- }
65
-
66
- }
67
-
68
- async function set(values) {
69
-
70
- if (!meta) {
71
- return false;
72
- }
73
-
74
- try {
75
-
76
- await meta.settings.set(
77
- "nodebb-plugin-integration",
78
- values
79
- );
80
-
81
- return true;
82
-
83
- } catch (err) {
84
-
85
- console.error(
86
- "[Config] unable to save settings:",
87
- err.message
88
- );
89
-
90
- return false;
91
-
92
- }
93
-
94
- }
95
-
96
- async function info() {
97
-
98
- const cfg = await get();
99
-
100
- return {
101
-
102
- aiProvider: cfg.aiProvider,
103
-
104
- openaiModel: cfg.openaiModel,
105
-
106
- qdrantUrl: cfg.qdrantUrl,
107
-
108
- collection: cfg.collection,
109
-
110
- mcpEnabled: cfg.mcpEnabled,
111
-
112
- mcpPort: cfg.mcpPort,
113
-
114
- openaiApiKey:
115
- cfg.openaiApiKey
116
- ? "***configured***"
117
- : ""
118
-
119
- };
120
-
121
- }
122
-
123
- module.exports = {
124
-
125
- get,
126
-
127
- set,
128
-
129
- info,
130
-
131
- defaults: DEFAULTS
132
-
133
- };
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const Validation = require("./validation");
5
+
6
+ const DEFAULTS = Object.freeze({
7
+ aiProvider: "openai",
8
+ openaiApiKey: "",
9
+ openaiModel: "text-embedding-3-small",
10
+ embeddingSize: 1536,
11
+ qdrantUrl: "http://qdrant:6333",
12
+ collection: "nodebb_knowledge",
13
+ excludedCategories: [],
14
+
15
+ mcpEnabled: false,
16
+ mcpHost: "127.0.0.1",
17
+ mcpPort: 3100,
18
+ mcpNodebbUrl: "http://127.0.0.1:4567",
19
+ mcpBearerToken: "",
20
+ mcpServiceUid: 0,
21
+
22
+ internalApiToken: ""
23
+ });
24
+
25
+ const SETTINGS_KEY = "nodebb-plugin-integration";
26
+
27
+ let nativeMeta = null;
28
+
29
+ function getMeta() {
30
+ if (nativeMeta) {
31
+ return nativeMeta;
32
+ }
33
+
34
+ try {
35
+ if (typeof nodebb !== "undefined" && nodebb &&
36
+ typeof nodebb.require === "function") {
37
+ nativeMeta = nodebb.require("./src/meta");
38
+ }
39
+ } catch (error) {
40
+ throw new Error(`Cannot load NodeBB meta module: ${error.message}`);
41
+ }
42
+
43
+ if (!nativeMeta || !nativeMeta.settings) {
44
+ throw new Error("NodeBB settings API is unavailable");
45
+ }
46
+
47
+ return nativeMeta;
48
+ }
49
+
50
+ async function get() {
51
+ const meta = getMeta();
52
+ const saved = await meta.settings.get(SETTINGS_KEY);
53
+ const settings = Validation.normalize(saved || {}, DEFAULTS);
54
+
55
+ if (!settings.internalApiToken) {
56
+ settings.internalApiToken = crypto.randomBytes(48).toString("hex");
57
+ await meta.settings.set(SETTINGS_KEY, settings);
58
+ }
59
+
60
+ return settings;
61
+ }
62
+
63
+ async function set(values = {}) {
64
+ if (!values || typeof values !== "object" || Array.isArray(values)) {
65
+ throw new TypeError("Settings must be an object");
66
+ }
67
+
68
+ const current = await get();
69
+ const next = { ...current };
70
+
71
+ const allowed = [
72
+ "aiProvider",
73
+ "openaiApiKey",
74
+ "openaiModel",
75
+ "embeddingSize",
76
+ "qdrantUrl",
77
+ "collection",
78
+ "excludedCategories",
79
+ "mcpEnabled",
80
+ "mcpHost",
81
+ "mcpPort",
82
+ "mcpNodebbUrl",
83
+ "mcpBearerToken",
84
+ "mcpServiceUid"
85
+ ];
86
+
87
+ for (const key of allowed) {
88
+ if (Object.prototype.hasOwnProperty.call(values, key)) {
89
+ next[key] = values[key];
90
+ }
91
+ }
92
+
93
+ for (const key of ["openaiApiKey", "mcpBearerToken"]) {
94
+ if (
95
+ Object.prototype.hasOwnProperty.call(values, key) &&
96
+ (typeof values[key] !== "string" || !values[key].trim())
97
+ ) {
98
+ next[key] = current[key];
99
+ }
100
+ }
101
+
102
+ if (Object.prototype.hasOwnProperty.call(values, "aiProvider")) {
103
+ next.aiProvider = Validation.validateAiProvider(values.aiProvider);
104
+ }
105
+
106
+ if (Object.prototype.hasOwnProperty.call(values, "qdrantUrl")) {
107
+ next.qdrantUrl = Validation.validateQdrantUrl(values.qdrantUrl);
108
+ }
109
+
110
+ if (Object.prototype.hasOwnProperty.call(values, "mcpHost")) {
111
+ next.mcpHost = Validation.validateMcpHost(values.mcpHost);
112
+ }
113
+
114
+ if (Object.prototype.hasOwnProperty.call(values, "mcpPort")) {
115
+ next.mcpPort = Validation.validatePort(values.mcpPort);
116
+ }
117
+
118
+ if (Object.prototype.hasOwnProperty.call(values, "mcpNodebbUrl")) {
119
+ next.mcpNodebbUrl = Validation.validateNodebbUrl(values.mcpNodebbUrl);
120
+ }
121
+
122
+ if (Object.prototype.hasOwnProperty.call(values, "mcpServiceUid")) {
123
+ next.mcpServiceUid = Validation.validateServiceUid(values.mcpServiceUid);
124
+ }
125
+
126
+ if (Object.prototype.hasOwnProperty.call(values, "mcpBearerToken")) {
127
+ next.mcpBearerToken = Validation.validateBearerToken(next.mcpBearerToken);
128
+ }
129
+
130
+ const normalized = Validation.normalize(next, DEFAULTS);
131
+
132
+ if (normalized.mcpEnabled) {
133
+ if (!normalized.mcpBearerToken) {
134
+ throw new Error("Set MCP Bearer Token before enabling MCP");
135
+ }
136
+
137
+ if (!normalized.mcpServiceUid) {
138
+ throw new Error("Set MCP service UID before enabling MCP");
139
+ }
140
+ }
141
+
142
+ await getMeta().settings.set(SETTINGS_KEY, normalized);
143
+
144
+ return normalized;
145
+ }
146
+
147
+ async function info() {
148
+ const settings = await get();
149
+
150
+ return {
151
+ aiProvider: settings.aiProvider,
152
+ aiProviderOpenai: settings.aiProvider === "openai",
153
+ aiProviderFake: settings.aiProvider === "fake",
154
+ openaiConfigured: Boolean(settings.openaiApiKey),
155
+ openaiModel: settings.openaiModel,
156
+ embeddingSize: settings.embeddingSize,
157
+ qdrantUrl: settings.qdrantUrl,
158
+ collection: settings.collection,
159
+ excludedCategories: settings.excludedCategories,
160
+
161
+ mcpEnabled: settings.mcpEnabled,
162
+ mcpHost: settings.mcpHost,
163
+ mcpPort: settings.mcpPort,
164
+ mcpNodebbUrl: settings.mcpNodebbUrl,
165
+ mcpBearerTokenConfigured: Boolean(settings.mcpBearerToken),
166
+ mcpServiceUid: settings.mcpServiceUid,
167
+ internalApiConfigured: Boolean(settings.internalApiToken)
168
+ };
169
+ }
170
+
171
+ module.exports = {
172
+ DEFAULTS,
173
+ get,
174
+ set,
175
+ info
176
+ };
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+
3
+ const net = require("net");
4
+
5
+ function normalizeBoolean(value) {
6
+ return value === true ||
7
+ value === "true" ||
8
+ value === "on" ||
9
+ value === 1;
10
+ }
11
+
12
+ function normalizeCategories(value) {
13
+ if (!Array.isArray(value)) {
14
+ return [];
15
+ }
16
+
17
+ return [...new Set(
18
+ value
19
+ .map(Number)
20
+ .filter(item => Number.isSafeInteger(item) && item > 0)
21
+ )];
22
+ }
23
+
24
+ function validateHttpUrl(value, label) {
25
+ if (typeof value !== "string" || !value.trim()) {
26
+ throw new Error(`${label} is required`);
27
+ }
28
+
29
+ let parsed;
30
+
31
+ try {
32
+ parsed = new URL(value.trim());
33
+ } catch {
34
+ throw new Error(`Invalid ${label}`);
35
+ }
36
+
37
+ if (
38
+ !["http:", "https:"].includes(parsed.protocol) ||
39
+ !parsed.hostname ||
40
+ parsed.username ||
41
+ parsed.password ||
42
+ parsed.search ||
43
+ parsed.hash
44
+ ) {
45
+ throw new Error(`${label} must be a valid HTTP or HTTPS URL`);
46
+ }
47
+
48
+ return parsed.toString().replace(/\/+$/, "");
49
+ }
50
+
51
+ function validateQdrantUrl(value) {
52
+ return validateHttpUrl(value, "Qdrant URL");
53
+ }
54
+
55
+ function validateNodebbUrl(value) {
56
+ return validateHttpUrl(value, "NodeBB API URL");
57
+ }
58
+
59
+ function validateAiProvider(value) {
60
+ if (value !== "openai" && value !== "fake") {
61
+ throw new Error("AI provider must be openai or fake");
62
+ }
63
+
64
+ return value;
65
+ }
66
+
67
+ function validateMcpHost(value) {
68
+ if (typeof value !== "string" || !value.trim()) {
69
+ throw new Error("MCP bind address is required");
70
+ }
71
+
72
+ const host = value.trim();
73
+
74
+ if (host.length > 253 || /[\s/\\?#@]/.test(host)) {
75
+ throw new Error("Invalid MCP bind address");
76
+ }
77
+
78
+ if (net.isIP(host)) {
79
+ return host;
80
+ }
81
+
82
+ const labels = host.split(".");
83
+
84
+ const validHostname = labels.every((label) => {
85
+ if (
86
+ label.length < 1 ||
87
+ label.length > 63 ||
88
+ !/[a-zA-Z0-9]/.test(label[0]) ||
89
+ !/[a-zA-Z0-9]/.test(label[label.length - 1])
90
+ ) {
91
+ return false;
92
+ }
93
+
94
+ return [...label].every((char) => (
95
+ (char >= "a" && char <= "z") ||
96
+ (char >= "A" && char <= "Z") ||
97
+ (char >= "0" && char <= "9") ||
98
+ char === "-"
99
+ ));
100
+ });
101
+
102
+ if (!validHostname) {
103
+ throw new Error("Invalid MCP bind address");
104
+ }
105
+
106
+ return host;
107
+ }
108
+
109
+ function validatePort(value) {
110
+ const port = Number(value);
111
+
112
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
113
+ throw new Error("MCP port must be between 1 and 65535");
114
+ }
115
+
116
+ return port;
117
+ }
118
+
119
+ function validateServiceUid(value) {
120
+ const uid = Number(value);
121
+
122
+ if (!Number.isSafeInteger(uid) || uid < 1) {
123
+ throw new Error("MCP service UID must be a positive NodeBB user ID");
124
+ }
125
+
126
+ return uid;
127
+ }
128
+
129
+ function validateBearerToken(value) {
130
+ if (typeof value !== "string" || /[\r\n]/.test(value)) {
131
+ throw new Error("Invalid MCP Bearer Token");
132
+ }
133
+
134
+ const token = value.trim();
135
+
136
+ if (/\s/.test(token)) {
137
+ throw new Error("MCP Bearer Token must not contain whitespace");
138
+ }
139
+
140
+ if (token && token.length < 32) {
141
+ throw new Error("MCP Bearer Token must contain at least 32 characters");
142
+ }
143
+
144
+ if (token.length > 512) {
145
+ throw new Error("Invalid MCP Bearer Token");
146
+ }
147
+
148
+ return token;
149
+ }
150
+
151
+ function normalize(values = {}, defaults) {
152
+ const result = {
153
+ ...defaults,
154
+ ...values
155
+ };
156
+
157
+ try {
158
+ result.aiProvider = validateAiProvider(result.aiProvider);
159
+ } catch {
160
+ result.aiProvider = defaults.aiProvider;
161
+ }
162
+
163
+ result.openaiApiKey =
164
+ typeof result.openaiApiKey === "string"
165
+ ? result.openaiApiKey.trim()
166
+ : "";
167
+
168
+ result.openaiModel =
169
+ typeof result.openaiModel === "string" &&
170
+ result.openaiModel.trim()
171
+ ? result.openaiModel.trim()
172
+ : defaults.openaiModel;
173
+
174
+ result.embeddingSize = Number(result.embeddingSize);
175
+
176
+ if (
177
+ !Number.isSafeInteger(result.embeddingSize) ||
178
+ result.embeddingSize < 1
179
+ ) {
180
+ result.embeddingSize = defaults.embeddingSize;
181
+ }
182
+
183
+ try {
184
+ result.qdrantUrl = validateQdrantUrl(result.qdrantUrl);
185
+ } catch {
186
+ result.qdrantUrl = defaults.qdrantUrl;
187
+ }
188
+
189
+ result.collection =
190
+ typeof result.collection === "string" &&
191
+ /^[a-zA-Z0-9_-]+$/.test(result.collection.trim())
192
+ ? result.collection.trim()
193
+ : defaults.collection;
194
+
195
+ result.excludedCategories =
196
+ normalizeCategories(result.excludedCategories);
197
+
198
+ result.mcpEnabled = normalizeBoolean(result.mcpEnabled);
199
+
200
+ try {
201
+ result.mcpHost = validateMcpHost(result.mcpHost);
202
+ } catch {
203
+ result.mcpHost = defaults.mcpHost;
204
+ }
205
+
206
+ try {
207
+ result.mcpPort = validatePort(result.mcpPort);
208
+ } catch {
209
+ result.mcpPort = defaults.mcpPort;
210
+ }
211
+
212
+ try {
213
+ result.mcpNodebbUrl = validateNodebbUrl(result.mcpNodebbUrl);
214
+ } catch {
215
+ result.mcpNodebbUrl = defaults.mcpNodebbUrl;
216
+ }
217
+
218
+ try {
219
+ result.mcpServiceUid = validateServiceUid(result.mcpServiceUid);
220
+ } catch {
221
+ result.mcpServiceUid = 0;
222
+ }
223
+
224
+ try {
225
+ result.mcpBearerToken = validateBearerToken(result.mcpBearerToken);
226
+ } catch {
227
+ result.mcpBearerToken = "";
228
+ }
229
+
230
+ result.internalApiToken =
231
+ typeof result.internalApiToken === "string"
232
+ ? result.internalApiToken
233
+ : "";
234
+
235
+ return result;
236
+ }
237
+
238
+ module.exports = {
239
+ normalizeBoolean,
240
+ normalizeCategories,
241
+ validateHttpUrl,
242
+ validateQdrantUrl,
243
+ validateNodebbUrl,
244
+ validateAiProvider,
245
+ validateMcpHost,
246
+ validatePort,
247
+ validateServiceUid,
248
+ validateBearerToken,
249
+ normalize
250
+ };