• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22809145645

07 Mar 2026 11:04PM UTC coverage: 87.566% (+0.03%) from 87.533%
22809145645

push

github

BillChirico
fix: remove broken node_modules symlinks from git

These symlinks point to /home/bill/volvox-bot/... which doesn't exist
in any other environment. They block Railway deployments and should
never be tracked in version control.

5971 of 7227 branches covered (82.62%)

Branch coverage included in aggregate %.

10248 of 11295 relevant lines covered (90.73%)

229.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.35
/src/api/middleware/auditLog.js
1
/**
2
 * Audit Log Middleware
3
 * Intercepts mutating requests (POST/PUT/PATCH/DELETE) on authenticated routes
4
 * and records audit entries non-blockingly.
5
 */
6

7
import { info, error as logError } from '../../logger.js';
8
import { getConfig } from '../../modules/config.js';
9
import { maskSensitiveFields } from '../utils/configAllowlist.js';
10
import { broadcastAuditEntry } from '../ws/auditStream.js';
11

12
/** HTTP methods considered mutating */
13
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
57✔
14

15
/**
16
 * Derive an action string from the HTTP method and request path.
17
 *
18
 * @param {string} method - HTTP method (e.g. 'PUT')
19
 * @param {string} path - Request path (e.g. '/api/v1/guilds/123/config')
20
 * @returns {string} Dot-separated action identifier
21
 */
22
export function deriveAction(method, path) {
23
  // Normalise: strip /api/v1 prefix and trailing slash
24
  const cleaned = path.replace(/^\/api\/v1\/?/, '').replace(/\/$/, '');
75✔
25
  const segments = cleaned.split('/').filter(Boolean);
75✔
26

27
  // Common patterns:
28
  //   PUT  guilds/:id/config               → config.update
29
  //   PUT  guilds/:id/members/:memberId/xp → members.xp_update
30
  //   POST moderation/warn                 → moderation.create
31

32
  if (segments.length === 0) return `${method.toLowerCase()}.unknown`;
75✔
33

34
  // Skip 'guilds' + guild ID prefix when present
35
  let i = 0;
74✔
36
  if (segments[0] === 'guilds' && segments.length > 1) {
74✔
37
    i = 2; // skip 'guilds' and guild ID
73✔
38
  }
39

40
  const rest = segments.slice(i);
74✔
41
  if (rest.length === 0) return 'guild.update';
74✔
42

43
  const resource = rest[0];
73✔
44
  const sub = rest.length > 2 ? rest[rest.length - 1] : null;
73✔
45

46
  const methodVerb =
47
    method === 'POST'
75✔
48
      ? 'create'
49
      : method === 'PUT' || method === 'PATCH'
65!
50
        ? 'update'
51
        : method === 'DELETE'
×
52
          ? 'delete'
53
          : method.toLowerCase();
54

55
  if (sub) {
75✔
56
    return `${resource}.${sub}_${methodVerb}`;
21✔
57
  }
58

59
  return `${resource}.${methodVerb}`;
52✔
60
}
61

62
/**
63
 * Extract the guild ID from the request path if present.
64
 *
65
 * @param {string} path - Request path
66
 * @returns {string|null} Guild ID or null
67
 */
68
function extractGuildId(path) {
69
  const match = path.match(/\/guilds\/([^/]+)/);
258✔
70
  return match ? match[1] : null;
258✔
71
}
72

73
/**
74
 * Compute a shallow diff between two objects, returning only changed keys.
75
 *
76
 * @param {Object} before - Previous state
77
 * @param {Object} after - New state
78
 * @returns {Object} Object with `before` and `after` containing only differing keys
79
 */
80
export function computeConfigDiff(before, after) {
81
  const diff = { before: {}, after: {} };
12✔
82
  const allKeys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]);
12!
83

84
  for (const key of allKeys) {
12✔
85
    const b = JSON.stringify(before?.[key]);
58✔
86
    const a = JSON.stringify(after?.[key]);
58✔
87
    if (b !== a) {
58✔
88
      diff.before[key] = before?.[key];
5✔
89
      diff.after[key] = after?.[key];
5✔
90
    }
91
  }
92

93
  return diff;
12✔
94
}
95

96
/**
97
 * Insert an audit log entry into the database. Fire-and-forget (non-blocking).
98
 *
99
 * @param {import('pg').Pool} pool - Database connection pool
100
 * @param {Object} entry - Audit log entry
101
 */
