@adsim/wordpress-mcp-server 1.0.0 → 3.0.0

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.
Files changed (64) hide show
  1. package/.env.example +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/LICENSE +1 -1
  4. package/README.md +596 -135
  5. package/index.js +1367 -0
  6. package/package.json +21 -33
  7. package/src/auth/bearer.js +72 -0
  8. package/src/transport/http.js +264 -0
  9. package/tests/helpers/mockWpRequest.js +135 -0
  10. package/tests/unit/governance.test.js +260 -0
  11. package/tests/unit/tools/comments.test.js +170 -0
  12. package/tests/unit/tools/media.test.js +279 -0
  13. package/tests/unit/tools/pages.test.js +222 -0
  14. package/tests/unit/tools/plugins.test.js +268 -0
  15. package/tests/unit/tools/posts.test.js +310 -0
  16. package/tests/unit/tools/revisions.test.js +299 -0
  17. package/tests/unit/tools/search.test.js +190 -0
  18. package/tests/unit/tools/seo.test.js +248 -0
  19. package/tests/unit/tools/site.test.js +133 -0
  20. package/tests/unit/tools/taxonomies.test.js +220 -0
  21. package/tests/unit/tools/themes.test.js +163 -0
  22. package/tests/unit/tools/users.test.js +113 -0
  23. package/tests/unit/transport/http.test.js +300 -0
  24. package/vitest.config.js +12 -0
  25. package/dist/constants.d.ts +0 -13
  26. package/dist/constants.d.ts.map +0 -1
  27. package/dist/constants.js +0 -10
  28. package/dist/constants.js.map +0 -1
  29. package/dist/index.d.ts +0 -3
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -33
  32. package/dist/index.js.map +0 -1
  33. package/dist/schemas/index.d.ts +0 -308
  34. package/dist/schemas/index.d.ts.map +0 -1
  35. package/dist/schemas/index.js +0 -191
  36. package/dist/schemas/index.js.map +0 -1
  37. package/dist/services/formatters.d.ts +0 -22
  38. package/dist/services/formatters.d.ts.map +0 -1
  39. package/dist/services/formatters.js +0 -52
  40. package/dist/services/formatters.js.map +0 -1
  41. package/dist/services/wp-client.d.ts +0 -38
  42. package/dist/services/wp-client.d.ts.map +0 -1
  43. package/dist/services/wp-client.js +0 -102
  44. package/dist/services/wp-client.js.map +0 -1
  45. package/dist/tools/content.d.ts +0 -4
  46. package/dist/tools/content.d.ts.map +0 -1
  47. package/dist/tools/content.js +0 -196
  48. package/dist/tools/content.js.map +0 -1
  49. package/dist/tools/posts.d.ts +0 -4
  50. package/dist/tools/posts.d.ts.map +0 -1
  51. package/dist/tools/posts.js +0 -179
  52. package/dist/tools/posts.js.map +0 -1
  53. package/dist/tools/seo.d.ts +0 -4
  54. package/dist/tools/seo.d.ts.map +0 -1
  55. package/dist/tools/seo.js +0 -241
  56. package/dist/tools/seo.js.map +0 -1
  57. package/dist/tools/taxonomy.d.ts +0 -4
  58. package/dist/tools/taxonomy.d.ts.map +0 -1
  59. package/dist/tools/taxonomy.js +0 -82
  60. package/dist/tools/taxonomy.js.map +0 -1
  61. package/dist/types.d.ts +0 -160
  62. package/dist/types.d.ts.map +0 -1
  63. package/dist/types.js +0 -3
  64. package/dist/types.js.map +0 -1
package/package.json CHANGED
@@ -1,60 +1,48 @@
1
1
  {
2
2
  "name": "@adsim/wordpress-mcp-server",
3
- "version": "1.0.0",
4
- "description": "MCP Server for WordPress REST API Manage posts, pages, categories, tags, SEO metadata, and more via Claude Desktop or any MCP-compatible client.",
3
+ "version": "3.0.0",
4
+ "description": "A Model Context Protocol (MCP) server for WordPress REST API integration. Manage posts, search content, and interact with your WordPress site through any MCP-compatible client.",
5
5
  "type": "module",
6
- "main": "dist/index.js",
6
+ "main": "index.js",
7
7
  "bin": {
8
- "wordpress-mcp-server": "dist/index.js"
8
+ "wordpress-mcp-server": "./index.js",
9
+ "@adsim/wordpress-mcp-server": "./index.js"
9
10
  },
10
11
  "scripts": {
11
- "build": "tsc",
12
- "dev": "tsc --watch",
13
- "start": "node dist/index.js",
14
- "prepublishOnly": "npm run build",
15
- "clean": "rm -rf dist"
12
+ "start": "node index.js",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "test:coverage": "vitest run --coverage"
16
16
  },
17
17
  "keywords": [
18
18
  "mcp",
19
+ "model-context-protocol",
19
20
  "wordpress",
21
+ "wordpress-api",
20
22
  "rest-api",
21
23
  "claude",
22
- "anthropic",
23
- "model-context-protocol",
24
- "seo",
25
- "rankmath",
26
- "yoast",
27
- "content-management",
28
- "wp-api"
24
+ "ai",
25
+ "automation",
26
+ "cms"
29
27
  ],
30
- "author": {
31
- "name": "Georges Cordewiener",
32
- "email": "georges@adsim.be",
33
- "url": "https://adsim.be"
34
- },
28
+ "author": "Georges Cordewiener <georges@adsim.be>",
35
29
  "license": "MIT",
36
30
  "repository": {
37
31
  "type": "git",
38
- "url": "https://github.com/GeorgesAdSim/wordpress-mcp-server.git"
32
+ "url": "git+https://github.com/adsimbe/wordpress-mcp-server.git"
39
33
  },
40
34
  "bugs": {
41
- "url": "https://github.com/GeorgesAdSim/wordpress-mcp-server/issues"
35
+ "url": "https://github.com/adsimbe/wordpress-mcp-server/issues"
42
36
  },
43
- "homepage": "https://github.com/GeorgesAdSim/wordpress-mcp-server#readme",
37
+ "homepage": "https://github.com/adsimbe/wordpress-mcp-server#readme",
44
38
  "engines": {
45
39
  "node": ">=18.0.0"
46
40
  },
47
- "files": [
48
- "dist/**/*",
49
- "README.md",
50
- "LICENSE"
51
- ],
52
41
  "dependencies": {
53
- "@modelcontextprotocol/sdk": "^1.12.0",
54
- "zod": "^3.23.0"
42
+ "@modelcontextprotocol/sdk": "^1.26.0",
43
+ "node-fetch": "^3.3.2"
55
44
  },
56
45
  "devDependencies": {
57
- "@types/node": "^20.0.0",
58
- "typescript": "^5.5.0"
46
+ "vitest": "^4.0.18"
59
47
  }
60
48
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Bearer token authentication for HTTP transport.
3
+ *
4
+ * Provides timing-safe token validation to prevent timing attacks,
5
+ * structured JSON audit logging on stderr, and configurable auth bypass.
6
+ */
7
+
8
+ import { timingSafeEqual } from 'node:crypto';
9
+
10
+ /**
11
+ * Validate Bearer token from Authorization header.
12
+ *
13
+ * @param {import('node:http').IncomingMessage} req
14
+ * @param {string|null} authToken Expected token. null = auth disabled (passthrough).
15
+ * @returns {{ valid: boolean, status?: number, body?: object }}
16
+ */
17
+ export function validateBearerToken(req, authToken) {
18
+ // Auth disabled — passthrough
19
+ if (!authToken) {
20
+ return { valid: true };
21
+ }
22
+
23
+ const header = req.headers['authorization'] || '';
24
+ const match = header.match(/^Bearer\s+(.+)$/i);
25
+
26
+ if (!match) {
27
+ logAuthFailure(req, 'missing_or_malformed_token');
28
+ return {
29
+ valid: false,
30
+ status: 401,
31
+ body: { error: 'Unauthorized', code: 'INVALID_TOKEN' },
32
+ };
33
+ }
34
+
35
+ const provided = match[1];
36
+
37
+ // Timing-safe comparison: pad both to same length to avoid leaking length info
38
+ const expected = Buffer.from(authToken, 'utf-8');
39
+ const received = Buffer.from(provided, 'utf-8');
40
+
41
+ const maxLen = Math.max(expected.length, received.length);
42
+ const a = Buffer.alloc(maxLen);
43
+ const b = Buffer.alloc(maxLen);
44
+ expected.copy(a);
45
+ received.copy(b);
46
+
47
+ if (expected.length !== received.length || !timingSafeEqual(a, b)) {
48
+ logAuthFailure(req, 'invalid_token');
49
+ return {
50
+ valid: false,
51
+ status: 401,
52
+ body: { error: 'Unauthorized', code: 'INVALID_TOKEN' },
53
+ };
54
+ }
55
+
56
+ return { valid: true };
57
+ }
58
+
59
+ /**
60
+ * @param {import('node:http').IncomingMessage} req
61
+ * @param {string} reason
62
+ */
63
+ function logAuthFailure(req, reason) {
64
+ const record = {
65
+ timestamp: new Date().toISOString(),
66
+ event: 'auth_failure',
67
+ reason,
68
+ ip: req.socket?.remoteAddress || null,
69
+ path: req.url || null,
70
+ };
71
+ console.error(`[AUTH] ${JSON.stringify(record)}`);
72
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * HTTP Streamable Transport Manager for WordPress MCP Server.
3
+ *
4
+ * Implements MCP 2025-03-26 Streamable HTTP spec:
5
+ * - POST /mcp → JSON-RPC request handling (JSON or SSE)
6
+ * - GET /mcp → SSE stream for server-to-client notifications
7
+ * - DELETE /mcp → terminate session
8
+ * - GET /health → health check
9
+ *
10
+ * Bearer auth, Origin validation, session management, and audit logging
11
+ * are applied before any MCP processing.
12
+ */
13
+
14
+ import { createServer as createHttpServer } from 'node:http';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
17
+ import { validateBearerToken } from '../auth/bearer.js';
18
+
19
+ /**
20
+ * @typedef {object} HttpConfig
21
+ * @property {number} [port=3000]
22
+ * @property {string} [host='0.0.0.0']
23
+ * @property {string|null} [authToken=null]
24
+ * @property {string[]} [allowedOrigins=[]]
25
+ * @property {number} [sessionTimeoutMs=1800000] 30 minutes
26
+ */
27
+
28
+ const DEFAULT_CONFIG = {
29
+ port: 3000,
30
+ host: '0.0.0.0',
31
+ authToken: null,
32
+ allowedOrigins: [],
33
+ sessionTimeoutMs: 30 * 60 * 1000,
34
+ };
35
+
36
+ export class HttpTransportManager {
37
+ /**
38
+ * Create an HTTP server wired to MCP.
39
+ *
40
+ * Each new session gets its own MCP Server + Transport pair (the SDK requires
41
+ * one transport per server instance). The `serverFactory` creates a fresh,
42
+ * fully-configured Server on each `initialize` request.
43
+ *
44
+ * @param {() => import('@modelcontextprotocol/sdk/server/index.js').Server} serverFactory
45
+ * @param {HttpConfig} [config]
46
+ * @returns {import('node:http').Server}
47
+ */
48
+ createServer(serverFactory, config = {}) {
49
+ const cfg = { ...DEFAULT_CONFIG, ...config };
50
+
51
+ if (!cfg.authToken) {
52
+ console.error('[WARN] MCP HTTP transport started WITHOUT Bearer auth (MCP_AUTH_TOKEN not set). Any client can connect.');
53
+ }
54
+
55
+ // Map of sessionId → { transport, server, lastActivity }
56
+ const sessions = new Map();
57
+
58
+ // Session reaper
59
+ const reaper = setInterval(() => {
60
+ const now = Date.now();
61
+ for (const [id, entry] of sessions) {
62
+ if (now - entry.lastActivity > cfg.sessionTimeoutMs) {
63
+ entry.transport.close().catch(() => {});
64
+ sessions.delete(id);
65
+ console.error(`[INFO] Session ${id} expired (timeout ${cfg.sessionTimeoutMs}ms)`);
66
+ }
67
+ }
68
+ }, 60_000);
69
+ reaper.unref();
70
+
71
+ const httpServer = createHttpServer(async (req, res) => {
72
+ const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
73
+ const pathname = url.pathname;
74
+
75
+ // ── Health endpoint ──
76
+ if (pathname === '/health' && req.method === 'GET') {
77
+ res.writeHead(200, { 'Content-Type': 'application/json' });
78
+ res.end(JSON.stringify({ status: 'ok', transport: 'http', version: '3.0.0' }));
79
+ return;
80
+ }
81
+
82
+ // ── Only /mcp from here ──
83
+ if (pathname !== '/mcp') {
84
+ res.writeHead(404, { 'Content-Type': 'application/json' });
85
+ res.end(JSON.stringify({ error: 'Not Found' }));
86
+ return;
87
+ }
88
+
89
+ // ── Origin validation ──
90
+ if (cfg.allowedOrigins.length > 0) {
91
+ const origin = req.headers['origin'];
92
+ if (origin && !cfg.allowedOrigins.includes(origin)) {
93
+ logHttpAudit(req, 'origin_rejected', { origin });
94
+ res.writeHead(403, { 'Content-Type': 'application/json' });
95
+ res.end(JSON.stringify({ error: 'Forbidden', code: 'ORIGIN_NOT_ALLOWED' }));
96
+ return;
97
+ }
98
+ }
99
+
100
+ // ── Bearer auth ──
101
+ const auth = validateBearerToken(req, cfg.authToken);
102
+ if (!auth.valid) {
103
+ res.writeHead(auth.status, { 'Content-Type': 'application/json' });
104
+ res.end(JSON.stringify(auth.body));
105
+ return;
106
+ }
107
+
108
+ // ── Route by method ──
109
+ if (req.method === 'POST') {
110
+ await handlePost(req, res, serverFactory, sessions);
111
+ } else if (req.method === 'GET') {
112
+ await handleGet(req, res, sessions);
113
+ } else if (req.method === 'DELETE') {
114
+ await handleDelete(req, res, sessions);
115
+ } else {
116
+ res.writeHead(405, { 'Content-Type': 'application/json', 'Allow': 'GET, POST, DELETE' });
117
+ res.end(JSON.stringify({ error: 'Method Not Allowed' }));
118
+ }
119
+ });
120
+
121
+ httpServer.on('close', () => {
122
+ clearInterval(reaper);
123
+ for (const [, entry] of sessions) {
124
+ entry.transport.close().catch(() => {});
125
+ }
126
+ sessions.clear();
127
+ });
128
+
129
+ return httpServer;
130
+ }
131
+ }
132
+
133
+ // ──────────────────────────────────────────────────────────────
134
+ // POST /mcp — JSON-RPC request
135
+ // ──────────────────────────────────────────────────────────────
136
+
137
+ async function handlePost(req, res, serverFactory, sessions) {
138
+ // Read body
139
+ const body = await readBody(req);
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(body);
143
+ } catch {
144
+ res.writeHead(400, { 'Content-Type': 'application/json' });
145
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
146
+ return;
147
+ }
148
+
149
+ // Check if this is an initialization request (method === 'initialize')
150
+ const isInit = isInitializeRequest(parsed);
151
+ const sessionId = req.headers['mcp-session-id'];
152
+
153
+ if (isInit) {
154
+ // Create a new transport + server for this session
155
+ const transport = new StreamableHTTPServerTransport({
156
+ sessionIdGenerator: () => randomUUID(),
157
+ });
158
+
159
+ const mcpServer = serverFactory();
160
+
161
+ transport.onclose = () => {
162
+ const sid = transport.sessionId;
163
+ if (sid && sessions.has(sid)) {
164
+ sessions.delete(sid);
165
+ console.error(`[INFO] Session ${sid} closed`);
166
+ }
167
+ };
168
+
169
+ // Connect fresh MCP server to this transport
170
+ await mcpServer.connect(transport);
171
+
172
+ // Handle the request — this will generate the session ID
173
+ await transport.handleRequest(req, res, parsed);
174
+
175
+ // Store session after initialization
176
+ const sid = transport.sessionId;
177
+ if (sid) {
178
+ sessions.set(sid, { transport, server: mcpServer, lastActivity: Date.now() });
179
+ console.error(`[INFO] New HTTP session: ${sid}`);
180
+ }
181
+ return;
182
+ }
183
+
184
+ // Non-init request: must have a valid session
185
+ if (!sessionId || !sessions.has(sessionId)) {
186
+ res.writeHead(400, { 'Content-Type': 'application/json' });
187
+ res.end(JSON.stringify({ error: 'Bad Request', code: 'INVALID_SESSION' }));
188
+ return;
189
+ }
190
+
191
+ const entry = sessions.get(sessionId);
192
+ entry.lastActivity = Date.now();
193
+ await entry.transport.handleRequest(req, res, parsed);
194
+ }
195
+
196
+ // ──────────────────────────────────────────────────────────────
197
+ // GET /mcp — SSE stream for server-to-client notifications
198
+ // ──────────────────────────────────────────────────────────────
199
+
200
+ async function handleGet(req, res, sessions) {
201
+ const sessionId = req.headers['mcp-session-id'];
202
+
203
+ if (!sessionId || !sessions.has(sessionId)) {
204
+ res.writeHead(400, { 'Content-Type': 'application/json' });
205
+ res.end(JSON.stringify({ error: 'Bad Request', code: 'INVALID_SESSION' }));
206
+ return;
207
+ }
208
+
209
+ const entry = sessions.get(sessionId);
210
+ entry.lastActivity = Date.now();
211
+ await entry.transport.handleRequest(req, res);
212
+ }
213
+
214
+ // ──────────────────────────────────────────────────────────────
215
+ // DELETE /mcp — Terminate session
216
+ // ──────────────────────────────────────────────────────────────
217
+
218
+ async function handleDelete(req, res, sessions) {
219
+ const sessionId = req.headers['mcp-session-id'];
220
+
221
+ if (!sessionId || !sessions.has(sessionId)) {
222
+ res.writeHead(404, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify({ error: 'Session not found' }));
224
+ return;
225
+ }
226
+
227
+ const entry = sessions.get(sessionId);
228
+ await entry.transport.close();
229
+ sessions.delete(sessionId);
230
+ console.error(`[INFO] Session ${sessionId} terminated via DELETE`);
231
+ res.writeHead(200, { 'Content-Type': 'application/json' });
232
+ res.end(JSON.stringify({ status: 'session_terminated' }));
233
+ }
234
+
235
+ // ──────────────────────────────────────────────────────────────
236
+ // Helpers
237
+ // ──────────────────────────────────────────────────────────────
238
+
239
+ function readBody(req) {
240
+ return new Promise((resolve, reject) => {
241
+ const chunks = [];
242
+ req.on('data', (chunk) => chunks.push(chunk));
243
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
244
+ req.on('error', reject);
245
+ });
246
+ }
247
+
248
+ function isInitializeRequest(parsed) {
249
+ if (Array.isArray(parsed)) {
250
+ return parsed.some((msg) => msg.method === 'initialize');
251
+ }
252
+ return parsed && parsed.method === 'initialize';
253
+ }
254
+
255
+ function logHttpAudit(req, event, extra = {}) {
256
+ const record = {
257
+ timestamp: new Date().toISOString(),
258
+ event,
259
+ ip: req.socket?.remoteAddress || null,
260
+ path: req.url || null,
261
+ ...extra,
262
+ };
263
+ console.error(`[HTTP] ${JSON.stringify(record)}`);
264
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Shared test helpers for WordPress MCP Server tool tests.
3
+ *
4
+ * Provides:
5
+ * - makeRequest(name, args) — build the { params } envelope handleToolCall expects
6
+ * - mockSuccess(data, opts) — configure fetch to return a successful JSON response
7
+ * - mockError(status, body) — configure fetch to return an HTTP error response
8
+ * - parseResult(result) — extract parsed JSON from the MCP { content } wrapper
9
+ * - getAuditLogs() — collect [AUDIT] lines written to console.error
10
+ */
11
+
12
+ import fetch from 'node-fetch';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Request builder
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Build the request object that handleToolCall expects.
20
+ * @param {string} name Tool name, e.g. "wp_search"
21
+ * @param {object} args Tool arguments
22
+ * @returns {{ params: { name: string, arguments: object } }}
23
+ */
24
+ export function makeRequest(name, args = {}) {
25
+ return { params: { name, arguments: args } };
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Fetch mock helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Configure node-fetch mock to resolve with a JSON success response.
34
+ * Supports chaining: call multiple times via mockImplementationOnce to queue
35
+ * sequential responses.
36
+ *
37
+ * @param {*} data JSON-serialisable payload
38
+ * @param {object} [opts]
39
+ * @param {number} [opts.status] HTTP status code (default 200)
40
+ * @param {boolean} [opts.once] If true use mockImplementationOnce (default true)
41
+ */
42
+ export function mockSuccess(data, opts = {}) {
43
+ const status = opts.status ?? 200;
44
+ const response = {
45
+ ok: true,
46
+ status,
47
+ headers: {
48
+ get: (h) => {
49
+ if (h === 'content-type') return 'application/json';
50
+ return null;
51
+ },
52
+ },
53
+ json: () => Promise.resolve(data),
54
+ text: () => Promise.resolve(JSON.stringify(data)),
55
+ };
56
+ const impl = () => Promise.resolve(response);
57
+
58
+ if (opts.once === false) {
59
+ fetch.mockImplementation(impl);
60
+ } else {
61
+ fetch.mockImplementationOnce(impl);
62
+ }
63
+ return response;
64
+ }
65
+
66
+ /**
67
+ * Configure node-fetch mock to resolve with an HTTP error response.
68
+ *
69
+ * @param {number} status HTTP status code (e.g. 403, 404)
70
+ * @param {string} [body] Response body text
71
+ * @param {object} [opts]
72
+ * @param {boolean} [opts.once] If true use mockImplementationOnce (default true)
73
+ */
74
+ export function mockError(status, body = '', opts = {}) {
75
+ const response = {
76
+ ok: false,
77
+ status,
78
+ headers: {
79
+ get: (h) => {
80
+ if (h === 'content-type') return 'application/json';
81
+ if (h === 'retry-after') return null;
82
+ return null;
83
+ },
84
+ },
85
+ json: () => Promise.resolve({}),
86
+ text: () => Promise.resolve(body),
87
+ };
88
+ const impl = () => Promise.resolve(response);
89
+
90
+ if (opts.once === false) {
91
+ fetch.mockImplementation(impl);
92
+ } else {
93
+ fetch.mockImplementationOnce(impl);
94
+ }
95
+ return response;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Result parser
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Parse the JSON payload from the MCP tool result envelope.
104
+ *
105
+ * handleToolCall returns `{ content: [{ type: 'text', text: '...' }] }`.
106
+ * This extracts and JSON-parses the text field.
107
+ *
108
+ * @param {object} result Return value from handleToolCall
109
+ * @returns {object} Parsed JSON object
110
+ */
111
+ export function parseResult(result) {
112
+ const text = result?.content?.[0]?.text;
113
+ if (!text) return result;
114
+ return JSON.parse(text);
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Audit log collector
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Return all `[AUDIT]` entries that have been written to console.error
123
+ * during the current test. Relies on the `consoleSpy` created in each
124
+ * test file's `beforeEach`.
125
+ *
126
+ * @returns {object[]} Array of parsed audit-log JSON objects.
127
+ */
128
+ export function getAuditLogs() {
129
+ const spy = console.error;
130
+ if (!spy || !spy.mock) return [];
131
+ return spy.mock.calls
132
+ .map((c) => c[0])
133
+ .filter((msg) => typeof msg === 'string' && msg.startsWith('[AUDIT]'))
134
+ .map((msg) => JSON.parse(msg.replace('[AUDIT] ', '')));
135
+ }