thinknagent 0.1.20 → 0.1.23

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.
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ThinkNCollab Model Context Protocol (MCP) Server
4
+ * Strict Spec Compliance: MCP Protocol Version 2024-11-05 / JSON-RPC 2.0
5
+ */
6
+
7
+ const readline = require('readline');
8
+ const http = require('http');
9
+ const https = require('https');
10
+ const { URL } = require('url');
11
+
12
+ const API_BASE_URL = process.env.THINKNCOLLAB_API_URL || 'http://localhost:3001';
13
+ const API_TOKEN = process.env.THINKNCOLLAB_TOKEN || '';
14
+ const BOARD_ID = process.env.THINKNCOLLAB_BOARD_ID || '';
15
+ const ROOM_ID = process.env.THINKNCOLLAB_ROOM_ID || '';
16
+
17
+ // ── HTTP API Request Helper ───────────────────────────────────────────────────
18
+ function apiRequest(method, endpoint, data = null) {
19
+ return new Promise((resolve, reject) => {
20
+ try {
21
+ const baseObj = new URL(API_BASE_URL);
22
+
23
+ // SECURITY FIX: Ensure endpoint is strictly relative to prevent URL override
24
+ // and exfiltration of API_TOKEN to third-party endpoints.
25
+ const safeEndpoint = endpoint.startsWith('http://') || endpoint.startsWith('https://')
26
+ ? new URL(endpoint).pathname + new URL(endpoint).search
27
+ : endpoint;
28
+
29
+ const parsedUrl = new URL(safeEndpoint, baseObj.origin);
30
+
31
+ if (parsedUrl.origin !== baseObj.origin) {
32
+ return reject(new Error(`Security Error: Request origin mismatch (${parsedUrl.origin} vs ${baseObj.origin})`));
33
+ }
34
+
35
+ const isHttps = parsedUrl.protocol === 'https:';
36
+ const client = isHttps ? https : http;
37
+
38
+ const payload = data ? JSON.stringify(data) : null;
39
+ const headers = {
40
+ 'Authorization': `Bearer ${API_TOKEN}`,
41
+ 'Content-Type': 'application/json',
42
+ 'User-Agent': 'ThinkNCollab-MCP/1.0.0'
43
+ };
44
+ if (payload) {
45
+ headers['Content-Length'] = Buffer.byteLength(payload);
46
+ }
47
+
48
+ const options = {
49
+ hostname: parsedUrl.hostname,
50
+ port: parsedUrl.port || (isHttps ? 443 : 80),
51
+ path: parsedUrl.pathname + parsedUrl.search,
52
+ method: method.toUpperCase(),
53
+ headers
54
+ };
55
+
56
+
57
+ const req = client.request(options, (res) => {
58
+ let body = '';
59
+ res.setEncoding('utf8');
60
+ res.on('data', chunk => { body += chunk; });
61
+ res.on('end', () => {
62
+ try {
63
+ const json = JSON.parse(body);
64
+ resolve(json);
65
+ } catch (e) {
66
+ resolve({ raw: body, statusCode: res.statusCode });
67
+ }
68
+ });
69
+ });
70
+
71
+ req.on('error', (err) => {
72
+ reject(err);
73
+ });
74
+
75
+ if (payload) {
76
+ req.write(payload);
77
+ }
78
+ req.end();
79
+ } catch (e) {
80
+ reject(e);
81
+ }
82
+ });
83
+ }
84
+
85
+ // ── Strict MCP Tool Definitions (JSON Schema draft-07 compatible) ─────────────
86
+ const TOOLS = [
87
+ {
88
+ name: 'thinkncollab_get_board_state',
89
+ description: 'Get full project backlog, columns, and task list from ThinkNCollab board.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ boardId: {
94
+ type: 'string',
95
+ description: 'Board ID (optional if set in environment)'
96
+ }
97
+ },
98
+ additionalProperties: false
99
+ }
100
+ },
101
+ {
102
+ name: 'thinkncollab_plan_and_create_tasks',
103
+ description: 'Decompose a project or feature into structured tasks and batch-create them on the board.',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ boardId: {
108
+ type: 'string',
109
+ description: 'Board ID (optional if set in environment)'
110
+ },
111
+ tasks: {
112
+ type: 'array',
113
+ description: 'Array of task objects to generate on the board',
114
+ items: {
115
+ type: 'object',
116
+ properties: {
117
+ title: { type: 'string', description: 'Task title' },
118
+ description: { type: 'string', description: 'Markdown technical spec & implementation steps' },
119
+ priority: { type: 'string', enum: ['high', 'medium', 'low'] },
120
+ category: { type: 'string', description: 'Category e.g. Feature Requests, Security Issues, Bugs' },
121
+ acceptanceCriteria: {
122
+ type: 'array',
123
+ items: { type: 'string' },
124
+ description: 'List of acceptance criteria checklist items'
125
+ }
126
+ },
127
+ required: ['title']
128
+ }
129
+ }
130
+ },
131
+ required: ['tasks'],
132
+ additionalProperties: false
133
+ }
134
+ },
135
+ {
136
+ name: 'thinkncollab_create_task',
137
+ description: 'Create a single new task with full technical documentation on the ThinkNCollab board.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ boardId: {
142
+ type: 'string',
143
+ description: 'Board ID (optional if set in environment)'
144
+ },
145
+ title: { type: 'string', description: 'Task title' },
146
+ description: { type: 'string', description: 'Markdown technical specification' },
147
+ priority: { type: 'string', enum: ['high', 'medium', 'low'] },
148
+ category: { type: 'string', description: 'Task category' },
149
+ acceptanceCriteria: {
150
+ type: 'array',
151
+ items: { type: 'string' },
152
+ description: 'List of acceptance criteria checklist items'
153
+ }
154
+ },
155
+ required: ['title'],
156
+ additionalProperties: false
157
+ }
158
+ },
159
+ {
160
+ name: 'thinkncollab_get_task_spec',
161
+ description: 'Read the full markdown specification, acceptance criteria, and comments of a task.',
162
+ inputSchema: {
163
+ type: 'object',
164
+ properties: {
165
+ taskId: { type: 'string', description: 'Task ID' }
166
+ },
167
+ required: ['taskId'],
168
+ additionalProperties: false
169
+ }
170
+ },
171
+ {
172
+ name: 'thinkncollab_update_task_spec',
173
+ description: 'Update the technical documentation, description, or acceptance criteria of a task.',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ taskId: { type: 'string', description: 'Task ID' },
178
+ description: { type: 'string', description: 'Updated markdown documentation' },
179
+ acceptanceCriteria: {
180
+ type: 'array',
181
+ items: { type: 'string' }
182
+ },
183
+ priority: { type: 'string', enum: ['high', 'medium', 'low'] }
184
+ },
185
+ required: ['taskId'],
186
+ additionalProperties: false
187
+ }
188
+ },
189
+ {
190
+ name: 'thinkncollab_start_task',
191
+ description: 'Mark a task as in-progress and notify the team that the AI agent is working on it.',
192
+ inputSchema: {
193
+ type: 'object',
194
+ properties: {
195
+ taskId: { type: 'string', description: 'Task ID' }
196
+ },
197
+ required: ['taskId'],
198
+ additionalProperties: false
199
+ }
200
+ },
201
+ {
202
+ name: 'thinkncollab_add_comment',
203
+ description: 'Add a progress note, architectural decision, or question to a task.',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ taskId: { type: 'string', description: 'Task ID' },
208
+ content: { type: 'string', description: 'Comment text / log' }
209
+ },
210
+ required: ['taskId', 'content'],
211
+ additionalProperties: false
212
+ }
213
+ },
214
+ {
215
+ name: 'thinkncollab_complete_task',
216
+ description: 'Mark a task completed, post the completion verification comment, and trigger auto-tests.',
217
+ inputSchema: {
218
+ type: 'object',
219
+ properties: {
220
+ taskId: { type: 'string', description: 'Task ID' },
221
+ comment: { type: 'string', description: 'Detailed completion summary of what was implemented and tested' }
222
+ },
223
+ required: ['taskId', 'comment'],
224
+ additionalProperties: false
225
+ }
226
+ },
227
+ {
228
+ name: 'thinkncollab_auto_assign_tasks',
229
+ description: 'Automatically categorize, tag, and distribute board tasks to team members based on domain skills and workload balance.',
230
+ inputSchema: {
231
+ type: 'object',
232
+ properties: {
233
+ boardId: {
234
+ type: 'string',
235
+ description: 'Board ID (optional if set in environment)'
236
+ }
237
+ },
238
+ additionalProperties: false
239
+ }
240
+ }
241
+ ];
242
+
243
+ // ── MCP Tool Execution Handler ────────────────────────────────────────────────
244
+ async function handleToolCall(name, args = {}) {
245
+ const bId = args.boardId || BOARD_ID;
246
+
247
+ switch (name) {
248
+ case 'thinkncollab_get_board_state': {
249
+ if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
250
+ return await apiRequest('GET', `/boards/${bId}/api/state`);
251
+ }
252
+
253
+ case 'thinkncollab_plan_and_create_tasks': {
254
+ if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
255
+ return await apiRequest('POST', `/boards/${bId}/api/plan`, { tasks: args.tasks });
256
+ }
257
+
258
+ case 'thinkncollab_create_task': {
259
+ if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
260
+ return await apiRequest('POST', `/boards/${bId}/api/tasks/create`, args);
261
+ }
262
+
263
+ case 'thinkncollab_auto_assign_tasks': {
264
+ if (!bId) throw new Error('boardId is required (or set THINKNCOLLAB_BOARD_ID environment variable)');
265
+ return await apiRequest('POST', `/boards/${bId}/api/auto-assign-all`);
266
+ }
267
+
268
+ case 'thinkncollab_get_task_spec': {
269
+ return await apiRequest('GET', `/tasks/${args.taskId}/api/spec`);
270
+ }
271
+
272
+ case 'thinkncollab_update_task_spec': {
273
+ return await apiRequest('PUT', `/tasks/${args.taskId}/api/spec`, args);
274
+ }
275
+
276
+ case 'thinkncollab_start_task': {
277
+ return await apiRequest('POST', `/tasks/${args.taskId}/api/start`);
278
+ }
279
+
280
+ case 'thinkncollab_add_comment': {
281
+ return await apiRequest('POST', `/tasks/${args.taskId}/api/comment`, { content: args.content });
282
+ }
283
+
284
+ case 'thinkncollab_complete_task': {
285
+ return await apiRequest('POST', `/tasks/${args.taskId}/api/complete`, { comment: args.comment });
286
+ }
287
+
288
+ default:
289
+ throw new Error(`Unknown tool: ${name}`);
290
+ }
291
+ }
292
+
293
+ // ── JSON-RPC 2.0 Response Dispatcher ──────────────────────────────────────────
294
+ function sendResult(id, result) {
295
+ if (id === null || id === undefined) return;
296
+ const res = { jsonrpc: '2.0', id, result };
297
+ process.stdout.write(JSON.stringify(res) + '\n');
298
+ }
299
+
300
+ function sendError(id, code, message) {
301
+ if (id === null || id === undefined) return;
302
+ const res = { jsonrpc: '2.0', id, error: { code, message } };
303
+ process.stdout.write(JSON.stringify(res) + '\n');
304
+ }
305
+
306
+ // ── Stdio Stream Listener ─────────────────────────────────────────────────────
307
+ const rl = readline.createInterface({
308
+ input: process.stdin,
309
+ output: process.stdout,
310
+ terminal: false
311
+ });
312
+
313
+ rl.on('line', async (line) => {
314
+ const trimmed = line.trim();
315
+ if (!trimmed) return;
316
+
317
+ let msg;
318
+ try {
319
+ msg = JSON.parse(trimmed);
320
+ } catch (err) {
321
+ sendError(null, -32700, 'Parse error');
322
+ return;
323
+ }
324
+
325
+ const { id, method, params } = msg;
326
+
327
+ // Handle Notifications (No id -> Never respond in JSON-RPC 2.0)
328
+ if (id === undefined || id === null) {
329
+ if (method === 'notifications/initialized' || method === 'initialized') {
330
+ process.stderr.write('[MCP] Client initialized successfully.\n');
331
+ }
332
+ return;
333
+ }
334
+
335
+ // Handle Requests
336
+ try {
337
+ if (method === 'initialize') {
338
+ sendResult(id, {
339
+ protocolVersion: '2024-11-05',
340
+ capabilities: {
341
+ tools: {
342
+ listChanged: false
343
+ }
344
+ },
345
+ serverInfo: {
346
+ name: 'thinkncollab',
347
+ version: '1.0.0'
348
+ }
349
+ });
350
+ } else if (method === 'tools/list') {
351
+ sendResult(id, {
352
+ tools: TOOLS
353
+ });
354
+ } else if (method === 'tools/call') {
355
+ const toolName = params?.name;
356
+ const toolArgs = params?.arguments || {};
357
+ try {
358
+ const data = await handleToolCall(toolName, toolArgs);
359
+ sendResult(id, {
360
+ content: [
361
+ {
362
+ type: 'text',
363
+ text: JSON.stringify(data, null, 2)
364
+ }
365
+ ],
366
+ isError: false
367
+ });
368
+ } catch (callErr) {
369
+ sendResult(id, {
370
+ content: [
371
+ {
372
+ type: 'text',
373
+ text: `Tool error (${toolName}): ${callErr.message}`
374
+ }
375
+ ],
376
+ isError: true
377
+ });
378
+ }
379
+ } else if (method === 'ping') {
380
+ sendResult(id, {});
381
+ } else {
382
+ sendError(id, -32601, `Method not found: ${method}`);
383
+ }
384
+ } catch (handlerErr) {
385
+ sendError(id, -32603, `Internal error: ${handlerErr.message}`);
386
+ }
387
+ });
package/lib/agent.js CHANGED
@@ -6,34 +6,42 @@ const MetricsPoller = require('./metrics');
6
6
  const LogWatcher = require('./logwatcher');
7
7
  const AlertEngine = require('./alerts');
8
8
  const ShellBridge = require('./shell');
9
+ const HistoryManager = require('./history');
9
10
  const store = require('./store');
10
11
  const chokidar = require('chokidar');
11
12
  const fs = require('fs');
12
13
 
13
- // allowed base dirs for log streaming
14
- // room owner agent:logs_updated se bahar ke paths reject ho jayenge
14
+ // allowed base dirs for log streaming — only specific, non-writable-by-others paths
15
+ // SECURITY FIX: Removed /tmp (world-writable attacker can create logs there and stream them)
16
+ // Removed /root (only needed for root-running deployments — overly broad)
15
17
  const LOG_PATH_ALLOWLIST = [
16
18
  '/var/log',
17
19
  '/home',
18
- '/root',
19
- '/tmp',
20
20
  ];
21
21
 
22
22
  function isSafeLogPath(logPath) {
23
23
  const resolved = path.resolve(logPath);
24
24
 
25
- // path traversal check resolved path allowlist mein hona chahiye
25
+ // Must be a .log file (reject binary or unknown extensions)
26
+ if (!resolved.endsWith('.log') && !resolved.endsWith('.txt') && !resolved.endsWith('.out')) {
27
+ console.warn(`[agent] Rejected log path (not a .log/.txt/.out file): ${resolved}`);
28
+ return false;
29
+ }
30
+
31
+ // path traversal check — resolved path must be inside allowlist
26
32
  const allowed = LOG_PATH_ALLOWLIST.some(base => resolved.startsWith(base + path.sep) || resolved === base);
27
33
  if (!allowed) {
28
34
  console.warn(`[agent] Rejected log path (not in allowlist): ${resolved}`);
29
35
  return false;
30
36
  }
31
37
 
32
- // sensitive files blocklist
38
+ // sensitive files / directories blocklist
33
39
  const BLOCKED = [
34
40
  '/etc/passwd', '/etc/shadow', '/etc/sudoers',
35
41
  '.ssh', '.gnupg', '.aws', '.env',
36
42
  'id_rsa', 'id_ed25519', 'authorized_keys',
43
+ '.thinknagent', '.thinkncollab', 'config.json',
44
+ 'session.json', '.npmrc', '.netrc',
37
45
  ];
38
46
  const blocked = BLOCKED.some(b => resolved.includes(b));
39
47
  if (blocked) {
@@ -44,6 +52,7 @@ function isSafeLogPath(logPath) {
44
52
  return true;
45
53
  }
46
54
 
55
+
47
56
  function validateRules(rules) {
48
57
  if (!Array.isArray(rules)) return [];
49
58
  return rules.filter(r =>
@@ -71,11 +80,16 @@ class Agent {
71
80
  });
72
81
 
73
82
  this.alerts.conn = this.conn;
83
+ this.history = new HistoryManager();
74
84
 
75
85
  this.metrics = new MetricsPoller({
76
86
  connection: this.conn,
77
87
  gpu: cfg.gpu || false,
78
- onMetricsEmit: (payload) => this.alerts.evaluate(payload),
88
+ interval: cfg.interval || 1000,
89
+ onMetricsEmit: (payload) => {
90
+ this.alerts.evaluate(payload);
91
+ this.history.recordSnapshot(payload);
92
+ },
79
93
  });
80
94
 
81
95
  this.logs = new LogWatcher({
@@ -108,6 +122,26 @@ class Agent {
108
122
  this.metrics.pollNow();
109
123
  });
110
124
 
125
+ this.conn.socket.on('agent:get_history', ({ hours, requestId }) => {
126
+ const targetHours = typeof hours === 'number' ? hours : 72;
127
+ console.log(`[agent] Historical metrics requested (${targetHours}h)`);
128
+ const historyData = this.history.getHistory(targetHours);
129
+ this.conn.socket.emit('agent:history_data', {
130
+ requestId,
131
+ hours: targetHours,
132
+ history: historyData
133
+ });
134
+ });
135
+
136
+ this.conn.socket.on('agent:send_logs', () => {
137
+ console.log('[agent] Logs refresh requested');
138
+ if (this.logs && this.logs.logPaths) {
139
+ for (const p of this.logs.logPaths) {
140
+ this.logs._sendTail(p);
141
+ }
142
+ }
143
+ });
144
+
111
145
  this.conn.socket.on('agent:logs_updated', ({ logs }) => {
112
146
  if (!Array.isArray(logs)) return;
113
147
 
package/lib/alerts.js CHANGED
@@ -44,7 +44,7 @@ class AlertEngine {
44
44
  const elapsed = (now - state.firstTriggeredAt) / 1000; // seconds
45
45
  if (elapsed >= rule.for && !state.fired) {
46
46
  state.fired = true;
47
- this._fire(rule, val);
47
+ this._fire(rule, val, metrics);
48
48
  }
49
49
  } else {
50
50
  // Condition cleared
@@ -57,17 +57,19 @@ class AlertEngine {
57
57
  }
58
58
  }
59
59
 
60
- _fire(rule, currentValue) {
60
+ _fire(rule, currentValue, metrics = null) {
61
61
  console.warn(`[alerts] FIRING: ${rule.id} — ${rule.metric} ${rule.op} ${rule.value} (current: ${currentValue})`);
62
62
  this.conn.emit('agent:alert', {
63
- id: rule.id,
64
- metric: rule.metric,
65
- op: rule.op,
66
- threshold: rule.value,
67
- current: currentValue,
68
- severity: rule.severity || 'warning',
69
- status: 'active',
70
- firedAt: Date.now(),
63
+ id: rule.id,
64
+ metric: rule.metric,
65
+ op: rule.op,
66
+ threshold: rule.value,
67
+ current: currentValue,
68
+ severity: rule.severity || 'warning',
69
+ status: 'active',
70
+ firedAt: Date.now(),
71
+ diagnostics: metrics?.diagnostics || null,
72
+ topProcesses: metrics?.processes?.top || [],
71
73
  });
72
74
  }
73
75
 
package/lib/connect.js CHANGED
@@ -143,6 +143,24 @@ s.on('agent:approved', ({ agentToken, role, roomId }) => {
143
143
  process.exit(0);
144
144
  });
145
145
 
146
+ // ── Periodic HMAC Challenge-Response Zero-Trust Protocol ───────────────────
147
+ s.on('agent:auth_challenge', ({ challenge, ts }) => {
148
+ const cfg = store.read();
149
+ const token = cfg.agentToken || this.agentToken;
150
+ const crypto = require('crypto');
151
+ if (!token) return;
152
+
153
+ const raw = `${challenge}:${ts}:${cfg.agentId}`;
154
+ const signature = crypto.createHmac('sha256', token).update(raw).digest('hex');
155
+
156
+ s.emit('agent:auth_challenge_response', {
157
+ challenge,
158
+ ts,
159
+ signature,
160
+ agentId: cfg.agentId
161
+ });
162
+ });
163
+
146
164
  s.on('connect_error', (err) => {
147
165
  console.error(`[thinknagent] Connection error: ${err.message}`);
148
166
  });
package/lib/daemon.js CHANGED
@@ -14,7 +14,7 @@ const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
14
14
  class DaemonManager {
15
15
  constructor() {
16
16
  if (!fs.existsSync(CONFIG_DIR)) {
17
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
17
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
18
18
  }
19
19
  }
20
20
 
@@ -26,8 +26,8 @@ class DaemonManager {
26
26
  return;
27
27
  }
28
28
 
29
- const supervisorPath = path.resolve(__dirname, 'supervisor.js');
30
- const logFd = fs.openSync(LOG_FILE, 'a');
29
+ const supervisorPath = path.resolve(__dirname, 'supervisor.js');
30
+ const logFd = fs.openSync(LOG_FILE, 'a', 0o600);
31
31
 
32
32
  // Spawn detached supervisor process
33
33
  const child = spawn(process.execPath, [supervisorPath], {
@@ -36,7 +36,8 @@ class DaemonManager {
36
36
  });
37
37
 
38
38
  child.unref();
39
- fs.writeFileSync(PID_FILE, String(child.pid), 'utf8');
39
+ fs.writeFileSync(PID_FILE, String(child.pid), { encoding: 'utf8', mode: 0o600 });
40
+
40
41
 
41
42
  console.log(chalk.green('\n ✔ ThinkNCollab Agent Daemon started with Auto-Restart!'));
42
43
  console.log(chalk.gray(' ─────────────────────────────────────────────'));
package/lib/e2ee.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ const crypto = require("crypto");
4
+
5
+ /*
6
+ End-to-End Encryption (AES-256-GCM) for ThinkNCollab Agent
7
+ Matches browser-side WebCrypto implementation in e2ee-vault.js
8
+
9
+ SECURITY FIX: Key derivation upgraded from SHA-256 (single pass, brute-forceable)
10
+ to PBKDF2-SHA256 with 100,000 iterations. The old approach used a predictable seed
11
+ derived from public roomId — an attacker who knows the roomId could derive the key.
12
+ */
13
+
14
+ function deriveKeySync(roomId, secretSeed) {
15
+ // Use PBKDF2 with a stable per-room salt and 100k iterations
16
+ // secretSeed is the user-provided secret; falls back to a hardened seed if absent
17
+ const password = secretSeed || ('tnc_vault_' + roomId + '_agent_secret');
18
+ const salt = Buffer.from('thinkncollab-e2ee-agent-salt-v2', 'utf8');
19
+ // 100,000 iterations — OWASP recommended minimum for PBKDF2-SHA256
20
+ return crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
21
+ }
22
+
23
+ function encryptE2EE(plaintext, roomId, secretSeed) {
24
+ if (!plaintext || typeof plaintext !== "string") return plaintext;
25
+ try {
26
+ const key = deriveKeySync(roomId, secretSeed);
27
+ const iv = crypto.randomBytes(12); // 12-byte random IV
28
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
29
+
30
+ const ciphertext = Buffer.concat([
31
+ cipher.update(plaintext, "utf8"),
32
+ cipher.final()
33
+ ]);
34
+ const tag = cipher.getAuthTag(); // 16-byte auth tag
35
+
36
+ // Envelope: [12-byte IV] + [16-byte Tag] + [Ciphertext]
37
+ const combined = Buffer.concat([iv, tag, ciphertext]);
38
+ return "e2ee:" + combined.toString("base64");
39
+ } catch (err) {
40
+ return plaintext;
41
+ }
42
+ }
43
+
44
+ module.exports = { encryptE2EE };
45
+