102
function insertAuditEntry(pool, entry) {
103
  const { guildId, userId, userTag, action, targetType, targetId, details, ipAddress } = entry;
16✔
104

105
  try {
16✔
106
    const result = pool.query(
16✔
107
      `INSERT INTO audit_logs (guild_id, user_id, user_tag, action, target_type, target_id, details, ip_address)
108
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
109
       RETURNING id, guild_id, user_id, user_tag, action, target_type, target_id, details, ip_address, created_at`,
110
      [
111
        guildId || 'global',
16!
112
        userId,
113
        userTag || null,
32✔
114
        action,
115
        targetType || null,
28✔
116
        targetId || null,
28✔
117
        details ? JSON.stringify(details) : null,
16!
118
        ipAddress || null,
16!
119
      ],
120
    );
121

122
    if (result && typeof result.then === 'function') {
16✔
123
      result
3✔
124
        .then((insertResult) => {
125
          info('Audit log entry created', { action, guildId, userId });
3✔
126
          // Broadcast to real-time audit log WebSocket clients
127
          const row = insertResult?.rows?.[0];
3✔
128
          const broadcastEntry = {
3✔
129
            guild_id: guildId || 'global',
3!
130
            user_id: userId,
131
            action,
132
            target_type: targetType || null,
4✔
133
            target_id: targetId || null,
4✔
134
            details: details || null,
3!
135
            ip_address: ipAddress || null,
3!
136
            created_at: new Date().toISOString(),
137
            ...(row || {}),
6✔
138
          };
139
          try {
3✔
140
            broadcastAuditEntry(broadcastEntry);
3✔
141
          } catch {
142
            // Non-critical — streaming failure must not affect audit integrity
143
          }
144
        })
145
        .catch((err) => {
146
          logError('Failed to insert audit log entry', { error: err.message, action, guildId });
×
147
        });
148
    }
149
  } catch (err) {
150
    logError('Failed to insert audit log entry', { error: err.message, action, guildId });
×
151
  }
152
}
153

154
/**
155
 * Express middleware that records audit log entries for mutating requests.
156
 * Non-blocking — the response is not delayed by the audit write.
157
 *
158
 * @returns {import('express').RequestHandler}
159
 */
160
export function auditLogMiddleware() {
161
  return (req, res, next) => {
680✔
162
    // Prevent double-execution: multiple routers can be mounted at the same path prefix
163
    // (e.g. /guilds mounts membersRouter, ticketsRouter, guildsRouter in sequence).
164
    // Only the first matching mount should attach the audit handler.
165
    if (req._auditLogAttached) {
769✔
166
      return next();
97✔
167
    }
168

169
    // Only audit mutating methods
170
    if (!MUTATING_METHODS.has(req.method)) {
672✔
171
      return next();
414✔
172
    }
173

174
    // Strip query string once — req.originalUrl includes it (e.g. /api/v1/guilds/123/config?limit=25)
175
    const cleanPath = (req.originalUrl || req.path).split('?')[0];
258!
176
    const guildId = extractGuildId(cleanPath) || req.body?.guildId || null;
769✔
177

178
    // Check if audit logging is enabled in config (guild-scoped when available)
179
    const config = getConfig(guildId || undefined);
769✔
180
    if (config.auditLog && config.auditLog.enabled === false) {
769✔
181
      return next();
1✔
182
    }
183

184
    const pool = req.app.locals.dbPool;
257✔
185
    if (!pool) {
257✔
186
      return next();
188✔
187
    }
188

189
    req._auditLogAttached = true;
69✔
190

191
    const userId = req.user?.userId || req.authMethod || 'unknown';
69!
192
    const userTag = req.user?.tag || req.user?.username || null;
769✔
193
    const action = deriveAction(req.method, cleanPath);
769✔
194
    const ipAddress = req.ip || req.socket?.remoteAddress;
769!
195

196
    // For config updates, capture before state to compute diff.
197
    // Use guild-scoped config for accurate before/after snapshots.
198
    const isConfigUpdate =
199
      cleanPath.includes('/config') && (req.method === 'PUT' || req.method === 'PATCH');
769✔
200

201
    let beforeConfig = null;
769✔
202
    if (isConfigUpdate) {
769✔
203
      try {
19✔
204
        beforeConfig = structuredClone(getConfig(guildId));
19✔
205
      } catch {
206
        // Non-critical — proceed without diff
207
      }
208
    }
209

210
    // Hook into response finish to capture the outcome
211
    res.on('finish', () => {
69✔
212
      // Only log successful mutations (2xx/3xx)
213
      if (res.statusCode >= 400) return;
66✔
214

215
      const details = { method: req.method, path: cleanPath };
16✔
216

217
      // Include request body with sensitive fields masked
218
      if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
16!
219
        details.body = maskSensitiveFields(req.body);
16✔
220
      }
221

222
      // Compute config diff for config updates, masking sensitive fields in both snapshots
223
      if (isConfigUpdate && beforeConfig) {
16✔
224
        try {
7✔
225
          const afterConfig = getConfig(guildId);
7✔
226
          const diff = computeConfigDiff(beforeConfig, afterConfig);
7✔
227
          if (Object.keys(diff.before).length > 0 || Object.keys(diff.after).length > 0) {
7✔
228
            details.configDiff = {
1✔
229
              before: maskSensitiveFields(diff.before),
230
              after: maskSensitiveFields(diff.after),
231
            };
232
          }
233
        } catch {
234
          // Non-critical
235
        }
236
      }
237

238
      // Derive target type/id from path
239
      let targetType = null;
16✔
240
      let targetId = null;
16✔
241
      const pathSegments = cleanPath
16✔
242
        .replace(/^\/api\/v1\/?/, '')
243
        .split('/')
244
        .filter(Boolean);
245

246
      // Pattern: guilds/:id/<resource>/:resourceId
247
      if (pathSegments.length >= 4 && pathSegments[0] === 'guilds') {
16✔
248
        targetType = pathSegments[2];
4✔
249
        targetId = pathSegments[3];
4✔
250
      }
251

252
      insertAuditEntry(pool, {
16✔
253
        guildId,
254
        userId,
255
        userTag,
256
        action,
257
        targetType,
258
        targetId,
259
        details,
260
        ipAddress,
261
      });
262
    });
263

264
    next();
69✔
265
  };
266
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc