@adsim/wordpress-mcp-server 4.6.0 → 5.1.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.
- package/.env.example +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adsim/wordpress-mcp-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
|
+
"description": "Enterprise WordPress MCP Server — 180 tools, modular architecture, governance, audit trail, WooCommerce, Schema.org, multilingual.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -24,18 +24,26 @@
|
|
|
24
24
|
"claude",
|
|
25
25
|
"ai",
|
|
26
26
|
"automation",
|
|
27
|
-
"cms"
|
|
27
|
+
"cms",
|
|
28
|
+
"seo",
|
|
29
|
+
"woocommerce",
|
|
30
|
+
"schema-org",
|
|
31
|
+
"multilingual",
|
|
32
|
+
"security-audit",
|
|
33
|
+
"performance",
|
|
34
|
+
"enterprise",
|
|
35
|
+
"full-site-editing"
|
|
28
36
|
],
|
|
29
37
|
"author": "Georges Cordewiener <georges@adsim.be>",
|
|
30
38
|
"license": "MIT",
|
|
31
39
|
"repository": {
|
|
32
40
|
"type": "git",
|
|
33
|
-
"url": "git+https://github.com/
|
|
41
|
+
"url": "git+https://github.com/GeorgesAdSim/wordpress-mcp-server.git"
|
|
34
42
|
},
|
|
35
43
|
"bugs": {
|
|
36
|
-
"url": "https://github.com/
|
|
44
|
+
"url": "https://github.com/GeorgesAdSim/wordpress-mcp-server/issues"
|
|
37
45
|
},
|
|
38
|
-
"homepage": "https://github.com/
|
|
46
|
+
"homepage": "https://github.com/GeorgesAdSim/wordpress-mcp-server#readme",
|
|
39
47
|
"engines": {
|
|
40
48
|
"node": ">=18.0.0"
|
|
41
49
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": {
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Estimated performance impact of common WordPress plugins. Impact scores are relative (1=minimal, 5=heavy). Data compiled from WebPageTest benchmarks, Query Monitor profiling, and community reports.",
|
|
5
|
+
"last_updated": "2025-03-01"
|
|
6
|
+
},
|
|
7
|
+
"plugins": {
|
|
8
|
+
"elementor/elementor.php": { "name": "Elementor", "impact": 4, "category": "page-builder", "notes": "Heavy DOM, inline CSS, multiple JS files. Use with landing pages only if possible." },
|
|
9
|
+
"elementor-pro/elementor-pro.php": { "name": "Elementor Pro", "impact": 5, "category": "page-builder", "notes": "Adds motion effects, popups, form widgets. Significant render cost." },
|
|
10
|
+
"js_composer/js_composer.php": { "name": "WPBakery Page Builder", "impact": 5, "category": "page-builder", "notes": "Legacy shortcode-based builder. Heavy inline styles and scripts." },
|
|
11
|
+
"beaver-builder-lite-version/fl-builder.php": { "name": "Beaver Builder", "impact": 3, "category": "page-builder", "notes": "Cleaner output than Elementor. Moderate JS footprint." },
|
|
12
|
+
"divi-builder/divi-builder.php": { "name": "Divi Builder", "impact": 5, "category": "page-builder", "notes": "Very heavy CSS and JS. One of the heaviest page builders." },
|
|
13
|
+
"wordpress-seo/wp-seo.php": { "name": "Yoast SEO", "impact": 2, "category": "seo", "notes": "Schema output and sitemap generation. Moderate admin overhead." },
|
|
14
|
+
"seo-by-rank-math/rank-math.php": { "name": "Rank Math", "impact": 2, "category": "seo", "notes": "Similar to Yoast. Slightly leaner frontend output." },
|
|
15
|
+
"wp-seopress/seopress.php": { "name": "SEOPress", "impact": 1, "category": "seo", "notes": "Lightweight SEO plugin with minimal frontend impact." },
|
|
16
|
+
"all-in-one-seo-pack/all_in_one_seo_pack.php": { "name": "All in One SEO", "impact": 2, "category": "seo", "notes": "Moderate impact, similar to Yoast." },
|
|
17
|
+
"woocommerce/woocommerce.php": { "name": "WooCommerce", "impact": 4, "category": "ecommerce", "notes": "Large plugin with many DB queries. Cart fragments AJAX is a known bottleneck." },
|
|
18
|
+
"woocommerce-payments/woocommerce-payments.php": { "name": "WooCommerce Payments", "impact": 2, "category": "ecommerce", "notes": "External Stripe JS loaded on checkout." },
|
|
19
|
+
"contact-form-7/wp-contact-form-7.php": { "name": "Contact Form 7", "impact": 2, "category": "forms", "notes": "Loads CSS/JS on all pages by default. Use conditional loading." },
|
|
20
|
+
"wpforms-lite/wpforms.php": { "name": "WPForms Lite", "impact": 2, "category": "forms", "notes": "Loads assets only on pages with forms (better than CF7 defaults)." },
|
|
21
|
+
"gravityforms/gravityforms.php": { "name": "Gravity Forms", "impact": 2, "category": "forms", "notes": "Premium form plugin. Moderate impact, conditional loading available." },
|
|
22
|
+
"ninja-forms/ninja-forms.php": { "name": "Ninja Forms", "impact": 3, "category": "forms", "notes": "React-based frontend can be heavy. Multiple JS bundles." },
|
|
23
|
+
"wordfence/wordfence.php": { "name": "Wordfence", "impact": 3, "category": "security", "notes": "Filesystem scanning and firewall rules add server-side latency. Minimal frontend impact." },
|
|
24
|
+
"better-wp-security/better-wp-security.php": { "name": "iThemes Security", "impact": 2, "category": "security", "notes": "Moderate server-side overhead for brute force protection and scanning." },
|
|
25
|
+
"sucuri-scanner/sucuri.php": { "name": "Sucuri Security", "impact": 2, "category": "security", "notes": "File integrity monitoring. Low frontend impact when using CDN firewall." },
|
|
26
|
+
"all-in-one-wp-security-and-firewall/wp-security.php": { "name": "All In One WP Security", "impact": 2, "category": "security", "notes": "Feature-rich security plugin. Moderate DB overhead." },
|
|
27
|
+
"wp-rocket/wp-rocket.php": { "name": "WP Rocket", "impact": -3, "category": "performance", "notes": "Page caching, CSS/JS minification, lazy loading. Significant positive impact." },
|
|
28
|
+
"w3-total-cache/w3-total-cache.php": { "name": "W3 Total Cache", "impact": -2, "category": "performance", "notes": "Complex caching plugin. Positive impact when configured correctly." },
|
|
29
|
+
"litespeed-cache/litespeed-cache.php": { "name": "LiteSpeed Cache", "impact": -3, "category": "performance", "notes": "Server-level cache integration. Excellent performance on LiteSpeed servers." },
|
|
30
|
+
"wp-super-cache/wp-cache.php": { "name": "WP Super Cache", "impact": -2, "category": "performance", "notes": "Simple page caching. Good basic performance improvement." },
|
|
31
|
+
"autoptimize/autoptimize.php": { "name": "Autoptimize", "impact": -2, "category": "performance", "notes": "CSS/JS minification and concatenation. Good complement to caching plugins." },
|
|
32
|
+
"wp-fastest-cache/wpFastestCache.php": { "name": "WP Fastest Cache", "impact": -2, "category": "performance", "notes": "Simple caching with minification. Easy to configure." },
|
|
33
|
+
"jetpack/jetpack.php": { "name": "Jetpack", "impact": 4, "category": "multi-purpose", "notes": "Loads many modules by default. External API calls on every page load. Disable unused modules." },
|
|
34
|
+
"jetpack-boost/jetpack-boost.php": { "name": "Jetpack Boost", "impact": -2, "category": "performance", "notes": "Lightweight performance module. CSS optimization and lazy images." },
|
|
35
|
+
"akismet/akismet.php": { "name": "Akismet", "impact": 1, "category": "anti-spam", "notes": "External API call for comment processing. Minimal frontend impact." },
|
|
36
|
+
"classic-editor/classic-editor.php": { "name": "Classic Editor", "impact": 0, "category": "editor", "notes": "Disables Gutenberg. No frontend impact." },
|
|
37
|
+
"advanced-custom-fields/acf.php": { "name": "Advanced Custom Fields", "impact": 1, "category": "custom-fields", "notes": "Minimal frontend impact. Admin-side overhead for field rendering." },
|
|
38
|
+
"advanced-custom-fields-pro/acf.php": { "name": "ACF Pro", "impact": 1, "category": "custom-fields", "notes": "Same as ACF with additional field types. Low impact." },
|
|
39
|
+
"revslider/revslider.php": { "name": "Slider Revolution", "impact": 5, "category": "slider", "notes": "Very heavy. Large JS/CSS payload, render-blocking. Consider alternatives." },
|
|
40
|
+
"LayerSlider/layerslider.php": { "name": "LayerSlider", "impact": 4, "category": "slider", "notes": "Heavy animation library. Significant CLS and LCP impact." },
|
|
41
|
+
"smart-slider-3/smart-slider-3.php": { "name": "Smart Slider 3", "impact": 3, "category": "slider", "notes": "Moderate impact. Lazy loading available." },
|
|
42
|
+
"wp-smushit/wp-smush.php": { "name": "Smush", "impact": -1, "category": "image-optimization", "notes": "Image compression and lazy loading. Positive impact on image-heavy sites." },
|
|
43
|
+
"imagify/imagify.php": { "name": "Imagify", "impact": -1, "category": "image-optimization", "notes": "Image optimization with WebP conversion. Reduces page weight." },
|
|
44
|
+
"shortpixel-image-optimiser/wp-shortpixel.php": { "name": "ShortPixel", "impact": -1, "category": "image-optimization", "notes": "Image compression service. Low overhead, good optimization." },
|
|
45
|
+
"ewww-image-optimizer/ewww-image-optimizer.php": { "name": "EWWW Image Optimizer", "impact": -1, "category": "image-optimization", "notes": "Local and cloud image optimization. WebP support." },
|
|
46
|
+
"google-analytics-for-wordpress/googleanalytics.php": { "name": "MonsterInsights", "impact": 3, "category": "analytics", "notes": "Loads GA script plus its own tracking layer. Use native GA tag instead." },
|
|
47
|
+
"google-site-kit/google-site-kit.php": { "name": "Google Site Kit", "impact": 2, "category": "analytics", "notes": "Admin dashboard overhead. Minimal frontend impact if using native GA tag." },
|
|
48
|
+
"mailchimp-for-wp/mailchimp-for-wp.php": { "name": "MC4WP Mailchimp", "impact": 1, "category": "email-marketing", "notes": "Minimal impact. Loads form assets conditionally." },
|
|
49
|
+
"the-events-calendar/the-events-calendar.php": { "name": "The Events Calendar", "impact": 3, "category": "events", "notes": "Custom post type with complex queries. Moderate frontend overhead." },
|
|
50
|
+
"tablepress/tablepress.php": { "name": "TablePress", "impact": 2, "category": "content", "notes": "DataTables JS library loaded on table pages. Moderate impact." },
|
|
51
|
+
"updraftplus/updraftplus.php": { "name": "UpdraftPlus", "impact": 1, "category": "backup", "notes": "Runs on cron schedule. No frontend impact during normal browsing." },
|
|
52
|
+
"duplicator/duplicator.php": { "name": "Duplicator", "impact": 0, "category": "backup", "notes": "Only active during migrations. Zero runtime impact." },
|
|
53
|
+
"redirection/redirection.php": { "name": "Redirection", "impact": 1, "category": "seo", "notes": "DB query per request for redirect rules. Minimal for small rule sets." },
|
|
54
|
+
"broken-link-checker/broken-link-checker.php": { "name": "Broken Link Checker", "impact": 4, "category": "seo", "notes": "Background scanning causes high server load. Use sparingly or via CLI." },
|
|
55
|
+
"regenerate-thumbnails/regenerate-thumbnails.php": { "name": "Regenerate Thumbnails", "impact": 0, "category": "media", "notes": "Only active when running regeneration. Zero runtime impact." },
|
|
56
|
+
"wordpress-popular-posts/wordpress-popular-posts.php": { "name": "WordPress Popular Posts", "impact": 3, "category": "content", "notes": "DB writes on every page view for tracking. Use caching or external tracking." },
|
|
57
|
+
"amp/amp.php": { "name": "AMP", "impact": 2, "category": "performance", "notes": "Generates AMP versions. Can conflict with other plugins. Mixed impact." }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress REST API caller with retry, timeout & audit — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copy; this will replace it in Step B+.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: wpApiCall depends on runtime state (currentTarget, fetch, log, VERSION)
|
|
6
|
+
* that lives in index.js. For now this file exports the constants and a factory.
|
|
7
|
+
* The actual migration will wire these up in a later step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── API configuration constants ──
|
|
11
|
+
export const MAX_RETRIES = parseInt(process.env.WP_MCP_MAX_RETRIES || '3', 10);
|
|
12
|
+
export const TIMEOUT_MS = parseInt(process.env.WP_MCP_TIMEOUT || '30000', 10);
|
|
13
|
+
export const RETRY_BASE_DELAY_MS = 1000;
|
|
14
|
+
|
|
15
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a wpApiCall function bound to dependencies.
|
|
19
|
+
* @param {Object} deps - { fetch, getActiveAuth, log, VERSION }
|
|
20
|
+
* @returns {Function} wpApiCall(endpoint, options)
|
|
21
|
+
*/
|
|
22
|
+
export function createWpApiCall({ fetch, getActiveAuth, log, VERSION }) {
|
|
23
|
+
return async function wpApiCall(endpoint, options = {}) {
|
|
24
|
+
const { url: baseUrl, auth: activeAuth } = getActiveAuth();
|
|
25
|
+
const basePath = options.basePath || '/wp-json/wp/v2';
|
|
26
|
+
const url = `${baseUrl}${basePath}${endpoint}`;
|
|
27
|
+
const method = options.method || 'GET';
|
|
28
|
+
const headers = {
|
|
29
|
+
'Authorization': `Basic ${activeAuth}`,
|
|
30
|
+
'User-Agent': `WordPress-MCP-Server/${VERSION}`,
|
|
31
|
+
...options.headers
|
|
32
|
+
};
|
|
33
|
+
if (!options.isMultipart) headers['Content-Type'] = 'application/json';
|
|
34
|
+
|
|
35
|
+
let lastError;
|
|
36
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const tid = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
39
|
+
try {
|
|
40
|
+
log.debug(`${method} ${endpoint} (${attempt}/${MAX_RETRIES})`);
|
|
41
|
+
const t0 = Date.now();
|
|
42
|
+
const fetchOpts = { method, headers, signal: controller.signal };
|
|
43
|
+
if (options.body) fetchOpts.body = options.body;
|
|
44
|
+
const response = await fetch(url, fetchOpts);
|
|
45
|
+
clearTimeout(tid);
|
|
46
|
+
log.debug(`${response.status} in ${Date.now() - t0}ms`);
|
|
47
|
+
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
const errorText = await response.text();
|
|
50
|
+
const sc = response.status;
|
|
51
|
+
if (sc >= 400 && sc < 500 && sc !== 429) {
|
|
52
|
+
const hints = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed' };
|
|
53
|
+
throw new Error(`WP API ${sc}: ${hints[sc] || ''}\n${errorText}`);
|
|
54
|
+
}
|
|
55
|
+
lastError = new Error(`WP API ${sc}: ${errorText}`);
|
|
56
|
+
if (sc === 429) { await sleep(parseInt(response.headers.get('retry-after') || '5', 10) * 1000); continue; }
|
|
57
|
+
if (attempt < MAX_RETRIES) { await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1)); continue; }
|
|
58
|
+
throw lastError;
|
|
59
|
+
}
|
|
60
|
+
const ct = response.headers.get('content-type');
|
|
61
|
+
if (!ct || !ct.includes('application/json')) return { success: true, status: response.status };
|
|
62
|
+
const jsonData = await response.json();
|
|
63
|
+
const wpTotal = response.headers.get('x-wp-total');
|
|
64
|
+
if (wpTotal !== null && jsonData !== null && typeof jsonData === 'object') {
|
|
65
|
+
Object.defineProperty(jsonData, '_wpTotal', { value: parseInt(wpTotal, 10), enumerable: false, configurable: true });
|
|
66
|
+
Object.defineProperty(jsonData, '_wpTotalPages', { value: parseInt(response.headers.get('x-wp-totalpages') || '0', 10), enumerable: false, configurable: true });
|
|
67
|
+
}
|
|
68
|
+
return jsonData;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
clearTimeout(tid);
|
|
71
|
+
if (error.name === 'AbortError') lastError = new Error(`Timeout ${TIMEOUT_MS}ms`);
|
|
72
|
+
else if (error.message.includes('WP API 4')) throw error;
|
|
73
|
+
else lastError = error;
|
|
74
|
+
if (attempt < MAX_RETRIES) await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw lastError || new Error(`Failed after ${MAX_RETRIES} attempts`);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit logging — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copy; this will replace it in Step B+.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const AUDIT_LOG = process.env.WP_AUDIT_LOG !== 'off';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Log an audit record to stderr.
|
|
10
|
+
* @param {Object} entry - Audit entry fields
|
|
11
|
+
* @param {Object} [context] - Runtime context { currentTarget }
|
|
12
|
+
*/
|
|
13
|
+
export function auditLog(entry, context = {}) {
|
|
14
|
+
if (!AUDIT_LOG) return;
|
|
15
|
+
const record = {
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
tool: entry.tool,
|
|
18
|
+
target: entry.target || null,
|
|
19
|
+
target_type: entry.target_type || null,
|
|
20
|
+
action: entry.action || null,
|
|
21
|
+
status: entry.status,
|
|
22
|
+
latency_ms: entry.latency_ms,
|
|
23
|
+
site: entry.site || context.currentTargetName || 'default',
|
|
24
|
+
params: entry.params || {},
|
|
25
|
+
error: entry.error || null,
|
|
26
|
+
...(entry.effective_controls ? { effective_controls: entry.effective_controls } : {})
|
|
27
|
+
};
|
|
28
|
+
console.error(`[AUDIT] ${JSON.stringify(record)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sanitize params for audit (remove content/body to keep logs small).
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeParams(args) {
|
|
35
|
+
const safe = { ...args };
|
|
36
|
+
if (safe.content && safe.content.length > 100) safe.content = `[${safe.content.length} chars]`;
|
|
37
|
+
if (safe.body) safe.body = '[binary]';
|
|
38
|
+
return safe;
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime context — holds dependencies that require index.js state.
|
|
3
|
+
* Populated by index.js at startup via initRuntime().
|
|
4
|
+
* Handler modules import `rt` and destructure what they need.
|
|
5
|
+
*
|
|
6
|
+
* Uses Object.defineProperties to preserve getters for mutable state
|
|
7
|
+
* like currentTarget and targets.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const rt = {};
|
|
11
|
+
|
|
12
|
+
export function initRuntime(deps) {
|
|
13
|
+
const descriptors = Object.getOwnPropertyDescriptors(deps);
|
|
14
|
+
Object.defineProperties(rt, descriptors);
|
|
15
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise governance controls — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copy; this will replace it in Step B+.
|
|
4
|
+
*
|
|
5
|
+
* NOTE: enforceReadOnly and getActiveControls depend on runtime state (currentTarget).
|
|
6
|
+
* For now these are exported as factories or standalone functions that accept context.
|
|
7
|
+
* The actual migration will wire these up in a later step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Governance constants ──
|
|
11
|
+
export const READ_ONLY = process.env.WP_READ_ONLY === 'true';
|
|
12
|
+
export const DRAFT_ONLY = process.env.WP_DRAFT_ONLY === 'true';
|
|
13
|
+
export const DISABLE_DELETE = process.env.WP_DISABLE_DELETE === 'true';
|
|
14
|
+
export const DISABLE_PLUGIN_MANAGEMENT = process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true';
|
|
15
|
+
export const REQUIRE_APPROVAL = process.env.WP_REQUIRE_APPROVAL === 'true';
|
|
16
|
+
export const CONFIRM_DESTRUCTIVE = process.env.WP_CONFIRM_DESTRUCTIVE === 'true';
|
|
17
|
+
export const MAX_CALLS_PER_MINUTE = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
18
|
+
export const ALLOWED_TYPES = process.env.WP_ALLOWED_TYPES ? process.env.WP_ALLOWED_TYPES.split(',').map(s => s.trim()) : null;
|
|
19
|
+
export const ALLOWED_STATUSES = process.env.WP_ALLOWED_STATUSES ? process.env.WP_ALLOWED_STATUSES.split(',').map(s => s.trim()) : null;
|
|
20
|
+
export const AUDIT_LOG_ENABLED = process.env.WP_AUDIT_LOG !== 'off';
|
|
21
|
+
export const VISUAL_STAGING = process.env.WP_VISUAL_STAGING === 'true';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compute active controls by merging global env vars with per-target overrides.
|
|
25
|
+
* @param {Object} [currentTarget] - The current target config (with optional .controls)
|
|
26
|
+
*/
|
|
27
|
+
export function getActiveControls(currentTarget = null) {
|
|
28
|
+
const tc = currentTarget?.controls || {};
|
|
29
|
+
const globalMaxCpm = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
30
|
+
const targetMaxCpm = tc.max_calls_per_minute || 0;
|
|
31
|
+
let effectiveMaxCpm;
|
|
32
|
+
if (globalMaxCpm > 0 && targetMaxCpm > 0) effectiveMaxCpm = Math.min(globalMaxCpm, targetMaxCpm);
|
|
33
|
+
else if (globalMaxCpm > 0) effectiveMaxCpm = globalMaxCpm;
|
|
34
|
+
else if (targetMaxCpm > 0) effectiveMaxCpm = targetMaxCpm;
|
|
35
|
+
else effectiveMaxCpm = 0;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
read_only: process.env.WP_READ_ONLY === 'true' || tc.read_only === true,
|
|
39
|
+
draft_only: process.env.WP_DRAFT_ONLY === 'true' || tc.draft_only === true,
|
|
40
|
+
disable_delete: process.env.WP_DISABLE_DELETE === 'true' || tc.disable_delete === true,
|
|
41
|
+
disable_plugin_management: process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true' || tc.disable_plugin_management === true,
|
|
42
|
+
require_approval: process.env.WP_REQUIRE_APPROVAL === 'true' || tc.require_approval === true,
|
|
43
|
+
confirm_destructive: process.env.WP_CONFIRM_DESTRUCTIVE === 'true' || tc.confirm_destructive === true,
|
|
44
|
+
max_calls_per_minute: effectiveMaxCpm,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute control sources (global vs target vs both).
|
|
50
|
+
* @param {Object} [currentTarget] - The current target config
|
|
51
|
+
*/
|
|
52
|
+
export function getControlSources(currentTarget = null) {
|
|
53
|
+
const tc = currentTarget?.controls || {};
|
|
54
|
+
const src = (g, t) => (g && t) ? 'both' : g ? 'global' : t ? 'target' : 'none';
|
|
55
|
+
const globalMaxCpm = parseInt(process.env.WP_MAX_CALLS_PER_MINUTE || '0', 10);
|
|
56
|
+
const targetMaxCpm = tc.max_calls_per_minute || 0;
|
|
57
|
+
return {
|
|
58
|
+
read_only_source: src(process.env.WP_READ_ONLY === 'true', tc.read_only === true),
|
|
59
|
+
draft_only_source: src(process.env.WP_DRAFT_ONLY === 'true', tc.draft_only === true),
|
|
60
|
+
disable_delete_source: src(process.env.WP_DISABLE_DELETE === 'true', tc.disable_delete === true),
|
|
61
|
+
disable_plugin_management_source: src(process.env.WP_DISABLE_PLUGIN_MANAGEMENT === 'true', tc.disable_plugin_management === true),
|
|
62
|
+
require_approval_source: src(process.env.WP_REQUIRE_APPROVAL === 'true', tc.require_approval === true),
|
|
63
|
+
confirm_destructive_source: src(process.env.WP_CONFIRM_DESTRUCTIVE === 'true', tc.confirm_destructive === true),
|
|
64
|
+
max_calls_per_minute_source: (globalMaxCpm > 0 && targetMaxCpm > 0) ? 'both' : (globalMaxCpm > 0 ? 'global' : (targetMaxCpm > 0 ? 'target' : 'none')),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate input arguments against a rules object.
|
|
70
|
+
*/
|
|
71
|
+
export function validateInput(args, rules) {
|
|
72
|
+
const errors = [];
|
|
73
|
+
for (const [field, rule] of Object.entries(rules)) {
|
|
74
|
+
const value = args[field];
|
|
75
|
+
if (rule.required && (value === undefined || value === null || value === '')) { errors.push(`"${field}" is required`); continue; }
|
|
76
|
+
if (value === undefined || value === null) continue;
|
|
77
|
+
if (rule.type === 'number') {
|
|
78
|
+
if (typeof value !== 'number' || isNaN(value)) errors.push(`"${field}" must be a number`);
|
|
79
|
+
else { if (rule.min !== undefined && value < rule.min) errors.push(`"${field}" >= ${rule.min}`); if (rule.max !== undefined && value > rule.max) errors.push(`"${field}" <= ${rule.max}`); }
|
|
80
|
+
}
|
|
81
|
+
if (rule.type === 'string' && typeof value !== 'string') errors.push(`"${field}" must be a string`);
|
|
82
|
+
else if (rule.type === 'string' && rule.enum && !rule.enum.includes(value)) errors.push(`"${field}" must be: ${rule.enum.join(', ')}`);
|
|
83
|
+
if (rule.type === 'array' && !Array.isArray(value)) errors.push(`"${field}" must be an array`);
|
|
84
|
+
}
|
|
85
|
+
if (errors.length > 0) throw new Error(`Validation:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Enforce read-only mode — throws if tool is a write tool and read_only is active.
|
|
90
|
+
* @param {string} toolName
|
|
91
|
+
* @param {Object} controls - Result of getActiveControls()
|
|
92
|
+
*/
|
|
93
|
+
export function enforceReadOnly(toolName, controls) {
|
|
94
|
+
const writeTools = ['wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page', 'wp_update_page', 'wp_upload_media', 'wp_create_comment', 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin', 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision', 'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post', 'wp_create_staging_draft', 'wp_merge_staging_to_live', 'wp_discard_staging_draft', 'wc_list_products', 'wc_get_product', 'wc_list_orders', 'wc_get_order', 'wc_list_customers', 'wc_inventory_alert', 'wc_order_intelligence', 'wc_seo_product_audit', 'wc_suggest_product_links', 'wc_update_product', 'wc_update_stock', 'wc_update_order_status', 'wp_create_template', 'wp_update_template', 'wp_delete_template', 'wp_create_template_part', 'wp_update_template_part', 'wp_delete_template_part', 'wp_update_global_styles', 'wp_create_block_pattern', 'wp_delete_block_pattern', 'wp_create_navigation_menu', 'wp_update_navigation_menu', 'wp_delete_navigation_menu', 'wp_update_widget', 'wp_delete_widget', 'wp_create_user', 'wp_update_user', 'wp_delete_user', 'wp_reset_user_password', 'wp_revoke_application_password', 'wp_bulk_update'];
|
|
95
|
+
if (controls.read_only && writeTools.includes(toolName)) {
|
|
96
|
+
throw new Error(`Blocked: Server is in READ-ONLY mode (WP_READ_ONLY=true). Tool "${toolName}" is not allowed.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility functions — extracted from index.js (v5.0.0 refactor Step A).
|
|
3
|
+
* index.js still contains its own copies; these will replace them in Step B+.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Compact JSON output ──
|
|
7
|
+
const WP_COMPACT_JSON = process.env.WP_COMPACT_JSON !== 'false';
|
|
8
|
+
|
|
9
|
+
export function json(data) {
|
|
10
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, WP_COMPACT_JSON ? 0 : 2) }] };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ── HTML strip ──
|
|
14
|
+
export function strip(html) {
|
|
15
|
+
return (html || '').replace(/<[^>]*>/g, '').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Pagination helper ──
|
|
19
|
+
export function buildPaginationMeta(total, page, perPage) {
|
|
20
|
+
return {
|
|
21
|
+
page: parseInt(page),
|
|
22
|
+
per_page: parseInt(perPage),
|
|
23
|
+
total: parseInt(total),
|
|
24
|
+
total_pages: Math.ceil(parseInt(total) / parseInt(perPage)),
|
|
25
|
+
has_more: (parseInt(page) * parseInt(perPage)) < parseInt(total),
|
|
26
|
+
next_page: ((parseInt(page) * parseInt(perPage)) < parseInt(total)) ? parseInt(page) + 1 : null
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Gutenberg block validator ──
|
|
31
|
+
export const DEPRECATED_BLOCKS = [
|
|
32
|
+
'core/subhead', 'core/text-columns', 'core/cover-image', 'core/button',
|
|
33
|
+
'core/blocks-gallery', 'core/latest-posts', 'core/categories',
|
|
34
|
+
'core/archives', 'core/calendar', 'core/tag-cloud'
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const SELF_CLOSING_BLOCKS = [
|
|
38
|
+
'core/separator', 'core/spacer', 'core/nextpage', 'core/more', 'core/missing'
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const NO_NEST_IN_SELF = [
|
|
42
|
+
'core/paragraph', 'core/heading', 'core/code', 'core/preformatted', 'core/verse'
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function validateBlocks(content, strict = false, fixSuggestions = true) {
|
|
46
|
+
const errors = [];
|
|
47
|
+
const warnings = [];
|
|
48
|
+
const suggestions = [];
|
|
49
|
+
const blockCounts = {};
|
|
50
|
+
const lines = content.split('\n');
|
|
51
|
+
|
|
52
|
+
const openStack = [];
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const line = lines[i];
|
|
56
|
+
const lineNum = i + 1;
|
|
57
|
+
|
|
58
|
+
let match;
|
|
59
|
+
const openRe = /<!--\s*wp:([a-z][a-z0-9-]*\/)?([a-z][a-z0-9-]*)\s*(\{.*?\})?\s*(\/)?-->/g;
|
|
60
|
+
while ((match = openRe.exec(line)) !== null) {
|
|
61
|
+
const ns = match[1] || 'core/';
|
|
62
|
+
const blockName = `${ns}${match[2]}`;
|
|
63
|
+
const attrJson = match[3];
|
|
64
|
+
const selfClosing = match[4] === '/';
|
|
65
|
+
|
|
66
|
+
blockCounts[blockName] = (blockCounts[blockName] || 0) + 1;
|
|
67
|
+
|
|
68
|
+
if (DEPRECATED_BLOCKS.includes(blockName)) {
|
|
69
|
+
warnings.push({ type: 'deprecated_block', block: blockName, message: `Block "${blockName}" is deprecated` });
|
|
70
|
+
if (fixSuggestions) suggestions.push({ issue: `Deprecated block "${blockName}"`, fix: `Replace with its modern equivalent` });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (attrJson) {
|
|
74
|
+
try { JSON.parse(attrJson); } catch (e) {
|
|
75
|
+
errors.push({ type: 'malformed_json', block: blockName, line: lineNum, message: `Malformed JSON attributes in ${blockName}: ${e.message}` });
|
|
76
|
+
if (fixSuggestions) suggestions.push({ issue: `Invalid JSON in ${blockName} at line ${lineNum}`, fix: `Fix JSON syntax: ${attrJson}` });
|
|
77
|
+
}
|
|
78
|
+
} else if (strict && !selfClosing && !SELF_CLOSING_BLOCKS.includes(blockName)) {
|
|
79
|
+
errors.push({ type: 'missing_attributes', block: blockName, line: lineNum, message: `Block "${blockName}" has no attributes (strict mode)` });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (openStack.length > 0 && NO_NEST_IN_SELF.includes(blockName)) {
|
|
83
|
+
const parent = openStack[openStack.length - 1];
|
|
84
|
+
if (NO_NEST_IN_SELF.includes(parent.name)) {
|
|
85
|
+
errors.push({ type: 'invalid_nesting', block: blockName, line: lineNum, message: `"${blockName}" cannot be nested inside "${parent.name}"` });
|
|
86
|
+
if (fixSuggestions) suggestions.push({ issue: `Invalid nesting at line ${lineNum}`, fix: `Close "${parent.name}" before opening "${blockName}"` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!selfClosing) {
|
|
91
|
+
openStack.push({ name: blockName, line: lineNum });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const closeRe = /<!--\s*\/wp:([a-z][a-z0-9-]*\/)?([a-z][a-z0-9-]*)\s*-->/g;
|
|
96
|
+
while ((match = closeRe.exec(line)) !== null) {
|
|
97
|
+
const ns = match[1] || 'core/';
|
|
98
|
+
const closeName = `${ns}${match[2]}`;
|
|
99
|
+
if (openStack.length === 0) {
|
|
100
|
+
errors.push({ type: 'unexpected_close', block: closeName, line: lineNum, message: `Closing comment for "${closeName}" without matching opening` });
|
|
101
|
+
} else {
|
|
102
|
+
const top = openStack[openStack.length - 1];
|
|
103
|
+
if (top.name === closeName) {
|
|
104
|
+
openStack.pop();
|
|
105
|
+
} else {
|
|
106
|
+
errors.push({ type: 'mismatched_close', block: closeName, line: lineNum, message: `Expected closing for "${top.name}" (opened line ${top.line}), found "${closeName}"` });
|
|
107
|
+
if (fixSuggestions) suggestions.push({ issue: `Mismatched block at line ${lineNum}`, fix: `Close "${top.name}" before closing "${closeName}"` });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const open of openStack) {
|
|
114
|
+
errors.push({ type: 'unclosed_block', block: open.name, line: open.line, message: `Block "${open.name}" opened at line ${open.line} is never closed` });
|
|
115
|
+
if (fixSuggestions) suggestions.push({ issue: `Unclosed "${open.name}" at line ${open.line}`, fix: `Add <!-- /wp:${open.name.replace('core/', '')} --> after the block content` });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const htmlContent = content.replace(/<!--.*?-->/gs, '');
|
|
119
|
+
const voidElements = new Set(['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr']);
|
|
120
|
+
const openTags = [];
|
|
121
|
+
const tagRe = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*\/?>/g;
|
|
122
|
+
let match;
|
|
123
|
+
while ((match = tagRe.exec(htmlContent)) !== null) {
|
|
124
|
+
const fullMatch = match[0];
|
|
125
|
+
const tagName = match[1].toLowerCase();
|
|
126
|
+
if (voidElements.has(tagName) || fullMatch.endsWith('/>')) continue;
|
|
127
|
+
if (fullMatch.startsWith('</')) {
|
|
128
|
+
if (openTags.length > 0 && openTags[openTags.length - 1] === tagName) openTags.pop();
|
|
129
|
+
} else {
|
|
130
|
+
openTags.push(tagName);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const tag of openTags) {
|
|
134
|
+
warnings.push({ type: 'unclosed_html_tag', block: null, message: `HTML tag <${tag}> may not be properly closed` });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const blocksFound = Object.entries(blockCounts).map(([name, count]) => ({ name, count }));
|
|
138
|
+
const totalBlocks = blocksFound.reduce((s, b) => s + b.count, 0);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
valid: errors.length === 0,
|
|
142
|
+
errors,
|
|
143
|
+
warnings,
|
|
144
|
+
suggestions: fixSuggestions ? suggestions : undefined,
|
|
145
|
+
blocks_found: blocksFound,
|
|
146
|
+
summary: `${errors.length} error${errors.length !== 1 ? 's' : ''}, ${warnings.length} warning${warnings.length !== 1 ? 's' : ''} found in ${totalBlocks} block${totalBlocks !== 1 ? 's' : ''}`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/tools/comments.js — engagement tools (2)
|
|
2
|
+
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
|
+
|
|
4
|
+
import { json, strip, buildPaginationMeta } from '../shared/utils.js';
|
|
5
|
+
import { validateInput } from '../shared/governance.js';
|
|
6
|
+
import { rt } from '../shared/context.js';
|
|
7
|
+
|
|
8
|
+
export const definitions = [
|
|
9
|
+
{ name: 'wp_list_comments', _category: 'engagement', description: 'Use to list comments filtered by post or status (approved/hold/spam). Read-only. Hint: use mode=\'summary\' for listings, \'ids_only\' for batch ops.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, post: { type: 'number' }, status: { type: 'string' }, orderby: { type: 'string', default: 'date_gmt' }, order: { type: 'string', default: 'desc' }, search: { type: 'string' }, mode: { type: 'string', enum: ['full', 'summary', 'ids_only'], default: 'full', description: 'full=all fields, summary=key fields only, ids_only=flat ID array' } }}},
|
|
10
|
+
{ name: 'wp_create_comment', _category: 'engagement', description: 'Use to post a comment or reply on any post. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { post: { type: 'number' }, content: { type: 'string' }, parent: { type: 'number', default: 0 }, author_name: { type: 'string' }, author_email: { type: 'string' }, status: { type: 'string', default: 'approved' } }, required: ['post', 'content'] }}
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export const handlers = {};
|
|
14
|
+
|
|
15
|
+
handlers['wp_list_comments'] = async (args) => {
|
|
16
|
+
const t0 = Date.now();
|
|
17
|
+
let result;
|
|
18
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
19
|
+
const { per_page = 10, page = 1, post, status, orderby = 'date_gmt', order = 'desc', search, mode = 'full' } = args;
|
|
20
|
+
let ep = `/comments?per_page=${per_page}&page=${page}&orderby=${orderby}&order=${order}`;
|
|
21
|
+
if (post) ep += `&post=${post}`; if (status) ep += `&status=${status}`; if (search) ep += `&search=${encodeURIComponent(search)}`;
|
|
22
|
+
const comments = await wpApiCall(ep);
|
|
23
|
+
const cmtPg = comments._wpTotal !== undefined ? buildPaginationMeta(comments._wpTotal, page, per_page) : undefined;
|
|
24
|
+
if (mode === 'ids_only') {
|
|
25
|
+
result = json({ total: comments.length, page, mode: 'ids_only', ids: comments.map(c => c.id), ...(cmtPg && { pagination: cmtPg }) });
|
|
26
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
if (mode === 'summary') {
|
|
30
|
+
result = json({ total: comments.length, page, mode: 'summary', comments: comments.map(c => ({ id: c.id, post: c.post, author_name: c.author_name, date: c.date, status: c.status })), ...(cmtPg && { pagination: cmtPg }) });
|
|
31
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
result = json({ total: comments.length, page, comments: comments.map(c => ({ id: c.id, post: c.post, parent: c.parent, author_name: c.author_name, date: c.date, status: c.status, content: strip(c.content.rendered).substring(0, 300), link: c.link })), ...(cmtPg && { pagination: cmtPg }) });
|
|
35
|
+
auditLog({ tool: name, action: 'list', status: 'success', latency_ms: Date.now() - t0 });
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
handlers['wp_create_comment'] = async (args) => {
|
|
39
|
+
const t0 = Date.now();
|
|
40
|
+
let result;
|
|
41
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
42
|
+
validateInput(args, { post: { type: 'number', required: true }, content: { type: 'string', required: true } });
|
|
43
|
+
const { post, content, parent = 0, author_name, author_email, status = 'approved' } = args;
|
|
44
|
+
const data = { post, content, parent, status };
|
|
45
|
+
if (author_name) data.author_name = author_name; if (author_email) data.author_email = author_email;
|
|
46
|
+
const c = await wpApiCall('/comments', { method: 'POST', body: JSON.stringify(data) });
|
|
47
|
+
result = json({ success: true, message: 'Comment created', comment: { id: c.id, post: c.post, status: c.status, content: strip(c.content.rendered).substring(0, 200) } });
|
|
48
|
+
auditLog({ tool: name, target: c.id, target_type: 'comment', action: 'create', status: 'success', latency_ms: Date.now() - t0, params: { post } });
|
|
49
|
+
return result;
|
|
50
|
+
};
|