Changeset 3784837
- Timestamp:
- 11/04/2026 09:39:12 PM (6 hours ago)
- Location:
- clautron
- Files:
-
- 20 added
- 70 edited
- 1 copied
-
tags/1.5.0 (copied) (copied from clautron/trunk )
-
tags/1.5.0/clautron.php (modified) (4 diffs)
-
tags/1.5.0/demo/blueprint-cookie-consent.json (modified) (1 diff)
-
tags/1.5.0/demo/blueprint-security-hardening.json (added)
-
tags/1.5.0/demo/capabilities (added)
-
tags/1.5.0/demo/capabilities/content-completeness.manifest.json (added)
-
tags/1.5.0/demo/capabilities/duplicate-post.manifest.json (added)
-
tags/1.5.0/demo/capabilities/seo.manifest.json (added)
-
tags/1.5.0/docs/schemas/capability_manifest.json (added)
-
tags/1.5.0/includes/class-abilities.php (added)
-
tags/1.5.0/includes/class-capability-store.php (added)
-
tags/1.5.0/includes/class-catalog.php (modified) (12 diffs)
-
tags/1.5.0/includes/class-db.php (modified) (5 diffs)
-
tags/1.5.0/includes/class-plugin.php (modified) (2 diffs)
-
tags/1.5.0/includes/class-smoke-tester.php (modified) (6 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-ai.php (modified) (1 diff)
-
tags/1.5.0/includes/primitives/class-primitive-comments.php (modified) (3 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-consent.php (added)
-
tags/1.5.0/includes/primitives/class-primitive-cookie.php (modified) (2 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-data.php (modified) (9 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-elementor.php (modified) (2 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-email.php (modified) (3 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-events.php (modified) (6 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-filter.php (modified) (1 diff)
-
tags/1.5.0/includes/primitives/class-primitive-frontend.php (modified) (6 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-http.php (modified) (3 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-language.php (modified) (1 diff)
-
tags/1.5.0/includes/primitives/class-primitive-logic.php (modified) (5 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-media.php (modified) (5 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-query.php (modified) (6 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-redirect.php (modified) (1 diff)
-
tags/1.5.0/includes/primitives/class-primitive-request.php (modified) (1 diff)
-
tags/1.5.0/includes/primitives/class-primitive-security.php (modified) (3 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-sitemap.php (modified) (1 diff)
-
tags/1.5.0/includes/primitives/class-primitive-taxonomy.php (modified) (4 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-transform.php (modified) (20 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-ui.php (modified) (11 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-user.php (modified) (7 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-webhook.php (modified) (1 diff)
-
tags/1.5.0/includes/primitives/class-primitive-woocommerce.php (modified) (3 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-wordpress.php (modified) (17 diffs)
-
tags/1.5.0/includes/primitives/class-primitive-workflow.php (modified) (4 diffs)
-
tags/1.5.0/includes/registry/class-manifest-registry.php (added)
-
tags/1.5.0/includes/registry/class-primitive-registry.php (modified) (4 diffs)
-
tags/1.5.0/readme.txt (modified) (6 diffs)
-
tags/1.5.0/uninstall.php (modified) (1 diff)
-
trunk/clautron.php (modified) (4 diffs)
-
trunk/demo/blueprint-cookie-consent.json (modified) (1 diff)
-
trunk/demo/blueprint-security-hardening.json (added)
-
trunk/demo/capabilities (added)
-
trunk/demo/capabilities/content-completeness.manifest.json (added)
-
trunk/demo/capabilities/duplicate-post.manifest.json (added)
-
trunk/demo/capabilities/seo.manifest.json (added)
-
trunk/docs/schemas/capability_manifest.json (added)
-
trunk/includes/class-abilities.php (added)
-
trunk/includes/class-capability-store.php (added)
-
trunk/includes/class-catalog.php (modified) (12 diffs)
-
trunk/includes/class-db.php (modified) (5 diffs)
-
trunk/includes/class-plugin.php (modified) (2 diffs)
-
trunk/includes/class-smoke-tester.php (modified) (6 diffs)
-
trunk/includes/primitives/class-primitive-ai.php (modified) (1 diff)
-
trunk/includes/primitives/class-primitive-comments.php (modified) (3 diffs)
-
trunk/includes/primitives/class-primitive-consent.php (added)
-
trunk/includes/primitives/class-primitive-cookie.php (modified) (2 diffs)
-
trunk/includes/primitives/class-primitive-data.php (modified) (9 diffs)
-
trunk/includes/primitives/class-primitive-elementor.php (modified) (2 diffs)
-
trunk/includes/primitives/class-primitive-email.php (modified) (3 diffs)
-
trunk/includes/primitives/class-primitive-events.php (modified) (6 diffs)
-
trunk/includes/primitives/class-primitive-filter.php (modified) (1 diff)
-
trunk/includes/primitives/class-primitive-frontend.php (modified) (6 diffs)
-
trunk/includes/primitives/class-primitive-http.php (modified) (3 diffs)
-
trunk/includes/primitives/class-primitive-language.php (modified) (1 diff)
-
trunk/includes/primitives/class-primitive-logic.php (modified) (5 diffs)
-
trunk/includes/primitives/class-primitive-media.php (modified) (5 diffs)
-
trunk/includes/primitives/class-primitive-query.php (modified) (6 diffs)
-
trunk/includes/primitives/class-primitive-redirect.php (modified) (1 diff)
-
trunk/includes/primitives/class-primitive-request.php (modified) (1 diff)
-
trunk/includes/primitives/class-primitive-security.php (modified) (3 diffs)
-
trunk/includes/primitives/class-primitive-sitemap.php (modified) (1 diff)
-
trunk/includes/primitives/class-primitive-taxonomy.php (modified) (4 diffs)
-
trunk/includes/primitives/class-primitive-transform.php (modified) (20 diffs)
-
trunk/includes/primitives/class-primitive-ui.php (modified) (11 diffs)
-
trunk/includes/primitives/class-primitive-user.php (modified) (7 diffs)
-
trunk/includes/primitives/class-primitive-webhook.php (modified) (1 diff)
-
trunk/includes/primitives/class-primitive-woocommerce.php (modified) (3 diffs)
-
trunk/includes/primitives/class-primitive-wordpress.php (modified) (17 diffs)
-
trunk/includes/primitives/class-primitive-workflow.php (modified) (4 diffs)
-
trunk/includes/registry/class-manifest-registry.php (added)
-
trunk/includes/registry/class-primitive-registry.php (modified) (4 diffs)
-
trunk/readme.txt (modified) (6 diffs)
-
trunk/uninstall.php (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
clautron/tags/1.5.0/clautron.php
r3691450 r3784837 4 4 * Plugin URI: https://wordpress.org/plugins/clautron/ 5 5 * Description: Get the feature. Not the plugin. Add exactly the WordPress capability you need: inspectable, secure, reversible. 6 * Version: 1. 2.06 * Version: 1.5.0 7 7 * Author: Clautron 8 8 * Author URI: https://profiles.wordpress.org/dudaster/ … … 20 20 // ─── Constants ─────────────────────────────────────────────────────────────── 21 21 22 define( 'CLAUTRON_VERSION', '1. 2.0' );22 define( 'CLAUTRON_VERSION', '1.5.0' ); 23 23 define( 'CLAUTRON_FILE', __FILE__ ); 24 24 define( 'CLAUTRON_DIR', plugin_dir_path( __FILE__ ) ); 25 25 define( 'CLAUTRON_URL', plugin_dir_url( __FILE__ ) ); 26 define( 'CLAUTRON_DB_VERSION', ' 4' );26 define( 'CLAUTRON_DB_VERSION', '5' ); 27 27 28 28 // ─── Autoloader ────────────────────────────────────────────────────────────── … … 121 121 'primitive-cookie' => 'includes/primitives/class-primitive-cookie.php', 122 122 123 // Consent (cookie-consent banner + category-gated script output) 124 'primitive-consent' => 'includes/primitives/class-primitive-consent.php', 125 123 126 // Language (multilingual query filtering) 124 127 'primitive-language' => 'includes/primitives/class-primitive-language.php', … … 157 160 'catalog' => 'includes/class-catalog.php', 158 161 162 // Capability Manifest (machine-readable contract: version, requirements, scopes, effects) 163 'manifest-registry' => 'includes/registry/class-manifest-registry.php', 164 'capability-store' => 'includes/class-capability-store.php', 165 159 166 // Smoke Tester (verify compiled blueprints are wired correctly) 160 167 'smoke-tester' => 'includes/class-smoke-tester.php', 168 169 // Abilities (every operation above as a self-describing WordPress Ability) 170 'abilities' => 'includes/class-abilities.php', 161 171 162 172 // Admin -
clautron/tags/1.5.0/demo/blueprint-cookie-consent.json
r3691441 r3784837 2 2 "id": "f2a3b4c5-d6e7-4890-b123-c45678901bcd", 3 3 "object_type": "feature", 4 "name": " CookieConsent Banner",5 "description": " Shows a GDPR-style cookie consent banner in the footer with an Accept link. Clicking it stores consent in a first-party cookie for 365 days, which also suppresses the banner on return visits. Replaces a basic cookie-notice plugin.",4 "name": "GDPR Consent Banner", 5 "description": "Cookie-consent banner with per-category consent (necessary/analytics/marketing), a full audit trail of who consented to what and when, automatic opt-out when the visitor's browser sends Global Privacy Control (Sec-GPC), and Google Consent Mode v2 signals for GA4/Ads. Replaces a dedicated cookie-consent plugin for the common case.", 6 6 "capability_id": "cookie-consent-banner", 7 7 "provides": {}, 8 8 "status": "draft", 9 9 "source": "manual", 10 "nl_input": "Show a cookie consent banner on the site with an accept button that remembers the choice for a year.",10 "nl_input": "Show a GDPR cookie consent banner with analytics/marketing categories, honor Global Privacy Control automatically, and signal Google Consent Mode.", 11 11 "structures": [ 12 12 { 13 13 "id": "s-banner", 14 "type": " inject_wp_footer",14 "type": "consent_banner", 15 15 "config": { 16 "template": "<div id=\"clautron-cookie-banner\" style=\"position:fixed;bottom:0;left:0;right:0;background:#222;color:#fff;padding:14px 20px;font-size:14px;z-index:9999;\">This site uses cookies to improve your experience. <a href=\"?clautron_action=accept_cookies\" style=\"color:#8ecdf7;\">Accept</a></div>", 17 "suppress_if_cookie": "cookie_consent" 16 "categories": [ 17 { "key": "necessary", "label": "Necessary", "description": "Required for the site to function.", "required": true }, 18 { "key": "analytics", "label": "Analytics", "description": "Helps us understand site usage." }, 19 { "key": "marketing", "label": "Marketing", "description": "Used for ad personalization." } 20 ], 21 "policy_version": "1", 22 "banner_text": "This site uses cookies. Choose which categories you're OK with.", 23 "position": "bottom", 24 "days": 180, 25 "endpoint_path": "consent", 26 "honor_gpc": true, 27 "consent_mode_map": { 28 "analytics": ["analytics_storage"], 29 "marketing": ["ad_storage", "ad_user_data", "ad_personalization"] 30 } 31 } 32 }, 33 { 34 "id": "s-analytics-script", 35 "type": "gate_script_by_consent", 36 "config": { 37 "category": "analytics", 38 "html": "<!-- Load your analytics script here — it only outputs once \"analytics\" consent is granted -->", 39 "placement": "head" 18 40 } 19 41 } 20 ],21 "trigger": { "type": "hook", "config": { "hook": "template_redirect" } },22 "conditions": [23 { "id": "c1", "type": "read_request_param", "config": { "source": "get", "param": "clautron_action", "output_variable": "action", "default": "" } },24 { "id": "c2", "type": "if_else", "config": { "field": "{{action}}", "operator": "eq", "value": "accept_cookies", "pass_branch": ["a1"], "fail_branch": [] } }25 ],26 "actions": [27 { "id": "a1", "type": "write_cookie", "config": { "name": "cookie_consent", "value": "accepted", "days": 365 } }28 42 ] 29 43 } -
clautron/tags/1.5.0/includes/class-catalog.php
r3582869 r3784837 9 9 * - Active capabilities (compiled + running on this site). 10 10 * - Available templates (demo blueprints not yet installed). 11 * - inspect(): install/version/compatibility state for one capability_id. 12 * 13 * When a capability_id has a manifest (demo/capabilities/*.manifest.json, 14 * loaded via CLAUTRON_Manifest_Registry), catalog entries carry it under 15 * 'manifest' plus a 'has_manifest' flag — additive, so capabilities without 16 * a manifest keep working exactly as before. 11 17 * 12 18 * External systems (e.g. AICOM) call these methods directly — no HTTP needed. … … 33 39 continue; 34 40 } 41 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null; 42 35 43 $active[] = [ 36 44 'capability_id' => $data['capability_id'], … … 42 50 'provides' => $data['provides'] ?? [], 43 51 'llm_guide' => $data['llm_guide'] ?? '', 52 'manifest' => $manifest, 53 'has_manifest' => $manifest !== null, 44 54 ]; 45 55 } … … 74 84 continue; 75 85 } 86 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null; 87 76 88 $templates[] = [ 77 89 'capability_id' => $data['capability_id'], … … 83 95 'provides' => $data['provides'] ?? [], 84 96 'llm_guide' => $data['llm_guide'] ?? '', 97 'manifest' => $manifest, 98 'has_manifest' => $manifest !== null, 85 99 ]; 86 100 } … … 127 141 } 128 142 129 // Find template file. 130 $demo_dir = CLAUTRON_DIR . 'demo/'; 131 $template = null; 132 foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) { 133 $data = json_decode( file_get_contents( $path ), true ); 134 if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) { 135 $template = $data; 136 break; 137 } 138 } 143 // Known but not active (e.g. deactivated) — reactivate the SAME 144 // blueprint instead of creating a new one, so version history and 145 // the blueprint's own identity survive a deactivate → install cycle. 146 $known = class_exists( 'CLAUTRON_Capability_Store' ) 147 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 148 : null; 149 if ( $known ) { 150 $existing_blueprint_id = (int) $known->blueprint_id; 151 $existing_row = CLAUTRON_Blueprint_Store::get( $existing_blueprint_id ); 152 if ( $existing_row ) { 153 $result = CLAUTRON_Plugin::make_compiler()->compile( $existing_blueprint_id ); 154 if ( ! empty( $result->get_errors() ) ) { 155 return [ 156 'success' => false, 157 'blueprint_id' => $existing_blueprint_id, 158 'compiled' => false, 159 'capability_id' => $capability_id, 160 'error' => implode( '; ', $result->get_errors() ), 161 ]; 162 } 163 self::record_installed_instance( $capability_id, $existing_blueprint_id ); 164 return [ 165 'success' => true, 166 'blueprint_id' => $existing_blueprint_id, 167 'compiled' => true, 168 'capability_id' => $capability_id, 169 'note' => 'Reactivated existing blueprint.', 170 'warnings' => $result->get_warnings(), 171 ]; 172 } 173 // Fall through to a fresh template install if the known 174 // blueprint row itself is gone (shouldn't normally happen). 175 } 176 177 $template = self::find_template_data( $capability_id ); 139 178 140 179 if ( ! $template ) { … … 178 217 } 179 218 219 self::record_installed_instance( $capability_id, $blueprint_id ); 220 180 221 return [ 181 222 'success' => true, … … 187 228 } 188 229 230 /** 231 * Update an already-installed capability to its manifest's current 232 * declared version, in place — same blueprint row, new blueprint_json, 233 * recompiled. Distinct from install(): requires the capability to 234 * already be installed, and requires a manifest (the target version 235 * always comes from the manifest's own 'version' field — there's no 236 * store of multiple historical template versions to pick from). 237 * 238 * @return array { 239 * @type bool $success 240 * @type string $status 'already_current' | 'updated' (only on success) 241 * @type int|null $blueprint_id 242 * @type string|null $from_version 243 * @type string|null $to_version 244 * @type string $error (only on failure) 245 * } 246 */ 247 public static function update( string $capability_id ): array { 248 $installed = class_exists( 'CLAUTRON_Capability_Store' ) 249 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 250 : null; 251 252 if ( ! $installed ) { 253 return [ 254 'success' => false, 255 'error' => "Capability '{$capability_id}' is not installed. Use clautron.catalog.install first.", 256 'code' => 'not_installed', 257 ]; 258 } 259 260 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 261 if ( ! $manifest ) { 262 return [ 263 'success' => false, 264 'error' => "Capability '{$capability_id}' has no manifest, so it has no declared target version to update to.", 265 'code' => 'no_manifest', 266 ]; 267 } 268 269 $from_version = $installed->installed_version; 270 $to_version = $manifest['version']; 271 272 if ( $from_version === $to_version ) { 273 return [ 274 'success' => true, 275 'status' => 'already_current', 276 'blueprint_id' => (int) $installed->blueprint_id, 277 'from_version' => $from_version, 278 'to_version' => $to_version, 279 ]; 280 } 281 282 $template = self::find_template_data( $capability_id ); 283 if ( ! $template ) { 284 return [ 285 'success' => false, 286 'error' => "No template found for capability_id '{$capability_id}'.", 287 'code' => 'template_missing', 288 ]; 289 } 290 291 $blueprint_id = (int) $installed->blueprint_id; 292 $blueprint_row = CLAUTRON_Blueprint_Store::get( $blueprint_id ); 293 if ( ! $blueprint_row ) { 294 return [ 295 'success' => false, 296 'error' => "Installed blueprint (id {$blueprint_id}) no longer exists.", 297 'code' => 'blueprint_missing', 298 ]; 299 } 300 301 // In-place edit: keep the existing blueprint's own UUID — this 302 // updates the same blueprint row, it doesn't create a new one. 303 $template['id'] = $blueprint_row->uuid; 304 305 $validation_blueprint = new CLAUTRON_Blueprint( $template ); 306 $validation = CLAUTRON_Blueprint_Validator::validate( $validation_blueprint ); 307 if ( ! $validation->is_valid() ) { 308 return [ 309 'success' => false, 310 'error' => 'Updated template failed validation: ' . implode( '; ', $validation->get_errors() ), 311 'code' => 'validation_failed', 312 ]; 313 } 314 315 $encoded = wp_json_encode( $template ); 316 $saved = CLAUTRON_Blueprint_Store::update_json( $blueprint_id, $encoded, "Updated to v{$to_version} via Capability Contract" ); 317 if ( ! $saved ) { 318 return [ 319 'success' => false, 320 'error' => 'Failed to save updated blueprint to database.', 321 'code' => 'save_failed', 322 ]; 323 } 324 325 $result = CLAUTRON_Plugin::make_compiler()->compile( $blueprint_id ); 326 if ( ! empty( $result->get_errors() ) ) { 327 return [ 328 'success' => false, 329 'blueprint_id' => $blueprint_id, 330 'from_version' => $from_version, 331 'to_version' => $to_version, 332 'error' => implode( '; ', $result->get_errors() ), 333 'code' => 'compile_failed', 334 ]; 335 } 336 337 self::record_installed_instance( $capability_id, $blueprint_id ); 338 339 return [ 340 'success' => true, 341 'status' => 'updated', 342 'blueprint_id' => $blueprint_id, 343 'from_version' => $from_version, 344 'to_version' => $to_version, 345 'warnings' => $result->get_warnings(), 346 ]; 347 } 348 349 /** 350 * Deactivate an installed capability — stops its compile-phase 351 * primitives from being re-applied on the next request 352 * (CLAUTRON_Runtime_Engine::boot() only replays workflows with 353 * status='active'; CLAUTRON_Compiler::decompile() removes the 354 * workflow row and pauses the blueprint). Never touches data: no 355 * postmeta, terms, or custom table rows written while the capability 356 * was active are read or modified. Always allowed, regardless of 357 * reversibility.strategy — that field describes what a future 358 * `rollback` action would (not) be able to undo, it does not gate 359 * deactivate. 360 * 361 * @return array { 362 * @type bool $success 363 * @type string $status 'deactivated' (only on success) 364 * @type int|null $blueprint_id 365 * @type bool $data_preserved Always true on success. 366 * @type string[] $residual_effects Human-readable notes derived from the manifest's 'effects', if any. 367 * @type string $error (only on failure) 368 * } 369 */ 370 public static function deactivate( string $capability_id ): array { 371 $installed = class_exists( 'CLAUTRON_Capability_Store' ) 372 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 373 : null; 374 375 if ( ! $installed || $installed->status !== 'active' ) { 376 return [ 377 'success' => false, 378 'error' => "Capability '{$capability_id}' is not currently active.", 379 'code' => 'not_active', 380 ]; 381 } 382 383 $blueprint_id = (int) $installed->blueprint_id; 384 385 CLAUTRON_Plugin::make_compiler()->decompile( $blueprint_id ); 386 387 // Covers decompile()'s one confirmed gap: it doesn't flush rewrite 388 // rules, so a register_cpt/register_taxonomy with a custom slug 389 // can leave stale permalinks until the next flush. Cheap enough 390 // for a rare, explicit operation. 391 flush_rewrite_rules( false ); 392 393 CLAUTRON_Capability_Store::update_status( $capability_id, 'paused' ); 394 395 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 396 $residual_effects = self::describe_residual_effects( $manifest['effects'] ?? [] ); 397 398 return [ 399 'success' => true, 400 'status' => 'deactivated', 401 'blueprint_id' => $blueprint_id, 402 'data_preserved' => true, 403 'residual_effects' => $residual_effects, 404 ]; 405 } 406 407 /** 408 * Translate a manifest's declared 'effects' into human-readable notes 409 * about what deactivate does NOT undo — derived from data already 410 * declared on the manifest, no new metadata needed. 411 */ 412 private static function describe_residual_effects( array $effects ): array { 413 $notes = []; 414 if ( ! empty( $effects['writes_database'] ) ) { 415 $notes[] = 'Data written while active (e.g. post meta) is preserved and not reverted.'; 416 } 417 if ( ! empty( $effects['changes_schema'] ) ) { 418 $notes[] = 'Any schema changes made while active (e.g. new fields/columns) are preserved.'; 419 } 420 if ( ! empty( $effects['writes_files'] ) ) { 421 $notes[] = 'Files written while active are preserved on disk.'; 422 } 423 return $notes; 424 } 425 426 /** 427 * Locate a demo template's decoded JSON by capability_id, or null. 428 */ 429 private static function find_template_data( string $capability_id ): ?array { 430 $demo_dir = CLAUTRON_DIR . 'demo/'; 431 foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) { 432 $data = json_decode( file_get_contents( $path ), true ); 433 if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) { 434 return $data; 435 } 436 } 437 return null; 438 } 439 440 /** 441 * Upsert the installed-instance row (wp_clautron_capabilities) after a 442 * successful install/compile — separate from the generic blueprint row, 443 * see CLAUTRON_Capability_Store. 444 */ 445 private static function record_installed_instance( string $capability_id, int $blueprint_id ): void { 446 if ( ! class_exists( 'CLAUTRON_Capability_Store' ) ) { 447 return; 448 } 449 450 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 451 452 CLAUTRON_Capability_Store::upsert( [ 453 'capability_id' => $capability_id, 454 'blueprint_id' => $blueprint_id, 455 'installed_version' => $manifest['version'] ?? 'unknown', 456 'status' => 'active', 457 'reversibility' => $manifest['reversibility']['strategy'] ?? 'none', 458 ] ); 459 } 460 189 461 // ── Idempotent provisioning ─────────────────────────────────────────────── 190 462 191 463 /** 192 * Ensure a capability exists and is active. Idempotent — safe to call repeatedly. 464 * What ensure() would do for a capability_id, without doing it. ensure() 465 * itself follows this, so a dry run can never disagree with the real call. 193 466 * 194 467 * Resolution order: 195 * 1. Already active with this capability_id → return it unchanged. 196 * 2. Available as a demo template → install it. 197 * 3. blueprint_json provided → create + compile (capability_id is forced into the JSON). 468 * 1. already_active — active with this capability_id: nothing to do. 469 * 2. reactivate — known but deactivated, blueprint still there: 470 * install() recompiles the SAME blueprint. Without 471 * this a custom (non-template) capability would get 472 * a second blueprint every time it was ensured 473 * after a deactivate. 474 * 3. install_template — a demo template: install it. 475 * 4. create — create + compile the caller's blueprint_json. 476 * 477 * @return array{action: string, blueprint_id: ?int} 478 */ 479 public static function ensure_plan( string $capability_id ): array { 480 $cap = self::find_active( $capability_id ); 481 if ( $cap ) { 482 return [ 'action' => 'already_active', 'blueprint_id' => (int) $cap['blueprint_id'] ]; 483 } 484 485 $known = CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ); 486 if ( $known && CLAUTRON_Blueprint_Store::get( (int) $known->blueprint_id ) ) { 487 return [ 'action' => 'reactivate', 'blueprint_id' => (int) $known->blueprint_id ]; 488 } 489 490 if ( in_array( $capability_id, array_column( self::get_templates(), 'capability_id' ), true ) ) { 491 return [ 'action' => 'install_template', 'blueprint_id' => null ]; 492 } 493 494 return [ 'action' => 'create', 'blueprint_id' => null ]; 495 } 496 497 /** 498 * Ensure a capability exists and is active. Idempotent — safe to call 499 * repeatedly. Resolution order: see ensure_plan(). 198 500 * 199 501 * @param string $capability_id Unique slug identifying this capability. … … 201 503 * @return array { 202 504 * @type bool $success 203 * @type string $status 'already_active' | ' installed_from_template' | 'created_and_compiled'505 * @type string $status 'already_active' | 'reactivated' | 'installed_from_template' | 'created_and_compiled' 204 506 * @type string $capability_id 205 507 * @type int $blueprint_id … … 208 510 */ 209 511 public static function ensure( string $capability_id, string $blueprint_json = '' ): array { 210 // ── 1. Already active ─────────────────────────────────────────────────211 $cap = self::find_active( $capability_id ); 212 if ( $cap) {512 $plan = self::ensure_plan( $capability_id ); 513 514 if ( 'already_active' === $plan['action'] ) { 213 515 return [ 214 516 'success' => true, 215 517 'status' => 'already_active', 216 518 'capability_id' => $capability_id, 217 'blueprint_id' => $cap['blueprint_id'], 218 ]; 219 } 220 221 // ── 2. Available as template ────────────────────────────────────────── 222 $templates = self::get_templates(); 223 foreach ( $templates as $tpl ) { 224 if ( $tpl['capability_id'] === $capability_id ) { 225 $result = self::install( $capability_id ); 226 $result['status'] = $result['success'] ? 'installed_from_template' : ( $result['status'] ?? 'error' ); 227 return $result; 228 } 229 } 230 231 // ── 3. Create from provided blueprint_json ──────────────────────────── 519 'blueprint_id' => $plan['blueprint_id'], 520 ]; 521 } 522 523 if ( 'reactivate' === $plan['action'] || 'install_template' === $plan['action'] ) { 524 $result = self::install( $capability_id ); 525 $result['status'] = $result['success'] 526 ? ( 'reactivate' === $plan['action'] ? 'reactivated' : 'installed_from_template' ) 527 : ( $result['status'] ?? 'error' ); 528 return $result; 529 } 530 531 // ── create: from the provided blueprint_json ────────────────────────── 232 532 if ( ! $blueprint_json ) { 233 533 return [ … … 284 584 } 285 585 586 self::record_installed_instance( $capability_id, $blueprint_id ); 587 286 588 return [ 287 589 'success' => true, … … 366 668 } 367 669 670 // ── Inspect ─────────────────────────────────────────────────────────────── 671 672 /** 673 * Contract-level actions that always work for any capability_id, 674 * manifest or not — they're generic lookups, never gated by what a 675 * specific manifest opts into declaring. 676 */ 677 private const ALWAYS_AVAILABLE_ACTIONS = [ 'discover', 'inspect' ]; 678 679 /** 680 * Per-capability lifecycle actions the Capability Contract currently 681 * has a real, working executor for. A manifest may *declare* more 682 * (e.g. "update", "verify", "remove") than the contract can actually 683 * run yet — available_actions only ever advertises the intersection, 684 * so a caller never gets pointed at an action that would just fail. 685 */ 686 private const IMPLEMENTED_LIFECYCLE_ACTIONS = [ 'install', 'update', 'deactivate' ]; 687 688 /** 689 * Resolve current install/version/compatibility state for a capability_id. 690 * 691 * @return array { 692 * @type bool $installed 693 * @type string|null $installed_version 694 * @type string|null $available_version From the manifest, if one exists. 695 * @type bool $compatible True if no unmet requirements (or no manifest to check). 696 * @type string $status not_installed|active|paused|removed 697 * @type string[] $available_actions ALWAYS_AVAILABLE_ACTIONS + (manifest actions ∩ IMPLEMENTED_LIFECYCLE_ACTIONS). 698 * @type string[] $unmet_requirements 699 * } 700 */ 701 public static function inspect( string $capability_id ): array { 702 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 703 704 $installed_row = class_exists( 'CLAUTRON_Capability_Store' ) 705 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 706 : null; 707 708 $unmet = ( $manifest && class_exists( 'CLAUTRON_Manifest_Registry' ) ) 709 ? CLAUTRON_Manifest_Registry::check_requirements( $manifest ) 710 : []; 711 712 $declared_actions = $manifest['actions'] ?? []; 713 $available_actions = array_values( array_unique( array_merge( 714 self::ALWAYS_AVAILABLE_ACTIONS, 715 array_intersect( $declared_actions, self::IMPLEMENTED_LIFECYCLE_ACTIONS ) 716 ) ) ); 717 718 return [ 719 'installed' => $installed_row !== null, 720 'installed_version' => $installed_row->installed_version ?? null, 721 'available_version' => $manifest['version'] ?? null, 722 'compatible' => empty( $unmet ), 723 'status' => $installed_row->status ?? 'not_installed', 724 'available_actions' => $available_actions, 725 'unmet_requirements' => $unmet, 726 ]; 727 } 728 368 729 // ── Helpers ─────────────────────────────────────────────────────────────── 369 730 -
clautron/tags/1.5.0/includes/class-db.php
r3582869 r3784837 14 14 * 3 — add last_error column to jobs table 15 15 * 4 — add clautron_events time-series table 16 * 5 — add clautron_capabilities table (installed-instance state, separate 17 * from the source/template manifest — see docs/schemas/capability_manifest.json) 16 18 */ 17 19 class CLAUTRON_DB { … … 193 195 ) $charset;" ); 194 196 197 // ── capabilities ────────────────────────────────────────────────────── 198 // Installed-instance state for a capability, separate from the 199 // source/template manifest (demo/capabilities/*.manifest.json) and 200 // from the generic blueprints table, which serves every blueprint 201 // whether or not it's a governed capability. One row per capability_id 202 // currently installed on this site. 203 dbDelta( "CREATE TABLE {$pfx}capabilities ( 204 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 205 capability_id VARCHAR(191) NOT NULL, 206 blueprint_id BIGINT UNSIGNED NOT NULL, 207 installed_version VARCHAR(20) NOT NULL DEFAULT 'unknown', 208 status VARCHAR(20) NOT NULL DEFAULT 'active', 209 reversibility VARCHAR(10) NOT NULL DEFAULT 'none', 210 installed_at DATETIME NOT NULL, 211 updated_at DATETIME NOT NULL, 212 PRIMARY KEY (id), 213 UNIQUE KEY capability_id (capability_id), 214 KEY blueprint_id (blueprint_id) 215 ) $charset;" ); 216 195 217 // ── In-place migrations for existing installs ───────────────────────── 196 218 // dbDelta reliably creates tables but does NOT add missing columns. … … 203 225 } 204 226 227 // v5: backfill capabilities table for blueprints that were installed 228 // (compiled + active, with a capability_id) before this table existed. 229 // Preserves continuity — nothing about an already-installed capability 230 // changes on upgrade, it just becomes visible to the new inspect()/ 231 // versioning machinery. 232 self::backfill_capabilities(); 233 205 234 update_option( 'clautron_db_version', CLAUTRON_DB_VERSION ); 235 } 236 237 /** 238 * One-time backfill: for every active blueprint that already declares a 239 * capability_id but has no row yet in the capabilities table, insert one. 240 * Safe to call repeatedly — skips capability_ids that already have a row. 241 */ 242 private static function backfill_capabilities(): void { 243 global $wpdb; 244 245 $existing_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT capability_id FROM %i', self::capabilities() ) ); 246 247 $rows = $wpdb->get_results( $wpdb->prepare( 248 'SELECT id, blueprint_json FROM %i WHERE status = %s', 249 self::blueprints(), 250 'active' 251 ) ); 252 253 $now = current_time( 'mysql', true ); 254 255 foreach ( $rows as $row ) { 256 $data = json_decode( $row->blueprint_json, true ); 257 if ( ! is_array( $data ) || empty( $data['capability_id'] ) ) { 258 continue; 259 } 260 if ( in_array( $data['capability_id'], $existing_ids, true ) ) { 261 continue; 262 } 263 264 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null; 265 $installed_version = $manifest['version'] ?? 'unknown'; 266 $reversibility = $manifest['reversibility']['strategy'] ?? 'none'; 267 268 $wpdb->insert( 269 self::capabilities(), 270 [ 271 'capability_id' => $data['capability_id'], 272 'blueprint_id' => (int) $row->id, 273 'installed_version' => $installed_version, 274 'status' => 'active', 275 'reversibility' => $reversibility, 276 'installed_at' => $now, 277 'updated_at' => $now, 278 ], 279 [ '%s', '%d', '%s', '%s', '%s', '%s', '%s' ] 280 ); 281 } 206 282 } 207 283 … … 212 288 global $wpdb; 213 289 $pfx = $wpdb->prefix . 'clautron_'; 214 $tables = [ ' events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ];290 $tables = [ 'capabilities', 'events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ]; 215 291 foreach ( $tables as $t ) { 216 292 $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $pfx . $t ) ); … … 229 305 public static function jobs(): string { global $wpdb; return $wpdb->prefix . 'clautron_jobs'; } 230 306 public static function events(): string { global $wpdb; return $wpdb->prefix . 'clautron_events'; } 307 public static function capabilities(): string { global $wpdb; return $wpdb->prefix . 'clautron_capabilities'; } 231 308 } -
clautron/tags/1.5.0/includes/class-plugin.php
r3691441 r3784837 42 42 CLAUTRON_Primitive_Redirect::register_all(); 43 43 CLAUTRON_Primitive_Cookie::register_all(); 44 CLAUTRON_Primitive_Consent::register_all(); 44 45 CLAUTRON_Primitive_Language::register_all(); 45 46 CLAUTRON_Primitive_Request::register_all(); … … 59 60 // ── 3. Boot job runner ─────────────────────────────────────────────── 60 61 CLAUTRON_Job_Runner::boot(); 62 63 // ── 3b. Expose operations as WordPress Abilities ───────────────────── 64 CLAUTRON_Abilities::boot(); 61 65 62 66 // ── 4b. Boot runtime engine ────────────────────────────────────────── -
clautron/tags/1.5.0/includes/class-smoke-tester.php
r3582869 r3784837 5 5 * Smoke Tester — verifies that a compiled blueprint is correctly wired into WordPress. 6 6 * 7 * Checks are lightweight WordPress-introspection only (no HTTP requests). 8 * Each check inspects the WordPress state that the compile-phase primitives 9 * should have set up: post types, taxonomies, meta boxes, columns, hooks, etc. 7 * Generic executor: each compile-phase structure node is checked by its own 8 * primitive's declared `verify` callable (CLAUTRON_Primitive_Registry). A 9 * primitive type with no `verify` declared lands in `unavailable`, not 10 * silently dropped — see the primitive's own file for its check, and 11 * CLAUTRON_Primitive_Registry::register()'s docblock for the `verify` 12 * contract. Part of the AICOM-Clautron Capability Contract v1. 10 13 */ 11 14 class CLAUTRON_Smoke_Tester { … … 20 23 * @type string[] $failed 21 24 * @type string[] $warnings 25 * @type string[] $unavailable Structures whose primitive type has no declared verify check. 22 26 * @type string $summary 23 27 * } … … 26 30 $row = CLAUTRON_Blueprint_Store::get( $blueprint_id ); 27 31 if ( ! $row ) { 28 return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], 0, 0);32 return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], [] ); 29 33 } 30 34 31 35 $data = json_decode( $row->blueprint_json, true ); 32 36 if ( ! is_array( $data ) ) { 33 return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], 0, 0);37 return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], [] ); 34 38 } 35 39 36 40 if ( $row->status !== 'active' ) { 37 return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], 0, 0);41 return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], [] ); 38 42 } 39 43 40 $passed = []; 41 $failed = []; 42 $warnings = []; 44 $passed = []; 45 $failed = []; 46 $warnings = []; 47 $unavailable = []; 43 48 44 49 // ── Check trigger ────────────────────────────────────────────────────── 50 // Not a primitive — the blueprint's own trigger field, checked directly. 45 51 $trigger = $data['trigger'] ?? null; 46 52 if ( $trigger ) { … … 59 65 $id = $node['id'] ?? $type; 60 66 61 switch ( $type ) {67 $def = class_exists( 'CLAUTRON_Primitive_Registry' ) ? CLAUTRON_Primitive_Registry::get( $type ) : null; 62 68 63 case 'register_cpt': { 64 $pt = $config['post_type'] ?? ''; 65 if ( $pt && get_post_type_object( $pt ) ) { 66 $passed[] = "register_cpt:{$pt}"; 67 } elseif ( $pt ) { 68 $failed[] = "register_cpt:{$pt} — post type not registered."; 69 } 70 break; 71 } 69 if ( ! $def ) { 70 $unavailable[] = "{$type}:{$id} — primitive type not registered."; 71 continue; 72 } 72 73 73 case 'register_taxonomy': { 74 $tax = $config['taxonomy'] ?? ''; 75 if ( $tax && taxonomy_exists( $tax ) ) { 76 $passed[] = "register_taxonomy:{$tax}"; 77 } elseif ( $tax ) { 78 $failed[] = "register_taxonomy:{$tax} — taxonomy not registered."; 79 } 80 break; 81 } 74 if ( empty( $def['verify'] ) || ! is_callable( $def['verify'] ) ) { 75 $unavailable[] = "{$type}:{$id} — no verification declared for this primitive type."; 76 continue; 77 } 82 78 83 case 'register_meta': { 84 $key = $config['meta_key'] ?? ''; 85 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 86 if ( $key ) { 87 $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' ); 88 if ( $registered ) { 89 $passed[] = "register_meta:{$key}"; 90 } else { 91 $warnings[] = "register_meta:{$key} — not found in registered meta (may still work via update_post_meta)."; 92 } 93 } 94 break; 95 } 79 $items = call_user_func( $def['verify'], $config ); 80 foreach ( (array) $items as $item ) { 81 $label = $item['label'] ?? "{$type}:{$id}"; 82 $reason = $item['reason'] ?? null; 83 $line = $reason ? "{$label} — {$reason}" : $label; 96 84 97 case 'add_metabox': { 98 $mbid = $config['id'] ?? ''; 99 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 100 $pt = $pts[0] ?? 'post'; 101 // Meta boxes are only registered in admin context. 102 if ( ! is_admin() ) { 103 $warnings[] = "add_metabox:{$mbid} — skipped (requires admin context; will work correctly on post edit screens)."; 85 switch ( $item['status'] ?? 'warning' ) { 86 case 'passed': 87 $passed[] = $line; 104 88 break; 105 } 106 $hook_name = 'add_meta_boxes_' . $pt; 107 do_action( $hook_name, null ); 108 do_action( 'add_meta_boxes', $pt, null ); 109 global $wp_meta_boxes; 110 $found = false; 111 foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context ) { 112 foreach ( $context as $priority ) { 113 if ( isset( $priority[ $mbid ] ) ) { 114 $found = true; 115 break 2; 116 } 117 } 118 } 119 if ( $found ) { 120 $passed[] = "add_metabox:{$mbid}"; 121 } else { 122 $failed[] = "add_metabox:{$mbid} — not found in wp_meta_boxes for post type '{$pt}'."; 123 } 124 break; 125 } 126 127 case 'add_admin_column': { 128 $pts = (array) ( $config['post_types'] ?? [] ); 129 foreach ( $pts as $pt ) { 130 $filter = "manage_{$pt}_posts_columns"; 131 if ( has_filter( $filter ) ) { 132 $passed[] = "add_admin_column:{$pt}"; 133 } else { 134 $failed[] = "add_admin_column:{$pt} — filter '{$filter}' not hooked."; 135 } 136 } 137 break; 138 } 139 140 case 'inject_wp_head': { 141 $priority = (int) ( $config['priority'] ?? 5 ); 142 if ( has_action( 'wp_head' ) ) { 143 $passed[] = "inject_wp_head (priority:{$priority})"; 144 } else { 145 $failed[] = 'inject_wp_head — no actions registered on wp_head.'; 146 } 147 break; 148 } 149 150 case 'inject_wp_footer': { 151 if ( has_action( 'wp_footer' ) ) { 152 $passed[] = 'inject_wp_footer'; 153 } else { 154 $failed[] = 'inject_wp_footer — no actions registered on wp_footer.'; 155 } 156 break; 157 } 158 159 case 'register_rest_route': { 160 $route = $config['route'] ?? ''; 161 if ( $route ) { 162 $routes = rest_get_server()->get_routes(); 163 $full_path = '/clautron/v1/' . ltrim( $route, '/' ); 164 if ( isset( $routes[ $full_path ] ) ) { 165 $passed[] = "register_rest_route:{$route}"; 166 } else { 167 $warnings[] = "register_rest_route:{$route} — not found in registered routes (REST server may not be initialized yet)."; 168 } 169 } 170 break; 171 } 172 173 case 'add_row_action': { 174 $pts = (array) ( $config['post_types'] ?? [] ); 175 foreach ( $pts as $pt ) { 176 $filter = "post_row_actions"; 177 if ( has_filter( $filter ) || has_filter( "{$pt}_row_actions" ) ) { 178 $passed[] = "add_row_action:{$pt}"; 179 } else { 180 $warnings[] = "add_row_action:{$pt} — row action filter not detected (may still work)."; 181 } 182 } 183 break; 184 } 185 186 case 'gate_publish': { 187 if ( has_action( 'admin_notices' ) ) { 188 $passed[] = 'gate_publish'; 189 } else { 190 $warnings[] = 'gate_publish — admin_notices hook not detected in current context.'; 191 } 192 break; 89 case 'failed': 90 $failed[] = $line; 91 break; 92 default: 93 $warnings[] = $line; 94 break; 193 95 } 194 96 } 195 97 } 196 98 197 $total = count( $passed ) + count( $failed ); 198 199 return self::result( $passed, $failed, $warnings, count( $passed ), $total ); 99 return self::result( $passed, $failed, $warnings, $unavailable ); 200 100 } 201 101 … … 206 106 array $failed, 207 107 array $warnings, 208 int $pass_count, 209 int $total 108 array $unavailable 210 109 ): array { 110 $pass_count = count( $passed ); 111 $total = $pass_count + count( $failed ); 112 211 113 if ( $total === 0 ) { 212 $summary = 'No checkable structures found.';114 $summary = empty( $unavailable ) ? 'No checkable structures found.' : 'No checks could run.'; 213 115 } elseif ( empty( $failed ) ) { 214 116 $summary = "All checks passed ({$pass_count}/{$total})."; … … 218 120 } 219 121 122 if ( ! empty( $unavailable ) ) { 123 $summary .= ' ' . count( $unavailable ) . ' structure(s) have no verification declared.'; 124 } 125 220 126 return [ 221 'passed' => $passed, 222 'failed' => $failed, 223 'warnings' => $warnings, 224 'summary' => $summary, 127 'passed' => $passed, 128 'failed' => $failed, 129 'warnings' => $warnings, 130 'unavailable' => $unavailable, 131 'summary' => $summary, 225 132 ]; 226 133 } -
clautron/tags/1.5.0/includes/primitives/class-primitive-ai.php
r3691441 r3784837 27 27 'category' => 'ai', 28 28 'phase' => 'runtime', 29 'effects' => [ 'external_requests' ], 29 30 'label' => 'AI Generate', 30 31 'description' => 'Generate text with the WordPress AI Client (site-configured provider) and store the result in a context variable. Use for excerpts, meta descriptions, alt text, translations, summaries. Prompt supports {{variable}} placeholders. Output is plain text only.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-comments.php
r3691441 r3784837 29 29 'category' => 'comments', 30 30 'phase' => 'runtime', 31 'effects' => [], 31 32 'label' => 'Query Comments', 32 33 'description' => 'Query WordPress comments with filters. Returns array of comments (id, post_id, author, author_email, content, status, date, parent_id) stored in a context variable.', … … 85 86 'category' => 'comments', 86 87 'phase' => 'runtime', 88 // approve: core emails the post author (wp_set_comment_status → wp_new_comment_notify_postauthor). 89 'effects' => static fn( array $c ): array => [ 'trash' => [ 'writes_database', 'deletes_data' ], 'approve' => [ 'writes_database', 'external_requests' ] ][ $c['status'] ?? '' ] ?? [ 'writes_database' ], 90 'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ], 87 91 'label' => 'Moderate Comment', 88 92 'description' => 'Change a comment\'s moderation status. Allowed statuses: approve, hold, spam, trash. Hard deletion is intentionally not supported.', … … 123 127 'category' => 'comments', 124 128 'phase' => 'runtime', 129 'effects' => [ 'writes_database' ], 125 130 'label' => 'Create Comment', 126 131 'description' => 'Insert a comment (or a threaded reply via parent_id) on a post. Content supports {{variable}} placeholders.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-cookie.php
r3691441 r3784837 37 37 'category' => 'data', 38 38 'phase' => 'runtime', 39 'effects' => [], 40 'reversible' => false, 39 41 'safe' => false, 40 42 'label' => 'Write Cookie', … … 83 85 'category' => 'data', 84 86 'phase' => 'runtime', 87 'effects' => [], 85 88 'label' => 'Read Cookie', 86 89 'description' => 'Read a clautron_-prefixed cookie into a workflow variable. Resolves to null if the cookie is not set.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-data.php
r3582869 r3784837 36 36 'category' => 'data', 37 37 'phase' => 'runtime', 38 'effects' => [], 38 39 'label' => 'Read Post Field', 39 40 'description' => 'Read a field from the current post (ID, title, content, status, author, etc.) and store it in a variable.', … … 85 86 'category' => 'data', 86 87 'phase' => 'runtime', 88 'effects' => [ 'writes_database' ], 87 89 'label' => 'Write Meta', 88 90 'description' => 'Write a meta value to a post, user, or term.', … … 122 124 'category' => 'data', 123 125 'phase' => 'runtime', 126 'effects' => [], 124 127 'label' => 'Read Meta', 125 128 'description' => 'Read a meta value from a post, user, or term and store it in a variable.', … … 161 164 'category' => 'data', 162 165 'phase' => 'runtime', 166 'effects' => [ 'writes_database' ], 163 167 'safe' => false, 164 168 'label' => 'Write Option', … … 187 191 'category' => 'data', 188 192 'phase' => 'runtime', 193 'effects' => [], 189 194 'label' => 'Read Option', 190 195 'description' => 'Read a WordPress option and store it in a variable.', … … 324 329 'category' => 'wordpress', 325 330 'phase' => 'compile', 331 'effects' => [ 'writes_database' ], 326 332 'label' => 'Gate Publish', 327 333 'description' => 'Block or warn when publishing a post that fails completeness checks.', … … 407 413 echo '</p></div>'; 408 414 } ); 415 }, 416 'verify' => static function ( array $config ): array { 417 if ( has_action( 'admin_notices' ) ) { 418 return [ [ 'status' => 'passed', 'label' => 'gate_publish' ] ]; 419 } 420 return [ [ 'status' => 'warning', 'label' => 'gate_publish', 'reason' => 'admin_notices hook not detected in current context.' ] ]; 409 421 }, 410 422 ] ); … … 478 490 'category' => 'data', 479 491 'phase' => 'runtime', 492 'effects' => [ 'writes_database' ], 480 493 'label' => 'Check Fields Completeness', 481 494 'description' => 'Evaluate a list of post fields and calculate a completeness score (0-100). Saves the score to a meta key and an output variable.', … … 563 576 'category' => 'data', 564 577 'phase' => 'runtime', 578 'effects' => [], 565 579 'label' => 'Run Report', 566 580 'description' => 'Execute a saved report blueprint and store its result rows in a context variable. Use with foreach_query_result to process each row.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-elementor.php
r3592659 r3784837 39 39 'category' => 'elementor', 40 40 'phase' => 'compile', 41 'effects' => [ 'executes_code' ], 41 42 'label' => 'Elementor Widget', 42 43 'description' => 'Create a custom Elementor widget with content controls, style tab sections, and a server-rendered HTML template. Appears under the "Clautron" category in the Elementor panel.', … … 97 98 'category' => 'wordpress', 98 99 'phase' => 'compile', 100 'effects' => [ 'executes_code' ], 99 101 'label' => 'WordPress Widget', 100 102 'description' => 'Register a native WordPress sidebar widget (WP_Widget) usable in Appearance > Widgets and any theme sidebar. Supports text, textarea, number, url, color, checkbox, and select controls. Use {{key}} (escaped), {{{key}}} (HTML), {{key.url}} (URL), and {{#if key}}...{{/if}} in the template.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-email.php
r3691441 r3784837 20 20 'category' => 'communication', 21 21 'phase' => 'runtime', 22 'effects' => [ 'external_requests' ], 22 23 'label' => 'Send Email', 23 24 'description' => 'Send an email via wp_mail(). All fields support {{variable}} template syntax.', … … 69 70 'category' => 'communication', 70 71 'phase' => 'runtime', 72 'effects' => [ 'external_requests' ], 71 73 'label' => 'Notify User', 72 74 'description' => 'Send a notification email to a WordPress user by their user ID. Subject and body support {{variable}} template syntax.', … … 129 131 'category' => 'communication', 130 132 'phase' => 'compile', 133 'effects' => [ 'external_requests' ], 131 134 'safe' => false, 132 135 'label' => 'Configure SMTP', -
clautron/tags/1.5.0/includes/primitives/class-primitive-events.php
r3691441 r3784837 32 32 'category' => 'events', 33 33 'phase' => 'runtime', 34 'effects' => [ 'writes_database' ], 34 35 'label' => 'Track Event', 35 36 'description' => 'Write one event to the clautron_events time-series store. Use this inside any blueprint trigger to record that something happened (a pageview, a login failure, an order, a cron run, etc.).', … … 78 79 'category' => 'events', 79 80 'phase' => 'runtime', 81 'effects' => [], 80 82 'label' => 'Query Events', 81 83 'description' => 'Read raw event rows from the clautron_events store. Results are stored in output_variable for use in subsequent primitives (foreach_query_result, send_email, etc.).', … … 120 122 'category' => 'events', 121 123 'phase' => 'runtime', 124 'effects' => [], 122 125 'label' => 'Aggregate Events', 123 126 'description' => 'Group and aggregate event data: total counts, sums, averages, grouped by time period or object. Returns an array of {period, value} rows in output_variable.', … … 160 163 'category' => 'events', 161 164 'phase' => 'runtime', 165 'effects' => [ 'writes_database', 'deletes_data' ], 162 166 'label' => 'Purge Events', 163 167 'description' => 'Delete old events for retention management. Use inside a recurring job (e.g. weekly). Returns the count of deleted rows in output_variable.', … … 201 205 'category' => 'events', 202 206 'phase' => 'compile', 207 'effects' => [ 'writes_database' ], 203 208 'safe' => false, 204 209 'label' => 'Log All Outgoing Email', … … 256 261 'category' => 'events', 257 262 'phase' => 'compile', 263 'effects' => [ 'writes_database' ], 258 264 'safe' => false, 259 265 'label' => 'Log Site Activity', -
clautron/tags/1.5.0/includes/primitives/class-primitive-filter.php
r3691441 r3784837 33 33 'category' => 'frontend', 34 34 'phase' => 'compile', 35 'effects' => [], 35 36 'label' => 'Filter Query by Request Parameters', 36 37 'description' => 'Restrict frontend main queries (archives, search, home) using one or more filter rules read from GET parameters: taxonomy facets, exact meta match, or numeric meta range. Built for shop/archive filter sidebars (category, in-stock, price range). Never touches wp-admin or REST requests.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-frontend.php
r3691441 r3784837 26 26 'category' => 'frontend', 27 27 'phase' => 'compile', 28 'effects' => [ 'executes_code' ], 28 29 'label' => 'Inject into <head>', 29 30 'description' => 'Output HTML into wp_head on singular post/page views. Supports {{post.field}}, {{site.field}}, and {{_meta_key}} placeholders. Optionally outputs a Schema.org JSON-LD block via json_ld config.', … … 118 119 }, $priority ); 119 120 }, 121 'verify' => static function ( array $config ): array { 122 $priority = (int) ( $config['priority'] ?? 5 ); 123 if ( has_action( 'wp_head' ) ) { 124 return [ [ 'status' => 'passed', 'label' => "inject_wp_head (priority:{$priority})" ] ]; 125 } 126 return [ [ 'status' => 'failed', 'label' => 'inject_wp_head', 'reason' => 'no actions registered on wp_head.' ] ]; 127 }, 120 128 ] ); 121 129 } … … 127 135 'category' => 'frontend', 128 136 'phase' => 'compile', 137 'effects' => [ 'executes_code' ], 129 138 'label' => 'Inject before </body>', 130 139 'description' => 'Output HTML into wp_footer. Same {{variable}} resolution as inject_wp_head (post.*, site.*, meta keys). Leave post_types empty to render on every frontend page (banners, popups, share widgets) — set it to restrict to singular views of those post types, where {{post.*}}/meta-key tokens resolve against the viewed post. Set suppress_if_cookie to hide the whole block once a clautron_-prefixed cookie (e.g. one set by write_cookie) has any non-empty value — the standard pattern for "don\'t show this again after accept/dismiss".', … … 180 189 }, $priority ); 181 190 }, 191 'verify' => static function ( array $config ): array { 192 if ( has_action( 'wp_footer' ) ) { 193 return [ [ 'status' => 'passed', 'label' => 'inject_wp_footer' ] ]; 194 } 195 return [ [ 'status' => 'failed', 'label' => 'inject_wp_footer', 'reason' => 'no actions registered on wp_footer.' ] ]; 196 }, 182 197 ] ); 183 198 } … … 189 204 'category' => 'frontend', 190 205 'phase' => 'compile', 206 'effects' => [ 'executes_code' ], 191 207 'label' => 'Register Shortcode', 192 208 'description' => 'Register a WordPress [shortcode] rendered from an HTML template. Attributes become template tokens: {{attr}} escaped text, {{{attr}}} sanitized HTML, {{attr.url}} URL, {{#if attr}}...{{/if}} conditionals. Enclosed content is available as {{{content}}}.', … … 272 288 'category' => 'frontend', 273 289 'phase' => 'compile', 290 'effects' => [], 274 291 'label' => 'Enqueue Registered Asset', 275 292 'description' => 'Load CSS/JS assets that are ALREADY registered on the site (by core, the theme, or plugins) onto frontend pages, by handle. Never loads external URLs or new code — only activates assets the site already ships (e.g. dashicons, wp-block-library, swiper, theme handles). Use widget/shortcode templates with the classes those assets provide.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-http.php
r3582869 r3784837 24 24 'category' => 'http', 25 25 'phase' => 'runtime', 26 'effects' => [ 'external_requests' ], 27 'safe' => false, 26 28 'label' => 'HTTP Request', 27 29 'description' => 'Send an HTTP request (GET, POST, PUT, DELETE, PATCH) and store the response body and status code in context variables.', … … 112 114 'category' => 'http', 113 115 'phase' => 'runtime', 116 'effects' => [ 'external_requests', 'writes_database' ], // SimplePie caches the feed in transients 117 'safe' => false, 114 118 'label' => 'Fetch RSS/Atom Feed', 115 119 'description' => 'Fetch an RSS or Atom feed and store items as an array of {title, link, description, pubdate} objects.', 116 'safe' => true,117 120 'schema' => [ 118 121 'url' => [ 'type' => 'string', 'required' => true, 'description' => 'Feed URL. Supports {{variable}}.' ], … … 170 173 'category' => 'http', 171 174 'phase' => 'runtime', 175 'effects' => [ 'external_requests' ], 172 176 'label' => 'Send Webhook', 173 177 'description' => 'POST a JSON payload to a webhook URL. Supports {{variable}} in URL and payload values.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-language.php
r3691441 r3784837 29 29 'category' => 'frontend', 30 30 'phase' => 'compile', 31 'effects' => [], 31 32 'label' => 'Filter Query by Language', 32 33 'description' => 'Restrict frontend main queries (archives, search, home, singular) to posts tagged with one term of a language taxonomy. Language is resolved from a URL query var, falling back to a cookie, falling back to a configured default. Never touches wp-admin or REST requests.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-logic.php
r3582869 r3784837 30 30 'category' => 'logic', 31 31 'phase' => 'runtime', 32 'effects' => [], 32 33 'label' => 'If / Else', 33 34 'description' => 'Evaluate a condition. Execute pass_branch nodes if true, fail_branch nodes if false.', … … 74 75 'category' => 'logic', 75 76 'phase' => 'runtime', 77 'effects' => [], 76 78 'label' => 'Stop Execution', 77 79 'description' => 'Halt workflow execution immediately. No further nodes will run.', … … 89 91 'category' => 'logic', 90 92 'phase' => 'runtime', 93 'effects' => [], 91 94 'label' => 'Set Variable', 92 95 'description' => 'Assign a literal or resolved value to a context variable for use in later nodes.', … … 111 114 'category' => 'logic', 112 115 'phase' => 'runtime', 116 'effects' => [], 113 117 'label' => 'For Each Row', 114 118 'description' => 'Iterate over rows from a query result stored in a context variable. Each row\'s columns become available as {{row.column_name}} for the body nodes.', … … 181 185 'category' => 'logic', 182 186 'phase' => 'runtime', 187 'effects' => [], 183 188 'label' => 'Switch / Case', 184 189 'description' => 'Evaluate a value against multiple cases and execute the matching branch. Falls through to default_branch if no case matches.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-media.php
r3691441 r3784837 27 27 'category' => 'media', 28 28 'phase' => 'runtime', 29 'effects' => [ 'writes_database', 'writes_files', 'external_requests' ], 29 30 'label' => 'Create Media from URL', 30 31 'description' => 'Download an image from a URL and add it to the WordPress media library. Returns the new attachment ID.', … … 81 82 'category' => 'media', 82 83 'phase' => 'runtime', 84 'effects' => [ 'writes_database' ], 83 85 'label' => 'Set Featured Image', 84 86 'description' => 'Set the featured image (thumbnail) of a post to a given attachment ID.', … … 120 122 'category' => 'media', 121 123 'phase' => 'runtime', 124 'effects' => [], 122 125 'label' => 'Get Image URL', 123 126 'description' => 'Get the URL of an attachment image at a given size.', … … 150 153 'category' => 'media', 151 154 'phase' => 'runtime', 155 'effects' => [ 'writes_database', 'writes_files' ], 152 156 'safe' => false, 153 157 'label' => 'Handle File Upload', … … 162 166 'handler' => static function ( array $config, CLAUTRON_Workflow_Context $ctx ): int|false { 163 167 $out_var = $config['output_variable'] ?? 'uploaded_attachment_id'; 164 $fail = static function () use ( $ctx, $out_var ): false{168 $fail = static function () use ( $ctx, $out_var ): bool { 165 169 if ( $out_var ) { 166 170 $ctx->write_variable( $out_var, false ); -
clautron/tags/1.5.0/includes/primitives/class-primitive-query.php
r3691441 r3784837 32 32 'category' => 'query', 33 33 'phase' => 'runtime', 34 'effects' => [], 34 35 'label' => 'Query Posts', 35 36 'description' => 'Query WordPress posts with flexible filters. Returns array of post objects (id, title, content, excerpt, status, type, author, date, permalink) stored in a context variable.', … … 97 98 'category' => 'query', 98 99 'phase' => 'runtime', 100 'effects' => [], 99 101 'label' => 'Get Post', 100 102 'description' => 'Fetch a single post by ID and store its fields in a context variable.', … … 133 135 'category' => 'query', 134 136 'phase' => 'runtime', 137 'effects' => [], 135 138 'label' => 'Count Posts', 136 139 'description' => 'Count posts matching given criteria. Writes integer to output_variable.', … … 191 194 'category' => 'query', 192 195 'phase' => 'runtime', 196 'effects' => [], 193 197 'label' => 'Query Users', 194 198 'description' => 'Query WordPress users with flexible filters. Returns array of user objects.', … … 246 250 'category' => 'query', 247 251 'phase' => 'runtime', 252 'effects' => [], 248 253 'label' => 'Get User', 249 254 'description' => 'Fetch a single user by ID and store their data in a context variable.', … … 282 287 'category' => 'query', 283 288 'phase' => 'runtime', 289 'effects' => [], 284 290 'label' => 'Query Terms', 285 291 'description' => 'Query taxonomy terms. Returns array of term objects.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-redirect.php
r3691441 r3784837 20 20 'category' => 'frontend', 21 21 'phase' => 'compile', 22 'effects' => [], 23 // Browsers cache permanent redirects indefinitely; removing the rule doesn't reach them. 24 'reversible' => static fn( array $c ): bool => ! in_array( (int) ( $c['status'] ?? 301 ), [ 301, 308 ], true ), 22 25 'label' => 'Redirect URL', 23 26 'description' => 'Redirect requests from one path to another. Refuses redirects that would break wp-admin/login/REST access or create a loop.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-request.php
r3691441 r3784837 25 25 'category' => 'data', 26 26 'phase' => 'runtime', 27 'effects' => [], 27 28 'safe' => false, 28 29 'label' => 'Read Request Parameter', -
clautron/tags/1.5.0/includes/primitives/class-primitive-security.php
r3691441 r3784837 3 3 4 4 /** 5 * Security Primitives — abuse throttling .5 * Security Primitives — abuse throttling and information-disclosure hardening. 6 6 * 7 7 * Registered primitives: 8 * - rate_limit_check Fixed-window counter (transient-backed) keyed by an 9 * identifier (defaults to client IP). Use as a 10 * condition to gate login/comment/form actions. 8 * - rate_limit_check Fixed-window counter (transient-backed) keyed by an 9 * identifier (defaults to client IP). Use as a 10 * condition to gate login/comment/form actions. 11 * - harden_user_enumeration Stop anonymous visitors from discovering usernames 12 * (REST users endpoint, author archives, oEmbed 13 * author, login error messages). 14 * - security_headers Send Referrer-Policy, X-Content-Type-Options, 15 * X-Frame-Options and Permissions-Policy on 16 * frontend responses, and HSTS on HTTPS (opt-in). 17 * - hide_wp_version Remove generator tags, mask the WordPress 18 * version in core asset URLs and, opt-in, 19 * delete readme.html. 20 * 21 * The hardening primitives hook named static methods rather than closures 22 * wherever the callback needs no config: add_filter() with the same callable 23 * and priority is idempotent, so two blueprints declaring the same primitive 24 * do not stack duplicate callbacks, and verify can check the exact callable 25 * with has_filter() instead of "something is hooked here". 11 26 */ 12 27 class CLAUTRON_Primitive_Security extends CLAUTRON_Abstract_Primitive { 13 28 29 /** Login error codes that reveal whether a username/email exists. */ 30 private const LOGIN_ENUMERATION_CODES = [ 'invalid_username', 'invalid_email', 'incorrect_password' ]; 31 32 private const REFERRER_POLICIES = [ 33 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', 34 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', 35 ]; 36 37 private const FRAME_OPTIONS = [ 'SAMEORIGIN', 'DENY' ]; 38 39 private const PERMISSIONS_POLICY = 'camera=(), microphone=()'; 40 41 /** WordPress version readme.html was last removed for (see remove_core_readme()). */ 42 private const README_OPTION = 'clautron_readme_removed_for'; 43 44 /** Headers merged from every active security_headers structure; the first one to set a header wins. */ 45 private static array $security_headers = []; 46 14 47 public static function register_all(): void { 15 48 self::register_rate_limit_check(); 49 self::register_harden_user_enumeration(); 50 self::register_security_headers(); 51 self::register_hide_wp_version(); 16 52 } 17 53 … … 22 58 'category' => 'security', 23 59 'phase' => 'runtime', 60 'effects' => [ 'writes_database' ], 24 61 'safe' => false, 25 62 'label' => 'Rate Limit Check', … … 86 123 return false; 87 124 } 125 126 // ── harden_user_enumeration ────────────────────────────────────────────── 127 128 private static function register_harden_user_enumeration(): void { 129 CLAUTRON_Primitive_Registry::register( 'harden_user_enumeration', [ 130 'category' => 'security', 131 'phase' => 'compile', 132 'effects' => [], 133 'safe' => false, 134 'label' => 'Harden User Enumeration', 135 'description' => 'Stop anonymous visitors from discovering usernames. Blocks the /wp/v2/users REST endpoints for logged-out requests (logged-in users, including the block editor author picker, keep working); returns 404 for every author archive (?author=N, ?author_name=, /author/slug/ and their feeds), drops the users sitemap and points author links at the homepage; replaces the post author in oEmbed responses with the site name; and gives the same login error for an unknown username and a wrong password. Each part can be switched off individually.', 136 'schema' => [ 137 'block_rest_users' => [ 'type' => 'boolean', 'default' => true, 'description' => '401 on every /wp/v2/users route for logged-out requests.' ], 138 'disable_author_archives' => [ 'type' => 'boolean', 'default' => true, 'description' => '404 on all author archives for everyone, remove the users sitemap, point author links to the homepage.' ], 139 'strip_oembed_author' => [ 'type' => 'boolean', 'default' => true, 'description' => 'oEmbed responses show the site name and homepage instead of the post author.' ], 140 'generic_login_errors' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Same login error whether the username/email is unknown or the password is wrong.' ], 141 ], 142 'examples' => [ 143 [], 144 [ 'disable_author_archives' => false ], 145 ], 146 'handler' => static function ( array $config ): void { 147 if ( $config['block_rest_users'] ?? true ) { 148 add_filter( 'rest_request_before_callbacks', [ self::class, 'gate_rest_users' ], 10, 3 ); 149 } 150 if ( $config['disable_author_archives'] ?? true ) { 151 add_filter( 'request', [ self::class, 'block_author_request' ] ); 152 add_filter( 'wp_sitemaps_add_provider', [ self::class, 'drop_users_sitemap' ], 10, 2 ); 153 add_filter( 'author_link', [ self::class, 'neutral_author_link' ] ); 154 } 155 if ( $config['strip_oembed_author'] ?? true ) { 156 add_filter( 'oembed_response_data', [ self::class, 'neutral_oembed_author' ] ); 157 } 158 if ( $config['generic_login_errors'] ?? true ) { 159 // After every core authenticator (20) and the spam check (99). 160 add_filter( 'authenticate', [ self::class, 'generic_login_error' ], 100 ); 161 } 162 }, 163 'verify' => static function ( array $config ): array { 164 $items = []; 165 $checks = [ 166 'block_rest_users' => [ 'rest_request_before_callbacks', 'gate_rest_users' ], 167 'disable_author_archives' => [ 'request', 'block_author_request' ], 168 'strip_oembed_author' => [ 'oembed_response_data', 'neutral_oembed_author' ], 169 'generic_login_errors' => [ 'authenticate', 'generic_login_error' ], 170 ]; 171 foreach ( $checks as $option => [ $hook, $method ] ) { 172 if ( ! ( $config[ $option ] ?? true ) ) { 173 continue; 174 } 175 $items[] = false !== has_filter( $hook, [ self::class, $method ] ) 176 ? [ 'status' => 'passed', 'label' => "harden_user_enumeration:{$option}" ] 177 : [ 'status' => 'failed', 'label' => "harden_user_enumeration:{$option}", 'reason' => "not hooked on {$hook}." ]; 178 } 179 180 if ( $config['block_rest_users'] ?? true ) { 181 $status = self::anonymous_rest_status( '/wp/v2/users' ); 182 $items[] = in_array( $status, [ 401, 403 ], true ) 183 ? [ 'status' => 'passed', 'label' => "anonymous GET /wp/v2/users → {$status}" ] 184 : [ 'status' => 'failed', 'label' => 'anonymous GET /wp/v2/users', 'reason' => "expected 401, got {$status}." ]; 185 } 186 187 if ( $config['disable_author_archives'] ?? true ) { 188 $vars = self::author_query_vars( [ 'author' => '1' ], false ); 189 $items[] = ( $vars['error'] ?? '' ) === '404' 190 ? [ 'status' => 'passed', 'label' => '?author=1 resolves to 404' ] 191 : [ 'status' => 'failed', 'label' => '?author=1', 'reason' => 'author query was not turned into a 404.' ]; 192 } 193 194 return $items; 195 }, 196 ] ); 197 } 198 199 /** 200 * rest_request_before_callbacks: refuse every route served by the core 201 * users controller to logged-out requests. 202 * 203 * Keyed on the controller that matched, not on the URL: WordPress matches 204 * routes case-insensitively (/wp/v2/USERS works) and also accepts 205 * ?rest_route=, so a path check would have to replicate the router to be 206 * airtight. Whatever spelling reached the users controller is caught here. 207 */ 208 public static function gate_rest_users( mixed $response, array $handler, WP_REST_Request $request ): mixed { 209 $callback = $handler['callback'] ?? null; 210 if ( ! is_array( $callback ) || ! ( $callback[0] ?? null ) instanceof WP_REST_Users_Controller ) { 211 return $response; 212 } 213 if ( is_user_logged_in() ) { 214 return $response; 215 } 216 return new WP_Error( 217 'rest_forbidden', 218 __( 'Sorry, you are not allowed to do that.', 'clautron' ), 219 [ 'status' => rest_authorization_required_code() ] 220 ); 221 } 222 223 /** request filter: wiring for author_query_vars(). */ 224 public static function block_author_request( array $query_vars ): array { 225 return self::author_query_vars( $query_vars, is_admin() ); 226 } 227 228 /** 229 * Replace any author-archive query with a plain 404. Public for unit 230 * testing (see CLAUTRON_Primitive_WordPress::build_cpt_args()). 231 * 232 * Runs on `request`, before WP_Query exists, so every way of asking for an 233 * author archive is covered by one check: ?author=N, ?author_name=slug, 234 * /author/slug/ (rewrite sets author_name) and their /feed/ variants. 235 * Blocking only the canonical redirect is not enough: an author without 236 * published posts gets no redirect, but the archive still prints the slug 237 * in <title> and body_class. 238 * 239 * Left alone: 240 * - wp-admin, because edit.php?author=N calls wp() and goes through this 241 * same filter to filter the posts list; 242 * - REST requests, because /wp-json/wp/v2/posts?author=N is a legitimate 243 * filter and `author` is a public query var. 244 */ 245 public static function author_query_vars( array $query_vars, bool $in_admin ): array { 246 if ( $in_admin || isset( $query_vars['rest_route'] ) ) { 247 return $query_vars; 248 } 249 if ( empty( $query_vars['author'] ) && empty( $query_vars['author_name'] ) ) { 250 return $query_vars; 251 } 252 // Same shape WP::parse_request() produces for an unmatched URL, so 253 // WP::send_headers() sends the 404 status and nocache headers itself. 254 return [ 'error' => '404' ]; 255 } 256 257 /** wp_sitemaps_add_provider: the users sitemap only lists author archive URLs. */ 258 public static function drop_users_sitemap( mixed $provider, string $name ): mixed { 259 return 'users' === $name ? false : $provider; 260 } 261 262 /** author_link: author archives are gone, and the link itself carries the slug. */ 263 public static function neutral_author_link(): string { 264 return home_url( '/' ); 265 } 266 267 /** 268 * oembed_response_data: use the values core itself falls back to when a 269 * post has no author, instead of display_name + author archive URL. 270 */ 271 public static function neutral_oembed_author( array $data ): array { 272 if ( isset( $data['author_name'] ) || isset( $data['author_url'] ) ) { 273 $data['author_name'] = get_bloginfo( 'name' ); 274 $data['author_url'] = get_home_url(); 275 } 276 return $data; 277 } 278 279 /** 280 * authenticate: collapse "unknown username", "unknown email" and "wrong 281 * password" into one error. 282 * 283 * Reuses core's own 'invalidcombo' code: it is already in wp-login.php's 284 * shake list, and it keeps wp-login.php from pre-filling the username 285 * field only on a wrong password, which would otherwise leak the same 286 * information through the form instead of the message. 287 */ 288 public static function generic_login_error( mixed $user ): mixed { 289 if ( ! is_wp_error( $user ) || ! array_intersect( $user->get_error_codes(), self::LOGIN_ENUMERATION_CODES ) ) { 290 return $user; 291 } 292 return new WP_Error( 293 'invalidcombo', 294 __( '<strong>Error:</strong> The username, email address or password is incorrect.', 'clautron' ) 295 ); 296 } 297 298 /** Dispatch a GET as a logged-out visitor and return the HTTP status. */ 299 private static function anonymous_rest_status( string $route ): int { 300 $previous_user = get_current_user_id(); 301 wp_set_current_user( 0 ); 302 try { 303 return rest_do_request( new WP_REST_Request( 'GET', $route ) )->get_status(); 304 } finally { 305 wp_set_current_user( $previous_user ); 306 } 307 } 308 309 // ── security_headers ───────────────────────────────────────────────────── 310 311 private static function register_security_headers(): void { 312 CLAUTRON_Primitive_Registry::register( 'security_headers', [ 313 'category' => 'security', 314 'phase' => 'compile', 315 'effects' => [], 316 // Browsers keep honouring HSTS for max-age after the header stops. 317 'reversible' => static fn( array $c ): bool => (int) ( $c['hsts_max_age'] ?? 0 ) <= 0, 318 'safe' => false, 319 'label' => 'Security Headers', 320 'description' => 'Send Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy on frontend responses, plus Strict-Transport-Security (HSTS) on HTTPS when opted in. A header already set by another plugin through wp_headers is left as is. Headers are sent from PHP, so pages served straight from a page cache or CDN without running WordPress will not carry them; verify loads the homepage to detect that.', 321 'schema' => [ 322 'referrer_policy' => [ 'type' => 'string', 'default' => 'strict-origin-when-cross-origin', 'description' => 'Any standard Referrer-Policy value. Empty = do not send. strict-origin-when-cross-origin matches what WordPress core sends in wp-admin; no-referrer breaks YouTube embeds and referral analytics.' ], 323 'content_type_options' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Send X-Content-Type-Options: nosniff.' ], 324 'frame_options' => [ 'type' => 'string', 'default' => 'SAMEORIGIN', 'description' => 'SAMEORIGIN, DENY, or empty to not send. SAMEORIGIN keeps the Customizer and page-builder previews working.' ], 325 'permissions_policy' => [ 'type' => 'string', 'default' => self::PERMISSIONS_POLICY, 'description' => 'Permissions-Policy value, e.g. "camera=(), microphone=(), geolocation=(self)". Empty = do not send; an invalid value is not sent. The default turns off camera and microphone only: blocking geolocation would break store/branch locators, and payment would break Apple Pay / Google Pay buttons.' ], 326 'hsts_max_age' => [ 'type' => 'integer', 'default' => 0, 'description' => 'Opt-in Strict-Transport-Security max-age in seconds (31536000 = one year). 0 = do not send. Only sent over HTTPS. Browsers then refuse plain HTTP for that long and deactivating does not undo it, so turn it on only when the whole site works over HTTPS.' ], 327 'hsts_include_subdomains' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Add includeSubDomains to HSTS. Only when every subdomain serves HTTPS.' ], 328 ], 329 'examples' => [ 330 [], 331 [ 'permissions_policy' => 'camera=(), microphone=(), geolocation=(self)', 'hsts_max_age' => 31536000 ], 332 ], 333 'handler' => static function ( array $config ): void { 334 $headers = self::build_security_headers( $config ); 335 if ( ! $headers ) { 336 return; 337 } 338 self::$security_headers = self::merge_headers( self::$security_headers, $headers ); 339 add_filter( 'wp_headers', [ self::class, 'send_security_headers' ] ); 340 }, 341 'verify' => static function ( array $config ): array { 342 $expected = self::build_security_headers( $config ); 343 if ( ! $expected ) { 344 return []; 345 } 346 if ( false === has_filter( 'wp_headers', [ self::class, 'send_security_headers' ] ) ) { 347 return [ [ 'status' => 'failed', 'label' => 'security_headers', 'reason' => 'not hooked on wp_headers.' ] ]; 348 } 349 350 $items = []; 351 foreach ( $expected as $name => $value ) { 352 if ( ( self::$security_headers[ $name ] ?? null ) !== $value ) { 353 $items[] = [ 'status' => 'warning', 'label' => "security_headers:{$name}", 'reason' => 'another security_headers capability already sets it to "' . ( self::$security_headers[ $name ] ?? '' ) . '"; the first one wins.' ]; 354 } 355 } 356 357 $https = 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME ); 358 if ( isset( $expected['Strict-Transport-Security'] ) && ! $https ) { 359 $items[] = [ 'status' => 'warning', 'label' => 'security_headers:Strict-Transport-Security', 'reason' => 'HSTS is only sent over HTTPS and this site\'s address is http://.' ]; 360 unset( $expected['Strict-Transport-Security'] ); 361 } 362 363 $response = wp_remote_get( home_url( '/' ), [ 'timeout' => 5, 'redirection' => 3 ] ); 364 if ( is_wp_error( $response ) ) { 365 $items[] = [ 'status' => 'warning', 'label' => 'security_headers:live', 'reason' => 'could not load the homepage to confirm the headers reach visitors: ' . $response->get_error_message() ]; 366 return $items; 367 } 368 foreach ( $expected as $name => $value ) { 369 $items[] = '' !== wp_remote_retrieve_header( $response, $name ) 370 ? [ 'status' => 'passed', 'label' => "{$name} on homepage" ] 371 : [ 'status' => 'warning', 'label' => "{$name} on homepage", 'reason' => 'set by WordPress but missing from the live response; a page cache, CDN or proxy is serving the page without PHP or stripping the header. Set it at that layer too.' ]; 372 } 373 return $items; 374 }, 375 ] ); 376 } 377 378 /** 379 * wp_headers: everything active security_headers structures asked for. 380 * One named callback (not a closure per structure) so verify can check 381 * that this primitive, not just any plugin, is hooked. 382 */ 383 public static function send_security_headers( array $headers ): array { 384 $ours = self::$security_headers; 385 if ( ! is_ssl() ) { 386 unset( $ours['Strict-Transport-Security'] ); 387 } 388 return self::merge_headers( $headers, $ours ); 389 } 390 391 /** 392 * Header name → value from a security_headers config. Invalid values are 393 * dropped rather than sent, since a malformed Referrer-Policy or 394 * X-Frame-Options is ignored by browsers anyway. Public for unit testing. 395 */ 396 public static function build_security_headers( array $config ): array { 397 $headers = []; 398 399 $referrer = strtolower( trim( (string) ( $config['referrer_policy'] ?? 'strict-origin-when-cross-origin' ) ) ); 400 if ( in_array( $referrer, self::REFERRER_POLICIES, true ) ) { 401 $headers['Referrer-Policy'] = $referrer; 402 } 403 404 if ( $config['content_type_options'] ?? true ) { 405 $headers['X-Content-Type-Options'] = 'nosniff'; 406 } 407 408 $frame = strtoupper( trim( (string) ( $config['frame_options'] ?? 'SAMEORIGIN' ) ) ); 409 if ( in_array( $frame, self::FRAME_OPTIONS, true ) ) { 410 $headers['X-Frame-Options'] = $frame; 411 } 412 413 $permissions = trim( (string) ( $config['permissions_policy'] ?? self::PERMISSIONS_POLICY ) ); 414 $directive = '[a-z0-9-]+=(?:\*|\((?:[ \t]*(?:self|src|\*|"https?:\/\/[^"\s]+"))*[ \t]*\))'; 415 if ( preg_match( '/^' . $directive . '(?:[ \t]*,[ \t]*' . $directive . ')*$/D', $permissions ) ) { 416 $headers['Permissions-Policy'] = $permissions; 417 } 418 419 $max_age = min( 63072000, (int) ( $config['hsts_max_age'] ?? 0 ) ); 420 if ( $max_age > 0 ) { 421 $headers['Strict-Transport-Security'] = "max-age={$max_age}" . ( empty( $config['hsts_include_subdomains'] ) ? '' : '; includeSubDomains' ); 422 } 423 424 return $headers; 425 } 426 427 /** 428 * Add $ours to $existing without overriding a header someone else already 429 * set (case-insensitive, like HTTP header names). Overriding could weaken 430 * a stricter choice, e.g. a security plugin's X-Frame-Options: DENY. 431 */ 432 public static function merge_headers( array $existing, array $ours ): array { 433 $taken = array_change_key_case( $existing, CASE_LOWER ); 434 foreach ( $ours as $name => $value ) { 435 if ( ! isset( $taken[ strtolower( $name ) ] ) ) { 436 $existing[ $name ] = $value; 437 } 438 } 439 return $existing; 440 } 441 442 // ── hide_wp_version ────────────────────────────────────────────────────── 443 444 private static function register_hide_wp_version(): void { 445 CLAUTRON_Primitive_Registry::register( 'hide_wp_version', [ 446 'category' => 'security', 447 'phase' => 'compile', 448 'effects' => static fn( array $c ): array => empty( $c['remove_readme'] ) ? [] : [ 'writes_files', 'writes_database' ], 449 'effects_max' => [ 'writes_files', 'writes_database' ], 450 'safe' => false, 451 'label' => 'Hide WordPress Version', 452 'description' => 'Stop advertising the WordPress version. Removes the generator meta tag and the feed <generator> element (which also drops WooCommerce\'s generator line), turns off Elementor\'s generator tag without changing its saved setting, and replaces ?ver=<WordPress version> on asset URLs with a site-specific hash that still changes on every update, so browser caches are still refreshed. readme.html in the site root also states the version, but the web server serves it without WordPress, so nothing above hides it: set remove_readme to delete it (opt-in, a file deletion that deactivating does not undo).', 453 'schema' => [ 454 'remove_generator' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Remove generator tags (WordPress, WooCommerce, Elementor) from pages and feeds.' ], 455 'mask_asset_versions' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Replace ?ver=<WordPress version> on script/style URLs with a hash of it.' ], 456 'remove_readme' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Delete readme.html from the site root, and again after every WordPress update (which restores it). Works on any web server. Deactivating does not bring the file back; the next WordPress update does.' ], 457 ], 458 'examples' => [ 459 [], 460 [ 'remove_readme' => true ], 461 ], 462 'handler' => static function ( array $config ): void { 463 if ( ! empty( $config['remove_readme'] ) ) { 464 add_action( 'init', [ self::class, 'maybe_remove_core_readme' ] ); 465 } 466 if ( $config['remove_generator'] ?? true ) { 467 add_filter( 'the_generator', '__return_empty_string' ); 468 // Elementor prints its own tag on wp_head unless this option is '1'. 469 // pre_option_ overrides it for as long as the feature is active and 470 // leaves the stored value untouched when it is deactivated. 471 add_filter( 'pre_option_elementor_meta_generator_tag', [ self::class, 'elementor_generator_off' ] ); 472 } 473 if ( $config['mask_asset_versions'] ?? true ) { 474 add_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] ); 475 add_filter( 'style_loader_src', [ self::class, 'mask_core_version' ] ); 476 } 477 }, 478 'verify' => static function ( array $config ): array { 479 $items = []; 480 if ( $config['remove_generator'] ?? true ) { 481 $items[] = false !== has_filter( 'the_generator', '__return_empty_string' ) 482 ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_generator' ] 483 : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_generator', 'reason' => 'not hooked on the_generator.' ]; 484 } 485 if ( $config['mask_asset_versions'] ?? true ) { 486 $version = get_bloginfo( 'version' ); 487 $masked = self::mask_core_version( includes_url( 'js/jquery/jquery.min.js?ver=' . $version ) ); 488 $items[] = false !== has_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] ) && ! str_contains( $masked, $version ) 489 ? [ 'status' => 'passed', 'label' => 'hide_wp_version:mask_asset_versions' ] 490 : [ 'status' => 'failed', 'label' => 'hide_wp_version:mask_asset_versions', 'reason' => 'asset URLs still carry the WordPress version.' ]; 491 } 492 if ( ! empty( $config['remove_readme'] ) ) { 493 $items[] = ! file_exists( ABSPATH . 'readme.html' ) 494 ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_readme' ] 495 : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_readme', 'reason' => 'readme.html is still in the site root; WordPress could not delete it (file permissions). Delete it by hand or deny it at the web server.' ]; 496 } 497 return $items; 498 }, 499 ] ); 500 } 501 502 /** init: wiring for remove_core_readme(). */ 503 public static function maybe_remove_core_readme(): void { 504 self::remove_core_readme( ABSPATH . 'readme.html', (string) get_bloginfo( 'version' ) ); 505 } 506 507 /** 508 * Delete $path once per WordPress version. Every core update (one-click, 509 * WP-CLI or files copied by hand) puts readme.html back and changes the 510 * version, so keying on the version catches all of them for the price of 511 * one autoloaded option read per request, not a file stat. A failed 512 * delete is not recorded, so it is retried. Public for unit testing. 513 */ 514 public static function remove_core_readme( string $path, string $version ): bool { 515 if ( get_option( self::README_OPTION ) === $version ) { 516 return false; 517 } 518 if ( file_exists( $path ) ) { 519 wp_delete_file( $path ); 520 } 521 if ( file_exists( $path ) ) { 522 return false; 523 } 524 update_option( self::README_OPTION, $version, true ); 525 return true; 526 } 527 528 /** pre_option_elementor_meta_generator_tag: '1' is Elementor's "disabled". */ 529 public static function elementor_generator_off(): string { 530 return '1'; 531 } 532 533 /** 534 * script_loader_src / style_loader_src: swap ver=<WordPress version> for a 535 * salted hash of it. Assets enqueued without an explicit version get the 536 * WordPress version by default, so they are masked too. Other versions 537 * (a plugin's own) are left alone. Public for unit testing. 538 */ 539 public static function mask_core_version( mixed $src ): mixed { 540 if ( ! is_string( $src ) || '' === $src ) { 541 return $src; 542 } 543 $version = get_bloginfo( 'version' ); 544 parse_str( (string) wp_parse_url( $src, PHP_URL_QUERY ), $query ); 545 if ( ( $query['ver'] ?? null ) !== $version ) { 546 return $src; 547 } 548 return add_query_arg( 'ver', substr( wp_hash( $version ), 0, 8 ), $src ); 549 } 88 550 } -
clautron/tags/1.5.0/includes/primitives/class-primitive-sitemap.php
r3691441 r3784837 30 30 'category' => 'frontend', 31 31 'phase' => 'compile', 32 'effects' => [], 32 33 'safe' => false, 33 34 'label' => 'Register XML Sitemap', -
clautron/tags/1.5.0/includes/primitives/class-primitive-taxonomy.php
r3582869 r3784837 28 28 'category' => 'taxonomy', 29 29 'phase' => 'runtime', 30 'effects' => [ 'writes_database' ], 30 31 'label' => 'Set Post Terms', 31 32 'description' => 'Assign taxonomy terms to a post. Accepts term IDs, slugs, or names.', … … 72 73 'category' => 'taxonomy', 73 74 'phase' => 'runtime', 75 'effects' => [], 74 76 'label' => 'Get Post Terms', 75 77 'description' => 'Get all terms assigned to a post for a given taxonomy.', … … 117 119 'category' => 'taxonomy', 118 120 'phase' => 'runtime', 121 'effects' => [ 'writes_database' ], 119 122 'label' => 'Create Term', 120 123 'description' => 'Create a new taxonomy term if it does not already exist. Returns the term ID.', … … 176 179 'category' => 'taxonomy', 177 180 'phase' => 'runtime', 181 'effects' => [], 178 182 'label' => 'Term Exists', 179 183 'description' => 'Check if a term exists in a taxonomy. Writes term ID (int) or 0 if not found.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-transform.php
r3582869 r3784837 61 61 'category' => 'data', 62 62 'phase' => 'runtime', 63 'effects' => [], 63 64 'label' => 'Parse JSON', 64 65 'description' => 'Decode a JSON string into an associative array and store it in a context variable.', … … 90 91 'category' => 'data', 91 92 'phase' => 'runtime', 93 'effects' => [], 92 94 'label' => 'To JSON', 93 95 'description' => 'Encode a context variable as a JSON string and store it in another variable.', … … 119 121 'category' => 'data', 120 122 'phase' => 'runtime', 123 'effects' => [], 121 124 'label' => 'Map Fields', 122 125 'description' => 'Remap an array of objects to a new shape. Each output key\'s value is a source field name or a {{expr}} template evaluated per item.', … … 168 171 'category' => 'data', 169 172 'phase' => 'runtime', 173 'effects' => [], 170 174 'label' => 'Filter Array', 171 175 'description' => 'Filter an array of objects by a field condition. Supported operators: eq, neq, gt, gte, lt, lte, contains, not_contains, empty, not_empty.', … … 214 218 'category' => 'data', 215 219 'phase' => 'runtime', 220 'effects' => [], 216 221 'label' => 'Array Pluck', 217 222 'description' => 'Extract a single field from each item in an array, producing a flat array of values.', … … 256 261 'category' => 'data', 257 262 'phase' => 'runtime', 263 'effects' => [], 258 264 'label' => 'Array Merge', 259 265 'description' => 'Merge multiple array variables into one combined array.', … … 290 296 'category' => 'data', 291 297 'phase' => 'runtime', 298 'effects' => [], 292 299 'label' => 'Array Unique', 293 300 'description' => 'Remove duplicate values from a flat array and reindex.', … … 319 326 'category' => 'data', 320 327 'phase' => 'runtime', 328 'effects' => [], 321 329 'label' => 'Array Sort', 322 330 'description' => 'Sort an array. If field is set, sort an array of objects by that field. Otherwise sort a flat array.', … … 375 383 'category' => 'data', 376 384 'phase' => 'runtime', 385 'effects' => [], 377 386 'label' => 'Array Slice', 378 387 'description' => 'Return a portion of an array starting at offset for a given length.', … … 408 417 'category' => 'data', 409 418 'phase' => 'runtime', 419 'effects' => [], 410 420 'label' => 'Count Array', 411 421 'description' => 'Count the number of items in an array variable and store the integer.', … … 437 447 'category' => 'data', 438 448 'phase' => 'runtime', 449 'effects' => [], 439 450 'label' => 'String Format', 440 451 'description' => 'Render a template string with {{variable}} placeholders and store the result.', … … 465 476 'category' => 'data', 466 477 'phase' => 'runtime', 478 'effects' => [], 467 479 'label' => 'String Truncate', 468 480 'description' => 'Truncate a string to a maximum character length, appending a suffix when truncated.', … … 501 513 'category' => 'data', 502 514 'phase' => 'runtime', 515 'effects' => [], 503 516 'label' => 'String Replace', 504 517 'description' => 'Find and replace a substring within a string.', … … 533 546 'category' => 'data', 534 547 'phase' => 'runtime', 548 'effects' => [], 535 549 'label' => 'String Split', 536 550 'description' => 'Split a string by a delimiter into an array. Empty elements are removed.', … … 564 578 'category' => 'data', 565 579 'phase' => 'runtime', 580 'effects' => [], 566 581 'label' => 'String Join', 567 582 'description' => 'Join an array of strings into a single string using a glue separator.', … … 595 610 'category' => 'data', 596 611 'phase' => 'runtime', 612 'effects' => [], 597 613 'label' => 'Date Format', 598 614 'description' => 'Format a date string or timestamp using a PHP date format string.', … … 626 642 'category' => 'data', 627 643 'phase' => 'runtime', 644 'effects' => [], 628 645 'label' => 'Date Diff', 629 646 'description' => 'Calculate the absolute difference between two dates in the requested unit.', … … 675 692 'category' => 'data', 676 693 'phase' => 'runtime', 694 'effects' => [], 677 695 'label' => 'Date Add', 678 696 'description' => 'Add a specified amount of time to a date and format the result.', … … 719 737 'category' => 'data', 720 738 'phase' => 'runtime', 739 'effects' => [], 721 740 'label' => 'Slugify', 722 741 'description' => 'Convert a string to a URL-friendly slug using WordPress sanitize_title().', … … 747 766 'category' => 'data', 748 767 'phase' => 'runtime', 768 'effects' => [], 749 769 'label' => 'Math Calculate', 750 770 'description' => 'Perform a numeric math operation between two values.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-ui.php
r3691441 r3784837 41 41 'category' => 'ui', 42 42 'phase' => 'compile', 43 'effects' => [ 'writes_database' ], 43 44 'label' => 'Add Metabox', 44 45 'description' => 'Add a custom metabox panel to the post edit screen for one or more post types.', … … 109 110 } 110 111 }, 112 'verify' => static function ( array $config ): array { 113 $box_id = sanitize_key( $config['id'] ?? '' ); 114 if ( ! $box_id ) { 115 return []; 116 } 117 // Handler registers with the 'clautron-' prefix (see above) — 118 // the check must look for the same id it was actually 119 // registered under. 120 $mbid = 'clautron-' . $box_id; 121 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 122 $pt = $pts[0] ?? 'post'; 123 if ( ! is_admin() ) { 124 return [ [ 'status' => 'warning', 'label' => "add_metabox:{$mbid}", 'reason' => 'skipped (requires admin context; will work correctly on post edit screens).' ] ]; 125 } 126 $hook_name = 'add_meta_boxes_' . $pt; 127 do_action( $hook_name, null ); 128 do_action( 'add_meta_boxes', $pt, null ); 129 global $wp_meta_boxes; 130 $found = false; 131 foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context_boxes ) { 132 foreach ( $context_boxes as $priority_boxes ) { 133 if ( isset( $priority_boxes[ $mbid ] ) ) { 134 $found = true; 135 break 2; 136 } 137 } 138 } 139 if ( $found ) { 140 return [ [ 'status' => 'passed', 'label' => "add_metabox:{$mbid}" ] ]; 141 } 142 return [ [ 'status' => 'failed', 'label' => "add_metabox:{$mbid}", 'reason' => "not found in wp_meta_boxes for post type '{$pt}'." ] ]; 143 }, 111 144 ] ); 112 145 } … … 118 151 'category' => 'ui', 119 152 'phase' => 'compile', 153 'effects' => [], 120 154 'label' => 'Add Admin Column', 121 155 'description' => 'Add a custom column to the WordPress admin list table for a post type.', … … 215 249 } 216 250 }, 251 'verify' => static function ( array $config ): array { 252 $pts = (array) ( $config['post_types'] ?? [] ); 253 $items = []; 254 foreach ( $pts as $pt ) { 255 $filter = "manage_{$pt}_posts_columns"; 256 if ( has_filter( $filter ) ) { 257 $items[] = [ 'status' => 'passed', 'label' => "add_admin_column:{$pt}" ]; 258 } else { 259 $items[] = [ 'status' => 'failed', 'label' => "add_admin_column:{$pt}", 'reason' => "filter '{$filter}' not hooked." ]; 260 } 261 } 262 return $items; 263 }, 217 264 ] ); 218 265 } … … 224 271 'category' => 'ui', 225 272 'phase' => 'compile', 273 'effects' => [], 226 274 'label' => 'Add Admin Filter', 227 275 'description' => 'Add a custom dropdown filter to the WordPress admin post list table.', … … 325 373 'category' => 'ui', 326 374 'phase' => 'runtime', 375 'effects' => [ 'executes_code' ], 376 'safe' => false, 327 377 'label' => 'Add Admin Notice', 328 378 'description' => 'Display an admin notice (info, success, warning, error).', … … 359 409 'category' => 'ui', 360 410 'phase' => 'compile', 411 'effects' => [ 'writes_database' ], 361 412 'label' => 'Add Bulk Action', 362 413 'description' => 'Add a custom bulk action to a post type list table.', … … 423 474 'category' => 'ui', 424 475 'phase' => 'compile', 476 'effects' => [], 425 477 'label' => 'Add Report Page', 426 478 'description' => 'Add a stats/report section to the Clautron Reports admin page. Renders metric cards with post counts based on configurable queries.', … … 986 1038 'category' => 'ui', 987 1039 'phase' => 'compile', 1040 'effects' => [ 'writes_database' ], 988 1041 'label' => 'Add Row Action', 989 1042 'description' => 'Add a custom action link to post row actions in the list table (e.g. Clone).', … … 1041 1094 } ); 1042 1095 }, 1096 'verify' => static function ( array $config ): array { 1097 $pts = (array) ( $config['post_types'] ?? [] ); 1098 $items = []; 1099 foreach ( $pts as $pt ) { 1100 if ( has_filter( 'post_row_actions' ) || has_filter( "{$pt}_row_actions" ) ) { 1101 $items[] = [ 'status' => 'passed', 'label' => "add_row_action:{$pt}" ]; 1102 } else { 1103 $items[] = [ 'status' => 'warning', 'label' => "add_row_action:{$pt}", 'reason' => 'row action filter not detected (may still work).' ]; 1104 } 1105 } 1106 return $items; 1107 }, 1043 1108 ] ); 1044 1109 } … … 1122 1187 'category' => 'ui', 1123 1188 'phase' => 'compile', 1189 'effects' => [ 'writes_database' ], 1124 1190 'label' => 'Add Options Page', 1125 1191 'description' => 'Add a site-wide settings page under wp-admin (not tied to any single post). Uses the same field types as add_metabox\'s fields template: text, textarea, checkbox, select, radio, image, repeater. Each field is saved as its own option: clautron_opt_{menu_slug}_{field_key}.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-user.php
r3582869 r3784837 32 32 'category' => 'user', 33 33 'phase' => 'runtime', 34 'effects' => [], 34 35 'label' => 'Read User Meta', 35 36 'description' => 'Read a user meta field and store the value in a context variable.', … … 62 63 'category' => 'user', 63 64 'phase' => 'runtime', 65 'effects' => [ 'writes_database' ], 64 66 'label' => 'Write User Meta', 65 67 'description' => 'Write or update a user meta value. All fields support {{variable}} syntax.', … … 103 105 'category' => 'user', 104 106 'phase' => 'runtime', 107 'effects' => [ 'writes_database' ], 105 108 'label' => 'Update User Role', 106 109 'description' => 'Change a user\'s WordPress role. Allowed roles: subscriber, contributor, author, editor. Cannot promote to administrator.', … … 152 155 'category' => 'user', 153 156 'phase' => 'runtime', 157 'effects' => [ 'writes_database', 'deletes_data' ], 154 158 'label' => 'Delete User (Safe)', 155 159 'description' => 'Delete a WordPress user. By default, skipped if the user has any published posts or approved comments. Set force=true to override.', … … 230 234 'category' => 'user', 231 235 'phase' => 'runtime', 236 'effects' => [], 232 237 'label' => 'Get Current User', 233 238 'description' => 'Store the currently logged-in user\'s data (id, login, email, display_name, roles) in a context variable.', … … 270 275 'category' => 'user', 271 276 'phase' => 'runtime', 277 'effects' => [], 272 278 'label' => 'Check User Capability', 273 279 'description' => 'Check if a user has a specific capability. Writes true/false to output_variable.', … … 305 311 'category' => 'user', 306 312 'phase' => 'runtime', 313 'effects' => [], 307 314 'label' => 'Check User Role', 308 315 'description' => 'Check if a user has a specific role. Writes true/false to output_variable.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-webhook.php
r3691441 r3784837 38 38 'category' => 'http', 39 39 'phase' => 'compile', 40 'effects' => [ 'writes_database', 'changes_schema' ], 40 41 'safe' => false, 41 42 'label' => 'Register Payment Webhook', -
clautron/tags/1.5.0/includes/primitives/class-primitive-woocommerce.php
r3691441 r3784837 36 36 'category' => 'woocommerce', 37 37 'phase' => 'runtime', 38 'effects' => [], 38 39 'label' => 'Query Orders', 39 40 'description' => 'Query WooCommerce orders with filters. Returns array of orders (id, status, total, currency, customer_id, customer_email, date_created, items_count) stored in a context variable.', … … 109 110 'category' => 'woocommerce', 110 111 'phase' => 'runtime', 112 'effects' => [], 111 113 'label' => 'Query Products', 112 114 'description' => 'Query WooCommerce products with filters. Returns array of products (id, name, sku, price, regular_price, stock_status, stock_quantity, type, status) stored in a context variable.', … … 180 182 'category' => 'woocommerce', 181 183 'phase' => 'runtime', 184 'effects' => [ 'writes_database', 'external_requests' ], 182 185 'label' => 'Update Order Status', 183 186 'description' => 'Move a WooCommerce order to another standard status (pending, processing, on-hold, completed, cancelled, refunded, failed). Adds an order note attributing the change to the Clautron workflow. Trashing/deleting orders is intentionally not supported.', -
clautron/tags/1.5.0/includes/primitives/class-primitive-wordpress.php
r3691441 r3784837 46 46 'category' => 'wordpress', 47 47 'phase' => 'compile', 48 // A custom capability_type grants its caps to administrator, which 49 // WordPress stores in wp_user_roles: that outlives deactivation. 50 'effects' => static fn( array $c ): array => in_array( trim( explode( ',', (string) ( $c['capability_type'] ?? 'post' ) )[0] ), [ 'post', 'page' ], true ) ? [ 'changes_schema' ] : [ 'changes_schema', 'writes_database' ], 51 'effects_max' => [ 'changes_schema', 'writes_database' ], 48 52 'label' => 'Register Custom Post Type', 49 53 'description' => 'Register a new custom post type in WordPress.', … … 78 82 } ); 79 83 }, 84 'verify' => static function ( array $config ): array { 85 $pt = $config['post_type'] ?? ''; 86 if ( ! $pt ) { 87 return []; 88 } 89 if ( get_post_type_object( $pt ) ) { 90 return [ [ 'status' => 'passed', 'label' => "register_cpt:{$pt}" ] ]; 91 } 92 return [ [ 'status' => 'failed', 'label' => "register_cpt:{$pt}", 'reason' => 'post type not registered.' ] ]; 93 }, 80 94 ] ); 81 95 } … … 89 103 * type they just asked for. Grant the mapped capabilities to 90 104 * administrator; add_cap() is idempotent, safe to call every request. 105 * 106 * Only primitive capabilities. edit_post, read_post and delete_post are 107 * meta capabilities that map_meta_cap() resolves per post; a role must 108 * never hold them literally. WP_Role::has_cap() can't see them either, 109 * so granting them would rewrite wp_user_roles on every request, even 110 * for capability_type "post". 91 111 */ 92 112 public static function grant_cpt_capabilities_to_admin( \WP_Post_Type $post_type_obj ): void { … … 95 115 return; 96 116 } 97 foreach ( (array) $post_type_obj->cap as $cap ) { 117 $primitive = array_diff_key( (array) $post_type_obj->cap, array_flip( [ 'edit_post', 'read_post', 'delete_post' ] ) ); 118 foreach ( $primitive as $cap ) { 98 119 if ( is_string( $cap ) && ! $role->has_cap( $cap ) ) { 99 120 $role->add_cap( $cap ); 100 121 } 101 122 } 123 } 124 125 /** 126 * Effects of giving a post $status: trash removes it from the site, and 127 * publishing makes WordPress ping (update services, pingbacks, 128 * enclosures). An empty status leaves it unchanged. A value with a 129 * {{placeholder}} is only known at run time (and sanitize_key() turns 130 * "{{trash}}" into "trash"), so it gets the worst case. Core re-pinging 131 * when an already published post is saved again is not counted. 132 */ 133 public static function post_status_effects( string $status ): array { 134 if ( str_contains( $status, '{{' ) ) { 135 return [ 'writes_database', 'deletes_data', 'external_requests' ]; 136 } 137 return [ 138 'trash' => [ 'writes_database', 'deletes_data' ], 139 'publish' => [ 'writes_database', 'external_requests' ], 140 ][ sanitize_key( $status ) ] ?? [ 'writes_database' ]; 102 141 } 103 142 … … 142 181 'category' => 'wordpress', 143 182 'phase' => 'compile', 183 'effects' => [ 'changes_schema' ], 144 184 'label' => 'Register Taxonomy', 145 185 'description' => 'Register a custom taxonomy for one or more post types.', … … 167 207 register_taxonomy( $taxonomy, $post_types, self::build_taxonomy_args( $config, $label, $taxonomy ) ); 168 208 } ); 209 }, 210 'verify' => static function ( array $config ): array { 211 $tax = $config['taxonomy'] ?? ''; 212 if ( ! $tax ) { 213 return []; 214 } 215 if ( taxonomy_exists( $tax ) ) { 216 return [ [ 'status' => 'passed', 'label' => "register_taxonomy:{$tax}" ] ]; 217 } 218 return [ [ 'status' => 'failed', 'label' => "register_taxonomy:{$tax}", 'reason' => 'taxonomy not registered.' ] ]; 169 219 }, 170 220 ] ); … … 194 244 'category' => 'wordpress', 195 245 'phase' => 'compile', 246 'effects' => [ 'changes_schema' ], 196 247 'label' => 'Register Meta Field', 197 248 'description' => 'Register a meta field for posts, users, or terms with type and schema.', … … 231 282 } ); 232 283 }, 284 'verify' => static function ( array $config ): array { 285 $key = $config['meta_key'] ?? ''; 286 if ( ! $key ) { 287 return []; 288 } 289 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 290 $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' ); 291 if ( $registered ) { 292 return [ [ 'status' => 'passed', 'label' => "register_meta:{$key}" ] ]; 293 } 294 return [ [ 'status' => 'warning', 'label' => "register_meta:{$key}", 'reason' => 'not found in registered meta (may still work via update_post_meta).' ] ]; 295 }, 233 296 ] ); 234 297 } … … 240 303 'category' => 'wordpress', 241 304 'phase' => 'compile', 305 'effects' => [ 'changes_schema' ], 242 306 'safe' => false, 243 307 'label' => 'Register REST Route', … … 286 350 } ); 287 351 }, 352 'verify' => static function ( array $config ): array { 353 $route = $config['route'] ?? ''; 354 if ( ! $route ) { 355 return []; 356 } 357 $routes = rest_get_server()->get_routes(); 358 $full_path = '/clautron/v1/' . ltrim( $route, '/' ); 359 if ( isset( $routes[ $full_path ] ) ) { 360 return [ [ 'status' => 'passed', 'label' => "register_rest_route:{$route}" ] ]; 361 } 362 return [ [ 'status' => 'warning', 'label' => "register_rest_route:{$route}", 'reason' => 'not found in registered routes (REST server may not be initialized yet).' ] ]; 363 }, 288 364 ] ); 289 365 } … … 295 371 'category' => 'wordpress', 296 372 'phase' => 'compile', 373 'effects' => [ 'writes_database' ], 297 374 'label' => 'Schedule Post Expiry', 298 375 'description' => 'Automatically change post status on its expiry date (stored in meta).', … … 353 430 'category' => 'wordpress', 354 431 'phase' => 'runtime', 432 // The handler only allows draft/publish/pending/private (anything else 433 // becomes draft), so a new post can be published but never trashed. 434 'effects' => static fn( array $c ): array => array_values( array_diff( self::post_status_effects( (string) ( $c['post_status'] ?? 'draft' ) ), [ 'deletes_data' ] ) ), 435 'effects_max' => [ 'writes_database', 'external_requests' ], 436 'safe' => false, 355 437 'label' => 'Create Post', 356 438 'description' => 'Create a new WordPress post (any type / status). Supports deduplication by post meta key + value to avoid creating duplicate posts across job runs.', … … 450 532 'category' => 'wordpress', 451 533 'phase' => 'runtime', 534 'effects' => static fn( array $c ): array => self::post_status_effects( (string) ( $c['post_status'] ?? '' ) ), 535 'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ], 452 536 'label' => 'Update Post', 453 537 'description' => 'Update fields on an existing WordPress post. Only provided fields are changed.', … … 523 607 'category' => 'wordpress', 524 608 'phase' => 'runtime', 609 'effects' => [ 'writes_database', 'deletes_data' ], 525 610 'label' => 'Delete Post', 526 611 'description' => 'Move a post to trash (force=false) or permanently delete it (force=true).', … … 562 647 'category' => 'wordpress', 563 648 'phase' => 'runtime', 649 'effects' => static fn( array $c ): array => self::post_status_effects( (string) ( $c['status'] ?? '' ) ), 650 'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ], 564 651 'label' => 'Set Post Status', 565 652 'description' => 'Change the status of a post (draft, publish, pending, private, trash).', … … 602 689 'category' => 'wordpress', 603 690 'phase' => 'runtime', 691 'effects' => [], 604 692 'label' => 'Check WooCommerce HPOS', 605 693 'description' => 'Detects whether WooCommerce High Performance Order Storage (HPOS) is active. Stores true/false in a context variable. Use with if_else to branch between HPOS and legacy wp_posts order queries.', … … 625 713 'category' => 'wordpress', 626 714 'phase' => 'runtime', 715 'effects' => [ 'writes_database' ], 627 716 'label' => 'Schedule Single Event', 628 717 'description' => 'Schedule a one-time WP-Cron event to fire at a future time. The hook name must start with "clautron_" for safety. The arg is resolved as an integer (post/order ID).', -
clautron/tags/1.5.0/includes/primitives/class-primitive-workflow.php
r3582869 r3784837 28 28 'category' => 'workflow', 29 29 'phase' => 'runtime', 30 'effects' => [], 30 31 'label' => 'Validate Field', 31 32 'description' => 'Validate a value against rules (required, min_length, max_length, email, url, numeric, regex). Writes true/false to output_variable. Writes error message to error_variable if set.', … … 105 106 'category' => 'workflow', 106 107 'phase' => 'runtime', 108 'effects' => [ 'writes_database' ], 107 109 'label' => 'Request Review', 108 110 'description' => 'Flag a post for editorial review by setting its status to \'pending\' and storing a review request note in meta.', … … 144 146 'category' => 'workflow', 145 147 'phase' => 'runtime', 148 'effects' => [ 'writes_database', 'deletes_data', 'external_requests' ], // deletes the review note; publishing pings 146 149 'label' => 'Approve Content', 147 150 'description' => 'Approve content by publishing a pending post and clearing the review note.', … … 177 180 'category' => 'workflow', 178 181 'phase' => 'runtime', 182 'effects' => [ 'writes_database' ], 179 183 'label' => 'Reject Content', 180 184 'description' => 'Reject content by reverting a pending post to draft and storing a rejection reason.', -
clautron/tags/1.5.0/includes/registry/class-primitive-registry.php
r3582869 r3784837 36 36 * @type string $description One-sentence description for the AI prompt catalog. 37 37 * @type string $dependency null | 'woocommerce' | 'acf' | ... 38 * @type callable|null $verify function( array $config ): array — used by 39 * CLAUTRON_Smoke_Tester. Returns zero or more 40 * check items { status: passed|failed|warning, 41 * label: string, reason?: string }. Return [] 42 * when there's nothing meaningful to check for 43 * a given config (e.g. a required field is 44 * empty). Omit entirely (leave null) if this 45 * primitive type has no verification yet — it 46 * will show up as 'unavailable' rather than 47 * being silently skipped. 48 * @type string[]|Closure|null $effects Which of self::EFFECTS the primitive 49 * has, e.g. [ 'writes_database' ]; [] = none. 50 * A Closure( array $config ): string[] when 51 * they depend on config. null (omitted) = 52 * undeclared, which consumers must treat as 53 * "could be anything". An unknown effect name 54 * is reported with _doing_it_wrong() and makes 55 * the primitive undeclared, never "no effect". 56 * @type string[] $effects_max Required with a Closure: every effect any 57 * config can produce. The catalog publishes 58 * this worst case; the abilities still answer 59 * per call from the real config. 60 * @type bool|Closure|null $reversible Whether deactivating removes every 61 * trace. Omitted = derived from effects (no 62 * data written, deleted or sent away). Set it 63 * when something outlives deactivation that 64 * effects can't express (a cookie, HSTS). 65 * @type array[] $examples Example configs. Omitted = the 66 * clautron/primitives-list ability falls back 67 * to configs found in the shipped demos. 38 68 * } 39 69 */ … … 53 83 'description' => '', 54 84 'dependency' => null, 85 'verify' => null, 86 'effects' => null, 87 'effects_max' => null, 88 'reversible' => null, 89 'examples' => [], 55 90 ] ); 56 91 57 92 self::$primitives[ $type ]['type'] = $type; 93 self::check_effects( $type ); 94 } 95 96 /** 97 * A typo in an effect name must not quietly read as "no effect" (and so 98 * as reversible): report it and treat the primitive as undeclared. 99 */ 100 private static function check_effects( string $type ): void { 101 $def = self::$primitives[ $type ]; 102 $unknown = []; 103 foreach ( [ 'effects', 'effects_max' ] as $key ) { 104 if ( is_array( $def[ $key ] ) ) { 105 $unknown = array_merge( $unknown, array_diff( $def[ $key ], self::EFFECTS ) ); 106 } 107 } 108 if ( $unknown ) { 109 self::$primitives[ $type ]['effects'] = null; 110 self::doing_it_wrong( "Primitive '{$type}' declares unknown effects: " . implode( ', ', array_unique( $unknown ) ) . '. Allowed: ' . implode( ', ', self::EFFECTS ) . '.' ); 111 } elseif ( $def['effects'] instanceof Closure && ! is_array( $def['effects_max'] ) ) { 112 self::doing_it_wrong( "Primitive '{$type}' computes its effects from config but declares no effects_max; the catalog will list its effects as unknown." ); 113 } 114 } 115 116 private static function doing_it_wrong( string $message ): void { 117 if ( function_exists( '_doing_it_wrong' ) ) { 118 _doing_it_wrong( 'CLAUTRON_Primitive_Registry::register', esc_html( $message ), '1.5.0' ); 119 } 120 } 121 122 /** 123 * Effect vocabulary, the same keys a capability manifest's `effects` uses 124 * (Capability Contract v1), so a consumer reads both the same way: 125 * writes_database creates or changes content, meta, options, users, 126 * terms, comments, orders, events or transients 127 * writes_files writes or deletes files on disk 128 * deletes_data removes or trashes site data 129 * external_requests sends data off the site (HTTP, email, AI provider), 130 * including what WordPress itself sends because of 131 * it: pings when a post gets published, the post 132 * author's email when a comment is approved 133 * executes_code outputs markup or script taken from its config 134 * changes_schema changes the content model or API surface (post 135 * types, taxonomies, registered meta, REST routes) 136 */ 137 public const EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests', 'executes_code', 'changes_schema' ]; 138 139 /** Effects that make deactivation an incomplete undo when nothing else is declared. */ 140 private const PERSISTENT_EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests' ]; 141 142 /** 143 * Effects of one node, as EFFECTS => bool, or null when the primitive 144 * (or the type itself) is unknown or declares none. 145 */ 146 public static function node_effects( string $type, array $config = [] ): ?array { 147 $def = self::get( $type ); 148 if ( ! $def || null === $def['effects'] ) { 149 return null; 150 } 151 $list = $def['effects'] instanceof Closure ? (array) ( $def['effects'] )( $config ) : (array) $def['effects']; 152 if ( array_diff( $list, self::EFFECTS ) ) { 153 self::doing_it_wrong( "Primitive '{$type}' computed unknown effects: " . implode( ', ', array_diff( $list, self::EFFECTS ) ) . '.' ); 154 return null; 155 } 156 return array_combine( self::EFFECTS, array_map( static fn( string $e ): bool => in_array( $e, $list, true ), self::EFFECTS ) ); 157 } 158 159 /** 160 * Every effect the primitive can have with any config: effects_max for a 161 * config-dependent declaration, the static list otherwise. null when 162 * undeclared or when a Closure has no effects_max. 163 * 164 * @return string[]|null 165 */ 166 public static function max_effects( string $type ): ?array { 167 $def = self::get( $type ); 168 if ( ! $def || null === $def['effects'] ) { 169 return null; 170 } 171 if ( $def['effects'] instanceof Closure ) { 172 return is_array( $def['effects_max'] ) ? array_values( $def['effects_max'] ) : null; 173 } 174 return array_values( (array) $def['effects'] ); 175 } 176 177 /** Whether deactivating undoes the primitive with every config (false when any config can't). */ 178 private static function always_reversible( array $def, ?array $max ): bool { 179 if ( null === $max || $def['reversible'] instanceof Closure ) { 180 return false; 181 } 182 if ( null !== $def['reversible'] ) { 183 return (bool) $def['reversible']; 184 } 185 return ! array_intersect( $max, self::PERSISTENT_EFFECTS ); 186 } 187 188 /** 189 * Combined effects of every node in a blueprint (structures, conditions, 190 * actions), whether deactivating it would undo all of them, and which 191 * node types declare no effects (consumers should treat those as risky). 192 * 193 * @return array{effects: array<string,bool>, reversible: bool, undeclared: string[]} 194 */ 195 public static function effects_of( array $blueprint ): array { 196 $effects = array_fill_keys( self::EFFECTS, false ); 197 $reversible = true; 198 $undeclared = []; 199 200 foreach ( [ 'structures', 'conditions', 'actions' ] as $list ) { 201 foreach ( (array) ( $blueprint[ $list ] ?? [] ) as $node ) { 202 $type = (string) ( $node['type'] ?? '' ); 203 $config = (array) ( $node['config'] ?? [] ); 204 $node_effects = self::node_effects( $type, $config ); 205 if ( null === $node_effects ) { 206 $undeclared[] = $type; 207 $reversible = false; 208 continue; 209 } 210 foreach ( $node_effects as $effect => $has ) { 211 $effects[ $effect ] = $effects[ $effect ] || $has; 212 } 213 $reversible = $reversible && self::node_reversible( $type, $config, $node_effects ); 214 } 215 } 216 217 return [ 'effects' => $effects, 'reversible' => $reversible, 'undeclared' => array_values( array_unique( $undeclared ) ) ]; 218 } 219 220 private static function node_reversible( string $type, array $config, array $effects ): bool { 221 $declared = self::$primitives[ $type ]['reversible']; 222 if ( $declared instanceof Closure ) { 223 return (bool) $declared( $config ); 224 } 225 if ( null !== $declared ) { 226 return (bool) $declared; 227 } 228 return ! array_filter( array_intersect_key( $effects, array_flip( self::PERSISTENT_EFFECTS ) ) ); 58 229 } 59 230 … … 120 291 121 292 /** 122 * Build the capability catalog injected into the AI system prompt. 123 * Returns a lightweight array — type, category, description, schema. 293 * Build the capability catalog injected into the AI system prompt and 294 * returned by the clautron/primitives-list ability. 295 * 296 * `safe` false = needs explicit approval before use; `verifiable` = the 297 * primitive declares a verify check, so a smoke test can confirm it. 298 * `effects` is the worst case over every config (null when undeclared or 299 * unknown) and `reversible` is true only when deactivating undoes it with 300 * every config; `effects_vary` = some config values are milder, so ask 301 * effects_of() for the real config (the abilities return it per call). 124 302 * 125 303 * @return array[] … … 128 306 $catalog = []; 129 307 foreach ( self::$primitives as $type => $def ) { 308 $max = self::max_effects( $type ); 130 309 $catalog[] = [ 131 'type' => $type, 132 'category' => $def['category'], 133 'phase' => $def['phase'], 134 'label' => $def['label'], 135 'description' => $def['description'], 136 'schema' => $def['schema'], 137 'dependency' => $def['dependency'], 310 'type' => $type, 311 'category' => $def['category'], 312 'phase' => $def['phase'], 313 'label' => $def['label'], 314 'description' => $def['description'], 315 'schema' => $def['schema'], 316 'dependency' => $def['dependency'], 317 'safe' => (bool) $def['safe'], 318 'verifiable' => is_callable( $def['verify'] ), 319 'effects' => null === $max ? null : array_values( array_intersect( self::EFFECTS, $max ) ), 320 'effects_vary' => $def['effects'] instanceof Closure || $def['reversible'] instanceof Closure, 321 'reversible' => self::always_reversible( $def, $max ), 322 'examples' => array_values( (array) $def['examples'] ), 138 323 ]; 139 324 } -
clautron/tags/1.5.0/readme.txt
r3691450 r3784837 3 3 Tags: capability, automation, security, no-code, ai 4 4 Requires at least: 7.0 5 Tested up to: 7. 06 Stable tag: 1. 2.05 Tested up to: 7.1 6 Stable tag: 1.5.0 7 7 Requires PHP: 8.1 8 8 License: GPL-2.0-or-later … … 21 21 **One secure layer. Thousands of capabilities.** 22 22 23 Every capability Clautron builds can be activated , deactivated, and rolled back the same predictable way. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes.23 Every capability Clautron builds can be activated and deactivated the same predictable way, without touching any data it wrote while it was on. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes. 24 24 25 25 **Key features:** … … 28 28 * **Search first, generate second:** Clautron checks what already exists before generating anything new; when it does generate something, you review it before it runs 29 29 * **Know what runs on your site:** every capability is built from a declared, inspectable set of primitives 30 * **Reversible by design:** activate, deactivate, or roll back any capability without digging through code 30 * **Deactivate without data loss:** turn any capability off without digging through code. Nothing it wrote while active is touched, and turning it back on reactivates the same capability instead of starting over 31 * **Machine-readable capability manifest:** each capability can declare its own version, requirements, required permissions, and effects (what it reads, writes, or connects to). External tools like AICOM can inspect and manage capabilities through this same contract, without hardcoded per-capability logic 32 * **Know what a capability does before you turn it on:** every building block declares whether it writes data, deletes data, sends data off the site or outputs code, and whether turning it off fully undoes it 33 * **Security hardening without a security plugin:** stop username discovery, add the missing security headers and hide the WordPress version, each as a small capability you can switch off again 34 * **Ready for AI agents:** every Clautron operation is a WordPress Ability with its own schema, so AI agents and tools like AICOM can find and use them without custom integration code 31 35 * **Scheduled jobs and reports:** recurring automations and on-demand data queries, without a separate plugin for each 32 36 * **Safe by construction:** a safety policy layer blocks dangerous operations before they run … … 51 55 = What are primitives, and what's a capability? = 52 56 53 Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate , deactivate, or roll back as one unit.57 Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate or deactivate as one unit. Some capabilities also carry a manifest (a declared version, requirements, and effects) that lets external tools install, inspect, update, and deactivate them without capability-specific code. 54 58 55 59 = Does this plugin store data in the database? = 56 60 57 Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, and execution logs. All data is removed if you uninstall the plugin.61 Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, capability install state, and execution logs. All data is removed if you uninstall the plugin. 58 62 59 63 = What permissions are required? = 60 64 61 Clautron is accessible only to administrators (`manage_options` capability). No frontend functionality or public-facing pages are added to your site.65 Clautron's own screens are only available to administrators (`manage_options` capability). Clautron adds nothing to the frontend by itself: visitors only see changes from capabilities you activate, such as a consent banner, a redirect or security headers. 62 66 63 67 == External Services == … … 75 79 76 80 == Changelog == 81 82 = 1.5.0 = 83 * Added harden_user_enumeration: stops anonymous visitors from discovering usernames. Logged-out requests to the /wp/v2/users REST endpoints get a 401 (logged-in users and the block editor keep working), every author archive returns a 404, the users sitemap is removed, oEmbed shows the site instead of the author, and the login form gives the same error for an unknown username and a wrong password. 84 * Added security_headers: sends Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy, plus Strict-Transport-Security (HSTS) on HTTPS when you opt in. Its check loads your homepage to confirm the headers actually reach visitors, so a page cache or CDN that drops them is reported. 85 * Added hide_wp_version: removes the WordPress, WooCommerce and Elementor generator tags from pages and feeds, and masks the WordPress version in asset URLs while keeping browser caches working. Optionally deletes readme.html, and deletes it again after each WordPress update. 86 * Added a ready-made Security Hardening capability built from these three primitives. 87 * Every Clautron operation (catalog, install, inspect, update, deactivate, blueprints, events) is now a WordPress Ability with its own input schema and behavior annotations, so AI agents and management tools such as AICOM can discover and call them without Clautron-specific code. New: primitive-apply, which applies a single primitive as its own capability, validated and verified. 88 * Every primitive now declares its effects: whether it writes data, writes files, deletes data, sends data off the site, outputs code from its configuration, or changes the content model, and whether turning it off fully undoes it. Installing, applying or ensuring a capability reports these effects, in a dry run too, together with the step that reverses the change. 89 * Fixed ensuring a deactivated custom capability creating a second copy of it: it is now reactivated. 90 * Fixed register_cpt writing the edit_post, read_post and delete_post meta capabilities into the administrator role on every page load. 91 * http_request, fetch_feed, create_post and add_admin_notice are now flagged as needing approval, like the other primitives that can send data off the site, delete data or output code. 92 93 = 1.4.0 = 94 * Added consent_banner: a category-based cookie-consent banner (necessary/analytics/marketing) with an anonymous REST endpoint to record the visitor's choice, a full audit trail (who consented to what, when, from which source) in the events store, and automatic opt-out when the visitor's browser sends the Global Privacy Control (Sec-GPC) signal. 95 * Added gate_script_by_consent: outputs a script or HTML block only once its required consent category has been granted, reading the same cookie consent_banner writes. 96 * consent_banner can emit Google Consent Mode v2 signals (gtag consent default/update), mapping consent categories to Google's ad_storage/analytics_storage/ad_user_data/ad_personalization signals for GA4 and Ads. 97 * Fixed handle_upload's internal failure closure declaring a standalone `false` return type, valid only since PHP 8.2, which fataled on activation despite the plugin declaring Requires PHP 8.1. 98 99 = 1.3.0 = 100 * Added a machine-readable capability manifest (version, platform/PHP/WordPress requirements, required permissions, declared effects, reversibility), kept separate from a capability's blueprint and joined only by capability_id. Lets external tools like AICOM install, inspect, update, and deactivate capabilities through one consistent contract instead of capability-specific code. 101 * Added a new `wp_clautron_capabilities` table tracking each capability's installed version and status separately from its blueprint. This enables comparing installed vs. available version and reinstalling a deactivated capability without losing its edit history. 102 * Added a deactivate operation: stops a capability from being re-applied on future requests without touching any data it wrote while active. Reinstalling afterward reactivates the same capability rather than creating a duplicate. 103 * Added an update operation: re-syncs an installed capability to its manifest's current declared version in place. 104 * Primitive verification (used to confirm a compiled capability is correctly wired into WordPress) is no longer a fixed, hardcoded checklist covering a handful of primitive types: each primitive now declares its own check, and any primitive without one is reported explicitly rather than silently skipped. 105 * Fixed the metabox verification check to look up the actual registered meta box ID (it was checking an ID that never matched, so it could never report success). 77 106 78 107 = 1.2.0 = … … 132 161 == Upgrade Notice == 133 162 163 = 1.5.0 = 164 Adds security hardening primitives, WordPress Abilities for every operation, and declared effects for every primitive. No breaking changes. 165 166 = 1.3.0 = 167 Adds a capability manifest, deactivate/update operations, and per-primitive verification. No breaking changes. 168 134 169 = 1.1.0 = 135 170 Adds Elementor and WordPress widget primitives. No breaking changes. -
clautron/tags/1.5.0/uninstall.php
r3582869 r3784837 16 16 17 17 delete_option( 'clautron_settings' ); 18 delete_option( 'clautron_readme_removed_for' ); // hide_wp_version's remove_readme bookkeeping 18 19 delete_transient( 'clautron_active_workflows' ); -
clautron/trunk/clautron.php
r3691450 r3784837 4 4 * Plugin URI: https://wordpress.org/plugins/clautron/ 5 5 * Description: Get the feature. Not the plugin. Add exactly the WordPress capability you need: inspectable, secure, reversible. 6 * Version: 1. 2.06 * Version: 1.5.0 7 7 * Author: Clautron 8 8 * Author URI: https://profiles.wordpress.org/dudaster/ … … 20 20 // ─── Constants ─────────────────────────────────────────────────────────────── 21 21 22 define( 'CLAUTRON_VERSION', '1. 2.0' );22 define( 'CLAUTRON_VERSION', '1.5.0' ); 23 23 define( 'CLAUTRON_FILE', __FILE__ ); 24 24 define( 'CLAUTRON_DIR', plugin_dir_path( __FILE__ ) ); 25 25 define( 'CLAUTRON_URL', plugin_dir_url( __FILE__ ) ); 26 define( 'CLAUTRON_DB_VERSION', ' 4' );26 define( 'CLAUTRON_DB_VERSION', '5' ); 27 27 28 28 // ─── Autoloader ────────────────────────────────────────────────────────────── … … 121 121 'primitive-cookie' => 'includes/primitives/class-primitive-cookie.php', 122 122 123 // Consent (cookie-consent banner + category-gated script output) 124 'primitive-consent' => 'includes/primitives/class-primitive-consent.php', 125 123 126 // Language (multilingual query filtering) 124 127 'primitive-language' => 'includes/primitives/class-primitive-language.php', … … 157 160 'catalog' => 'includes/class-catalog.php', 158 161 162 // Capability Manifest (machine-readable contract: version, requirements, scopes, effects) 163 'manifest-registry' => 'includes/registry/class-manifest-registry.php', 164 'capability-store' => 'includes/class-capability-store.php', 165 159 166 // Smoke Tester (verify compiled blueprints are wired correctly) 160 167 'smoke-tester' => 'includes/class-smoke-tester.php', 168 169 // Abilities (every operation above as a self-describing WordPress Ability) 170 'abilities' => 'includes/class-abilities.php', 161 171 162 172 // Admin -
clautron/trunk/demo/blueprint-cookie-consent.json
r3691441 r3784837 2 2 "id": "f2a3b4c5-d6e7-4890-b123-c45678901bcd", 3 3 "object_type": "feature", 4 "name": " CookieConsent Banner",5 "description": " Shows a GDPR-style cookie consent banner in the footer with an Accept link. Clicking it stores consent in a first-party cookie for 365 days, which also suppresses the banner on return visits. Replaces a basic cookie-notice plugin.",4 "name": "GDPR Consent Banner", 5 "description": "Cookie-consent banner with per-category consent (necessary/analytics/marketing), a full audit trail of who consented to what and when, automatic opt-out when the visitor's browser sends Global Privacy Control (Sec-GPC), and Google Consent Mode v2 signals for GA4/Ads. Replaces a dedicated cookie-consent plugin for the common case.", 6 6 "capability_id": "cookie-consent-banner", 7 7 "provides": {}, 8 8 "status": "draft", 9 9 "source": "manual", 10 "nl_input": "Show a cookie consent banner on the site with an accept button that remembers the choice for a year.",10 "nl_input": "Show a GDPR cookie consent banner with analytics/marketing categories, honor Global Privacy Control automatically, and signal Google Consent Mode.", 11 11 "structures": [ 12 12 { 13 13 "id": "s-banner", 14 "type": " inject_wp_footer",14 "type": "consent_banner", 15 15 "config": { 16 "template": "<div id=\"clautron-cookie-banner\" style=\"position:fixed;bottom:0;left:0;right:0;background:#222;color:#fff;padding:14px 20px;font-size:14px;z-index:9999;\">This site uses cookies to improve your experience. <a href=\"?clautron_action=accept_cookies\" style=\"color:#8ecdf7;\">Accept</a></div>", 17 "suppress_if_cookie": "cookie_consent" 16 "categories": [ 17 { "key": "necessary", "label": "Necessary", "description": "Required for the site to function.", "required": true }, 18 { "key": "analytics", "label": "Analytics", "description": "Helps us understand site usage." }, 19 { "key": "marketing", "label": "Marketing", "description": "Used for ad personalization." } 20 ], 21 "policy_version": "1", 22 "banner_text": "This site uses cookies. Choose which categories you're OK with.", 23 "position": "bottom", 24 "days": 180, 25 "endpoint_path": "consent", 26 "honor_gpc": true, 27 "consent_mode_map": { 28 "analytics": ["analytics_storage"], 29 "marketing": ["ad_storage", "ad_user_data", "ad_personalization"] 30 } 31 } 32 }, 33 { 34 "id": "s-analytics-script", 35 "type": "gate_script_by_consent", 36 "config": { 37 "category": "analytics", 38 "html": "<!-- Load your analytics script here — it only outputs once \"analytics\" consent is granted -->", 39 "placement": "head" 18 40 } 19 41 } 20 ],21 "trigger": { "type": "hook", "config": { "hook": "template_redirect" } },22 "conditions": [23 { "id": "c1", "type": "read_request_param", "config": { "source": "get", "param": "clautron_action", "output_variable": "action", "default": "" } },24 { "id": "c2", "type": "if_else", "config": { "field": "{{action}}", "operator": "eq", "value": "accept_cookies", "pass_branch": ["a1"], "fail_branch": [] } }25 ],26 "actions": [27 { "id": "a1", "type": "write_cookie", "config": { "name": "cookie_consent", "value": "accepted", "days": 365 } }28 42 ] 29 43 } -
clautron/trunk/includes/class-catalog.php
r3582869 r3784837 9 9 * - Active capabilities (compiled + running on this site). 10 10 * - Available templates (demo blueprints not yet installed). 11 * - inspect(): install/version/compatibility state for one capability_id. 12 * 13 * When a capability_id has a manifest (demo/capabilities/*.manifest.json, 14 * loaded via CLAUTRON_Manifest_Registry), catalog entries carry it under 15 * 'manifest' plus a 'has_manifest' flag — additive, so capabilities without 16 * a manifest keep working exactly as before. 11 17 * 12 18 * External systems (e.g. AICOM) call these methods directly — no HTTP needed. … … 33 39 continue; 34 40 } 41 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null; 42 35 43 $active[] = [ 36 44 'capability_id' => $data['capability_id'], … … 42 50 'provides' => $data['provides'] ?? [], 43 51 'llm_guide' => $data['llm_guide'] ?? '', 52 'manifest' => $manifest, 53 'has_manifest' => $manifest !== null, 44 54 ]; 45 55 } … … 74 84 continue; 75 85 } 86 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null; 87 76 88 $templates[] = [ 77 89 'capability_id' => $data['capability_id'], … … 83 95 'provides' => $data['provides'] ?? [], 84 96 'llm_guide' => $data['llm_guide'] ?? '', 97 'manifest' => $manifest, 98 'has_manifest' => $manifest !== null, 85 99 ]; 86 100 } … … 127 141 } 128 142 129 // Find template file. 130 $demo_dir = CLAUTRON_DIR . 'demo/'; 131 $template = null; 132 foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) { 133 $data = json_decode( file_get_contents( $path ), true ); 134 if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) { 135 $template = $data; 136 break; 137 } 138 } 143 // Known but not active (e.g. deactivated) — reactivate the SAME 144 // blueprint instead of creating a new one, so version history and 145 // the blueprint's own identity survive a deactivate → install cycle. 146 $known = class_exists( 'CLAUTRON_Capability_Store' ) 147 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 148 : null; 149 if ( $known ) { 150 $existing_blueprint_id = (int) $known->blueprint_id; 151 $existing_row = CLAUTRON_Blueprint_Store::get( $existing_blueprint_id ); 152 if ( $existing_row ) { 153 $result = CLAUTRON_Plugin::make_compiler()->compile( $existing_blueprint_id ); 154 if ( ! empty( $result->get_errors() ) ) { 155 return [ 156 'success' => false, 157 'blueprint_id' => $existing_blueprint_id, 158 'compiled' => false, 159 'capability_id' => $capability_id, 160 'error' => implode( '; ', $result->get_errors() ), 161 ]; 162 } 163 self::record_installed_instance( $capability_id, $existing_blueprint_id ); 164 return [ 165 'success' => true, 166 'blueprint_id' => $existing_blueprint_id, 167 'compiled' => true, 168 'capability_id' => $capability_id, 169 'note' => 'Reactivated existing blueprint.', 170 'warnings' => $result->get_warnings(), 171 ]; 172 } 173 // Fall through to a fresh template install if the known 174 // blueprint row itself is gone (shouldn't normally happen). 175 } 176 177 $template = self::find_template_data( $capability_id ); 139 178 140 179 if ( ! $template ) { … … 178 217 } 179 218 219 self::record_installed_instance( $capability_id, $blueprint_id ); 220 180 221 return [ 181 222 'success' => true, … … 187 228 } 188 229 230 /** 231 * Update an already-installed capability to its manifest's current 232 * declared version, in place — same blueprint row, new blueprint_json, 233 * recompiled. Distinct from install(): requires the capability to 234 * already be installed, and requires a manifest (the target version 235 * always comes from the manifest's own 'version' field — there's no 236 * store of multiple historical template versions to pick from). 237 * 238 * @return array { 239 * @type bool $success 240 * @type string $status 'already_current' | 'updated' (only on success) 241 * @type int|null $blueprint_id 242 * @type string|null $from_version 243 * @type string|null $to_version 244 * @type string $error (only on failure) 245 * } 246 */ 247 public static function update( string $capability_id ): array { 248 $installed = class_exists( 'CLAUTRON_Capability_Store' ) 249 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 250 : null; 251 252 if ( ! $installed ) { 253 return [ 254 'success' => false, 255 'error' => "Capability '{$capability_id}' is not installed. Use clautron.catalog.install first.", 256 'code' => 'not_installed', 257 ]; 258 } 259 260 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 261 if ( ! $manifest ) { 262 return [ 263 'success' => false, 264 'error' => "Capability '{$capability_id}' has no manifest, so it has no declared target version to update to.", 265 'code' => 'no_manifest', 266 ]; 267 } 268 269 $from_version = $installed->installed_version; 270 $to_version = $manifest['version']; 271 272 if ( $from_version === $to_version ) { 273 return [ 274 'success' => true, 275 'status' => 'already_current', 276 'blueprint_id' => (int) $installed->blueprint_id, 277 'from_version' => $from_version, 278 'to_version' => $to_version, 279 ]; 280 } 281 282 $template = self::find_template_data( $capability_id ); 283 if ( ! $template ) { 284 return [ 285 'success' => false, 286 'error' => "No template found for capability_id '{$capability_id}'.", 287 'code' => 'template_missing', 288 ]; 289 } 290 291 $blueprint_id = (int) $installed->blueprint_id; 292 $blueprint_row = CLAUTRON_Blueprint_Store::get( $blueprint_id ); 293 if ( ! $blueprint_row ) { 294 return [ 295 'success' => false, 296 'error' => "Installed blueprint (id {$blueprint_id}) no longer exists.", 297 'code' => 'blueprint_missing', 298 ]; 299 } 300 301 // In-place edit: keep the existing blueprint's own UUID — this 302 // updates the same blueprint row, it doesn't create a new one. 303 $template['id'] = $blueprint_row->uuid; 304 305 $validation_blueprint = new CLAUTRON_Blueprint( $template ); 306 $validation = CLAUTRON_Blueprint_Validator::validate( $validation_blueprint ); 307 if ( ! $validation->is_valid() ) { 308 return [ 309 'success' => false, 310 'error' => 'Updated template failed validation: ' . implode( '; ', $validation->get_errors() ), 311 'code' => 'validation_failed', 312 ]; 313 } 314 315 $encoded = wp_json_encode( $template ); 316 $saved = CLAUTRON_Blueprint_Store::update_json( $blueprint_id, $encoded, "Updated to v{$to_version} via Capability Contract" ); 317 if ( ! $saved ) { 318 return [ 319 'success' => false, 320 'error' => 'Failed to save updated blueprint to database.', 321 'code' => 'save_failed', 322 ]; 323 } 324 325 $result = CLAUTRON_Plugin::make_compiler()->compile( $blueprint_id ); 326 if ( ! empty( $result->get_errors() ) ) { 327 return [ 328 'success' => false, 329 'blueprint_id' => $blueprint_id, 330 'from_version' => $from_version, 331 'to_version' => $to_version, 332 'error' => implode( '; ', $result->get_errors() ), 333 'code' => 'compile_failed', 334 ]; 335 } 336 337 self::record_installed_instance( $capability_id, $blueprint_id ); 338 339 return [ 340 'success' => true, 341 'status' => 'updated', 342 'blueprint_id' => $blueprint_id, 343 'from_version' => $from_version, 344 'to_version' => $to_version, 345 'warnings' => $result->get_warnings(), 346 ]; 347 } 348 349 /** 350 * Deactivate an installed capability — stops its compile-phase 351 * primitives from being re-applied on the next request 352 * (CLAUTRON_Runtime_Engine::boot() only replays workflows with 353 * status='active'; CLAUTRON_Compiler::decompile() removes the 354 * workflow row and pauses the blueprint). Never touches data: no 355 * postmeta, terms, or custom table rows written while the capability 356 * was active are read or modified. Always allowed, regardless of 357 * reversibility.strategy — that field describes what a future 358 * `rollback` action would (not) be able to undo, it does not gate 359 * deactivate. 360 * 361 * @return array { 362 * @type bool $success 363 * @type string $status 'deactivated' (only on success) 364 * @type int|null $blueprint_id 365 * @type bool $data_preserved Always true on success. 366 * @type string[] $residual_effects Human-readable notes derived from the manifest's 'effects', if any. 367 * @type string $error (only on failure) 368 * } 369 */ 370 public static function deactivate( string $capability_id ): array { 371 $installed = class_exists( 'CLAUTRON_Capability_Store' ) 372 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 373 : null; 374 375 if ( ! $installed || $installed->status !== 'active' ) { 376 return [ 377 'success' => false, 378 'error' => "Capability '{$capability_id}' is not currently active.", 379 'code' => 'not_active', 380 ]; 381 } 382 383 $blueprint_id = (int) $installed->blueprint_id; 384 385 CLAUTRON_Plugin::make_compiler()->decompile( $blueprint_id ); 386 387 // Covers decompile()'s one confirmed gap: it doesn't flush rewrite 388 // rules, so a register_cpt/register_taxonomy with a custom slug 389 // can leave stale permalinks until the next flush. Cheap enough 390 // for a rare, explicit operation. 391 flush_rewrite_rules( false ); 392 393 CLAUTRON_Capability_Store::update_status( $capability_id, 'paused' ); 394 395 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 396 $residual_effects = self::describe_residual_effects( $manifest['effects'] ?? [] ); 397 398 return [ 399 'success' => true, 400 'status' => 'deactivated', 401 'blueprint_id' => $blueprint_id, 402 'data_preserved' => true, 403 'residual_effects' => $residual_effects, 404 ]; 405 } 406 407 /** 408 * Translate a manifest's declared 'effects' into human-readable notes 409 * about what deactivate does NOT undo — derived from data already 410 * declared on the manifest, no new metadata needed. 411 */ 412 private static function describe_residual_effects( array $effects ): array { 413 $notes = []; 414 if ( ! empty( $effects['writes_database'] ) ) { 415 $notes[] = 'Data written while active (e.g. post meta) is preserved and not reverted.'; 416 } 417 if ( ! empty( $effects['changes_schema'] ) ) { 418 $notes[] = 'Any schema changes made while active (e.g. new fields/columns) are preserved.'; 419 } 420 if ( ! empty( $effects['writes_files'] ) ) { 421 $notes[] = 'Files written while active are preserved on disk.'; 422 } 423 return $notes; 424 } 425 426 /** 427 * Locate a demo template's decoded JSON by capability_id, or null. 428 */ 429 private static function find_template_data( string $capability_id ): ?array { 430 $demo_dir = CLAUTRON_DIR . 'demo/'; 431 foreach ( glob( $demo_dir . '*.json' ) ?: [] as $path ) { 432 $data = json_decode( file_get_contents( $path ), true ); 433 if ( is_array( $data ) && ( $data['capability_id'] ?? '' ) === $capability_id ) { 434 return $data; 435 } 436 } 437 return null; 438 } 439 440 /** 441 * Upsert the installed-instance row (wp_clautron_capabilities) after a 442 * successful install/compile — separate from the generic blueprint row, 443 * see CLAUTRON_Capability_Store. 444 */ 445 private static function record_installed_instance( string $capability_id, int $blueprint_id ): void { 446 if ( ! class_exists( 'CLAUTRON_Capability_Store' ) ) { 447 return; 448 } 449 450 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 451 452 CLAUTRON_Capability_Store::upsert( [ 453 'capability_id' => $capability_id, 454 'blueprint_id' => $blueprint_id, 455 'installed_version' => $manifest['version'] ?? 'unknown', 456 'status' => 'active', 457 'reversibility' => $manifest['reversibility']['strategy'] ?? 'none', 458 ] ); 459 } 460 189 461 // ── Idempotent provisioning ─────────────────────────────────────────────── 190 462 191 463 /** 192 * Ensure a capability exists and is active. Idempotent — safe to call repeatedly. 464 * What ensure() would do for a capability_id, without doing it. ensure() 465 * itself follows this, so a dry run can never disagree with the real call. 193 466 * 194 467 * Resolution order: 195 * 1. Already active with this capability_id → return it unchanged. 196 * 2. Available as a demo template → install it. 197 * 3. blueprint_json provided → create + compile (capability_id is forced into the JSON). 468 * 1. already_active — active with this capability_id: nothing to do. 469 * 2. reactivate — known but deactivated, blueprint still there: 470 * install() recompiles the SAME blueprint. Without 471 * this a custom (non-template) capability would get 472 * a second blueprint every time it was ensured 473 * after a deactivate. 474 * 3. install_template — a demo template: install it. 475 * 4. create — create + compile the caller's blueprint_json. 476 * 477 * @return array{action: string, blueprint_id: ?int} 478 */ 479 public static function ensure_plan( string $capability_id ): array { 480 $cap = self::find_active( $capability_id ); 481 if ( $cap ) { 482 return [ 'action' => 'already_active', 'blueprint_id' => (int) $cap['blueprint_id'] ]; 483 } 484 485 $known = CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ); 486 if ( $known && CLAUTRON_Blueprint_Store::get( (int) $known->blueprint_id ) ) { 487 return [ 'action' => 'reactivate', 'blueprint_id' => (int) $known->blueprint_id ]; 488 } 489 490 if ( in_array( $capability_id, array_column( self::get_templates(), 'capability_id' ), true ) ) { 491 return [ 'action' => 'install_template', 'blueprint_id' => null ]; 492 } 493 494 return [ 'action' => 'create', 'blueprint_id' => null ]; 495 } 496 497 /** 498 * Ensure a capability exists and is active. Idempotent — safe to call 499 * repeatedly. Resolution order: see ensure_plan(). 198 500 * 199 501 * @param string $capability_id Unique slug identifying this capability. … … 201 503 * @return array { 202 504 * @type bool $success 203 * @type string $status 'already_active' | ' installed_from_template' | 'created_and_compiled'505 * @type string $status 'already_active' | 'reactivated' | 'installed_from_template' | 'created_and_compiled' 204 506 * @type string $capability_id 205 507 * @type int $blueprint_id … … 208 510 */ 209 511 public static function ensure( string $capability_id, string $blueprint_json = '' ): array { 210 // ── 1. Already active ─────────────────────────────────────────────────211 $cap = self::find_active( $capability_id ); 212 if ( $cap) {512 $plan = self::ensure_plan( $capability_id ); 513 514 if ( 'already_active' === $plan['action'] ) { 213 515 return [ 214 516 'success' => true, 215 517 'status' => 'already_active', 216 518 'capability_id' => $capability_id, 217 'blueprint_id' => $cap['blueprint_id'], 218 ]; 219 } 220 221 // ── 2. Available as template ────────────────────────────────────────── 222 $templates = self::get_templates(); 223 foreach ( $templates as $tpl ) { 224 if ( $tpl['capability_id'] === $capability_id ) { 225 $result = self::install( $capability_id ); 226 $result['status'] = $result['success'] ? 'installed_from_template' : ( $result['status'] ?? 'error' ); 227 return $result; 228 } 229 } 230 231 // ── 3. Create from provided blueprint_json ──────────────────────────── 519 'blueprint_id' => $plan['blueprint_id'], 520 ]; 521 } 522 523 if ( 'reactivate' === $plan['action'] || 'install_template' === $plan['action'] ) { 524 $result = self::install( $capability_id ); 525 $result['status'] = $result['success'] 526 ? ( 'reactivate' === $plan['action'] ? 'reactivated' : 'installed_from_template' ) 527 : ( $result['status'] ?? 'error' ); 528 return $result; 529 } 530 531 // ── create: from the provided blueprint_json ────────────────────────── 232 532 if ( ! $blueprint_json ) { 233 533 return [ … … 284 584 } 285 585 586 self::record_installed_instance( $capability_id, $blueprint_id ); 587 286 588 return [ 287 589 'success' => true, … … 366 668 } 367 669 670 // ── Inspect ─────────────────────────────────────────────────────────────── 671 672 /** 673 * Contract-level actions that always work for any capability_id, 674 * manifest or not — they're generic lookups, never gated by what a 675 * specific manifest opts into declaring. 676 */ 677 private const ALWAYS_AVAILABLE_ACTIONS = [ 'discover', 'inspect' ]; 678 679 /** 680 * Per-capability lifecycle actions the Capability Contract currently 681 * has a real, working executor for. A manifest may *declare* more 682 * (e.g. "update", "verify", "remove") than the contract can actually 683 * run yet — available_actions only ever advertises the intersection, 684 * so a caller never gets pointed at an action that would just fail. 685 */ 686 private const IMPLEMENTED_LIFECYCLE_ACTIONS = [ 'install', 'update', 'deactivate' ]; 687 688 /** 689 * Resolve current install/version/compatibility state for a capability_id. 690 * 691 * @return array { 692 * @type bool $installed 693 * @type string|null $installed_version 694 * @type string|null $available_version From the manifest, if one exists. 695 * @type bool $compatible True if no unmet requirements (or no manifest to check). 696 * @type string $status not_installed|active|paused|removed 697 * @type string[] $available_actions ALWAYS_AVAILABLE_ACTIONS + (manifest actions ∩ IMPLEMENTED_LIFECYCLE_ACTIONS). 698 * @type string[] $unmet_requirements 699 * } 700 */ 701 public static function inspect( string $capability_id ): array { 702 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $capability_id ) : null; 703 704 $installed_row = class_exists( 'CLAUTRON_Capability_Store' ) 705 ? CLAUTRON_Capability_Store::find_by_capability_id( $capability_id ) 706 : null; 707 708 $unmet = ( $manifest && class_exists( 'CLAUTRON_Manifest_Registry' ) ) 709 ? CLAUTRON_Manifest_Registry::check_requirements( $manifest ) 710 : []; 711 712 $declared_actions = $manifest['actions'] ?? []; 713 $available_actions = array_values( array_unique( array_merge( 714 self::ALWAYS_AVAILABLE_ACTIONS, 715 array_intersect( $declared_actions, self::IMPLEMENTED_LIFECYCLE_ACTIONS ) 716 ) ) ); 717 718 return [ 719 'installed' => $installed_row !== null, 720 'installed_version' => $installed_row->installed_version ?? null, 721 'available_version' => $manifest['version'] ?? null, 722 'compatible' => empty( $unmet ), 723 'status' => $installed_row->status ?? 'not_installed', 724 'available_actions' => $available_actions, 725 'unmet_requirements' => $unmet, 726 ]; 727 } 728 368 729 // ── Helpers ─────────────────────────────────────────────────────────────── 369 730 -
clautron/trunk/includes/class-db.php
r3582869 r3784837 14 14 * 3 — add last_error column to jobs table 15 15 * 4 — add clautron_events time-series table 16 * 5 — add clautron_capabilities table (installed-instance state, separate 17 * from the source/template manifest — see docs/schemas/capability_manifest.json) 16 18 */ 17 19 class CLAUTRON_DB { … … 193 195 ) $charset;" ); 194 196 197 // ── capabilities ────────────────────────────────────────────────────── 198 // Installed-instance state for a capability, separate from the 199 // source/template manifest (demo/capabilities/*.manifest.json) and 200 // from the generic blueprints table, which serves every blueprint 201 // whether or not it's a governed capability. One row per capability_id 202 // currently installed on this site. 203 dbDelta( "CREATE TABLE {$pfx}capabilities ( 204 id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 205 capability_id VARCHAR(191) NOT NULL, 206 blueprint_id BIGINT UNSIGNED NOT NULL, 207 installed_version VARCHAR(20) NOT NULL DEFAULT 'unknown', 208 status VARCHAR(20) NOT NULL DEFAULT 'active', 209 reversibility VARCHAR(10) NOT NULL DEFAULT 'none', 210 installed_at DATETIME NOT NULL, 211 updated_at DATETIME NOT NULL, 212 PRIMARY KEY (id), 213 UNIQUE KEY capability_id (capability_id), 214 KEY blueprint_id (blueprint_id) 215 ) $charset;" ); 216 195 217 // ── In-place migrations for existing installs ───────────────────────── 196 218 // dbDelta reliably creates tables but does NOT add missing columns. … … 203 225 } 204 226 227 // v5: backfill capabilities table for blueprints that were installed 228 // (compiled + active, with a capability_id) before this table existed. 229 // Preserves continuity — nothing about an already-installed capability 230 // changes on upgrade, it just becomes visible to the new inspect()/ 231 // versioning machinery. 232 self::backfill_capabilities(); 233 205 234 update_option( 'clautron_db_version', CLAUTRON_DB_VERSION ); 235 } 236 237 /** 238 * One-time backfill: for every active blueprint that already declares a 239 * capability_id but has no row yet in the capabilities table, insert one. 240 * Safe to call repeatedly — skips capability_ids that already have a row. 241 */ 242 private static function backfill_capabilities(): void { 243 global $wpdb; 244 245 $existing_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT capability_id FROM %i', self::capabilities() ) ); 246 247 $rows = $wpdb->get_results( $wpdb->prepare( 248 'SELECT id, blueprint_json FROM %i WHERE status = %s', 249 self::blueprints(), 250 'active' 251 ) ); 252 253 $now = current_time( 'mysql', true ); 254 255 foreach ( $rows as $row ) { 256 $data = json_decode( $row->blueprint_json, true ); 257 if ( ! is_array( $data ) || empty( $data['capability_id'] ) ) { 258 continue; 259 } 260 if ( in_array( $data['capability_id'], $existing_ids, true ) ) { 261 continue; 262 } 263 264 $manifest = class_exists( 'CLAUTRON_Manifest_Registry' ) ? CLAUTRON_Manifest_Registry::get( $data['capability_id'] ) : null; 265 $installed_version = $manifest['version'] ?? 'unknown'; 266 $reversibility = $manifest['reversibility']['strategy'] ?? 'none'; 267 268 $wpdb->insert( 269 self::capabilities(), 270 [ 271 'capability_id' => $data['capability_id'], 272 'blueprint_id' => (int) $row->id, 273 'installed_version' => $installed_version, 274 'status' => 'active', 275 'reversibility' => $reversibility, 276 'installed_at' => $now, 277 'updated_at' => $now, 278 ], 279 [ '%s', '%d', '%s', '%s', '%s', '%s', '%s' ] 280 ); 281 } 206 282 } 207 283 … … 212 288 global $wpdb; 213 289 $pfx = $wpdb->prefix . 'clautron_'; 214 $tables = [ ' events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ];290 $tables = [ 'capabilities', 'events', 'jobs', 'ai_requests', 'conversations', 'execution_logs', 'workflows', 'versions', 'blueprints' ]; 215 291 foreach ( $tables as $t ) { 216 292 $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $pfx . $t ) ); … … 229 305 public static function jobs(): string { global $wpdb; return $wpdb->prefix . 'clautron_jobs'; } 230 306 public static function events(): string { global $wpdb; return $wpdb->prefix . 'clautron_events'; } 307 public static function capabilities(): string { global $wpdb; return $wpdb->prefix . 'clautron_capabilities'; } 231 308 } -
clautron/trunk/includes/class-plugin.php
r3691441 r3784837 42 42 CLAUTRON_Primitive_Redirect::register_all(); 43 43 CLAUTRON_Primitive_Cookie::register_all(); 44 CLAUTRON_Primitive_Consent::register_all(); 44 45 CLAUTRON_Primitive_Language::register_all(); 45 46 CLAUTRON_Primitive_Request::register_all(); … … 59 60 // ── 3. Boot job runner ─────────────────────────────────────────────── 60 61 CLAUTRON_Job_Runner::boot(); 62 63 // ── 3b. Expose operations as WordPress Abilities ───────────────────── 64 CLAUTRON_Abilities::boot(); 61 65 62 66 // ── 4b. Boot runtime engine ────────────────────────────────────────── -
clautron/trunk/includes/class-smoke-tester.php
r3582869 r3784837 5 5 * Smoke Tester — verifies that a compiled blueprint is correctly wired into WordPress. 6 6 * 7 * Checks are lightweight WordPress-introspection only (no HTTP requests). 8 * Each check inspects the WordPress state that the compile-phase primitives 9 * should have set up: post types, taxonomies, meta boxes, columns, hooks, etc. 7 * Generic executor: each compile-phase structure node is checked by its own 8 * primitive's declared `verify` callable (CLAUTRON_Primitive_Registry). A 9 * primitive type with no `verify` declared lands in `unavailable`, not 10 * silently dropped — see the primitive's own file for its check, and 11 * CLAUTRON_Primitive_Registry::register()'s docblock for the `verify` 12 * contract. Part of the AICOM-Clautron Capability Contract v1. 10 13 */ 11 14 class CLAUTRON_Smoke_Tester { … … 20 23 * @type string[] $failed 21 24 * @type string[] $warnings 25 * @type string[] $unavailable Structures whose primitive type has no declared verify check. 22 26 * @type string $summary 23 27 * } … … 26 30 $row = CLAUTRON_Blueprint_Store::get( $blueprint_id ); 27 31 if ( ! $row ) { 28 return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], 0, 0);32 return self::result( [], [], [ "Blueprint {$blueprint_id} not found." ], [] ); 29 33 } 30 34 31 35 $data = json_decode( $row->blueprint_json, true ); 32 36 if ( ! is_array( $data ) ) { 33 return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], 0, 0);37 return self::result( [], [], [ 'blueprint_json is not valid JSON.' ], [] ); 34 38 } 35 39 36 40 if ( $row->status !== 'active' ) { 37 return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], 0, 0);41 return self::result( [], [], [ "Blueprint is '{$row->status}', not active — compile it first." ], [] ); 38 42 } 39 43 40 $passed = []; 41 $failed = []; 42 $warnings = []; 44 $passed = []; 45 $failed = []; 46 $warnings = []; 47 $unavailable = []; 43 48 44 49 // ── Check trigger ────────────────────────────────────────────────────── 50 // Not a primitive — the blueprint's own trigger field, checked directly. 45 51 $trigger = $data['trigger'] ?? null; 46 52 if ( $trigger ) { … … 59 65 $id = $node['id'] ?? $type; 60 66 61 switch ( $type ) {67 $def = class_exists( 'CLAUTRON_Primitive_Registry' ) ? CLAUTRON_Primitive_Registry::get( $type ) : null; 62 68 63 case 'register_cpt': { 64 $pt = $config['post_type'] ?? ''; 65 if ( $pt && get_post_type_object( $pt ) ) { 66 $passed[] = "register_cpt:{$pt}"; 67 } elseif ( $pt ) { 68 $failed[] = "register_cpt:{$pt} — post type not registered."; 69 } 70 break; 71 } 69 if ( ! $def ) { 70 $unavailable[] = "{$type}:{$id} — primitive type not registered."; 71 continue; 72 } 72 73 73 case 'register_taxonomy': { 74 $tax = $config['taxonomy'] ?? ''; 75 if ( $tax && taxonomy_exists( $tax ) ) { 76 $passed[] = "register_taxonomy:{$tax}"; 77 } elseif ( $tax ) { 78 $failed[] = "register_taxonomy:{$tax} — taxonomy not registered."; 79 } 80 break; 81 } 74 if ( empty( $def['verify'] ) || ! is_callable( $def['verify'] ) ) { 75 $unavailable[] = "{$type}:{$id} — no verification declared for this primitive type."; 76 continue; 77 } 82 78 83 case 'register_meta': { 84 $key = $config['meta_key'] ?? ''; 85 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 86 if ( $key ) { 87 $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' ); 88 if ( $registered ) { 89 $passed[] = "register_meta:{$key}"; 90 } else { 91 $warnings[] = "register_meta:{$key} — not found in registered meta (may still work via update_post_meta)."; 92 } 93 } 94 break; 95 } 79 $items = call_user_func( $def['verify'], $config ); 80 foreach ( (array) $items as $item ) { 81 $label = $item['label'] ?? "{$type}:{$id}"; 82 $reason = $item['reason'] ?? null; 83 $line = $reason ? "{$label} — {$reason}" : $label; 96 84 97 case 'add_metabox': { 98 $mbid = $config['id'] ?? ''; 99 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 100 $pt = $pts[0] ?? 'post'; 101 // Meta boxes are only registered in admin context. 102 if ( ! is_admin() ) { 103 $warnings[] = "add_metabox:{$mbid} — skipped (requires admin context; will work correctly on post edit screens)."; 85 switch ( $item['status'] ?? 'warning' ) { 86 case 'passed': 87 $passed[] = $line; 104 88 break; 105 } 106 $hook_name = 'add_meta_boxes_' . $pt; 107 do_action( $hook_name, null ); 108 do_action( 'add_meta_boxes', $pt, null ); 109 global $wp_meta_boxes; 110 $found = false; 111 foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context ) { 112 foreach ( $context as $priority ) { 113 if ( isset( $priority[ $mbid ] ) ) { 114 $found = true; 115 break 2; 116 } 117 } 118 } 119 if ( $found ) { 120 $passed[] = "add_metabox:{$mbid}"; 121 } else { 122 $failed[] = "add_metabox:{$mbid} — not found in wp_meta_boxes for post type '{$pt}'."; 123 } 124 break; 125 } 126 127 case 'add_admin_column': { 128 $pts = (array) ( $config['post_types'] ?? [] ); 129 foreach ( $pts as $pt ) { 130 $filter = "manage_{$pt}_posts_columns"; 131 if ( has_filter( $filter ) ) { 132 $passed[] = "add_admin_column:{$pt}"; 133 } else { 134 $failed[] = "add_admin_column:{$pt} — filter '{$filter}' not hooked."; 135 } 136 } 137 break; 138 } 139 140 case 'inject_wp_head': { 141 $priority = (int) ( $config['priority'] ?? 5 ); 142 if ( has_action( 'wp_head' ) ) { 143 $passed[] = "inject_wp_head (priority:{$priority})"; 144 } else { 145 $failed[] = 'inject_wp_head — no actions registered on wp_head.'; 146 } 147 break; 148 } 149 150 case 'inject_wp_footer': { 151 if ( has_action( 'wp_footer' ) ) { 152 $passed[] = 'inject_wp_footer'; 153 } else { 154 $failed[] = 'inject_wp_footer — no actions registered on wp_footer.'; 155 } 156 break; 157 } 158 159 case 'register_rest_route': { 160 $route = $config['route'] ?? ''; 161 if ( $route ) { 162 $routes = rest_get_server()->get_routes(); 163 $full_path = '/clautron/v1/' . ltrim( $route, '/' ); 164 if ( isset( $routes[ $full_path ] ) ) { 165 $passed[] = "register_rest_route:{$route}"; 166 } else { 167 $warnings[] = "register_rest_route:{$route} — not found in registered routes (REST server may not be initialized yet)."; 168 } 169 } 170 break; 171 } 172 173 case 'add_row_action': { 174 $pts = (array) ( $config['post_types'] ?? [] ); 175 foreach ( $pts as $pt ) { 176 $filter = "post_row_actions"; 177 if ( has_filter( $filter ) || has_filter( "{$pt}_row_actions" ) ) { 178 $passed[] = "add_row_action:{$pt}"; 179 } else { 180 $warnings[] = "add_row_action:{$pt} — row action filter not detected (may still work)."; 181 } 182 } 183 break; 184 } 185 186 case 'gate_publish': { 187 if ( has_action( 'admin_notices' ) ) { 188 $passed[] = 'gate_publish'; 189 } else { 190 $warnings[] = 'gate_publish — admin_notices hook not detected in current context.'; 191 } 192 break; 89 case 'failed': 90 $failed[] = $line; 91 break; 92 default: 93 $warnings[] = $line; 94 break; 193 95 } 194 96 } 195 97 } 196 98 197 $total = count( $passed ) + count( $failed ); 198 199 return self::result( $passed, $failed, $warnings, count( $passed ), $total ); 99 return self::result( $passed, $failed, $warnings, $unavailable ); 200 100 } 201 101 … … 206 106 array $failed, 207 107 array $warnings, 208 int $pass_count, 209 int $total 108 array $unavailable 210 109 ): array { 110 $pass_count = count( $passed ); 111 $total = $pass_count + count( $failed ); 112 211 113 if ( $total === 0 ) { 212 $summary = 'No checkable structures found.';114 $summary = empty( $unavailable ) ? 'No checkable structures found.' : 'No checks could run.'; 213 115 } elseif ( empty( $failed ) ) { 214 116 $summary = "All checks passed ({$pass_count}/{$total})."; … … 218 120 } 219 121 122 if ( ! empty( $unavailable ) ) { 123 $summary .= ' ' . count( $unavailable ) . ' structure(s) have no verification declared.'; 124 } 125 220 126 return [ 221 'passed' => $passed, 222 'failed' => $failed, 223 'warnings' => $warnings, 224 'summary' => $summary, 127 'passed' => $passed, 128 'failed' => $failed, 129 'warnings' => $warnings, 130 'unavailable' => $unavailable, 131 'summary' => $summary, 225 132 ]; 226 133 } -
clautron/trunk/includes/primitives/class-primitive-ai.php
r3691441 r3784837 27 27 'category' => 'ai', 28 28 'phase' => 'runtime', 29 'effects' => [ 'external_requests' ], 29 30 'label' => 'AI Generate', 30 31 'description' => 'Generate text with the WordPress AI Client (site-configured provider) and store the result in a context variable. Use for excerpts, meta descriptions, alt text, translations, summaries. Prompt supports {{variable}} placeholders. Output is plain text only.', -
clautron/trunk/includes/primitives/class-primitive-comments.php
r3691441 r3784837 29 29 'category' => 'comments', 30 30 'phase' => 'runtime', 31 'effects' => [], 31 32 'label' => 'Query Comments', 32 33 'description' => 'Query WordPress comments with filters. Returns array of comments (id, post_id, author, author_email, content, status, date, parent_id) stored in a context variable.', … … 85 86 'category' => 'comments', 86 87 'phase' => 'runtime', 88 // approve: core emails the post author (wp_set_comment_status → wp_new_comment_notify_postauthor). 89 'effects' => static fn( array $c ): array => [ 'trash' => [ 'writes_database', 'deletes_data' ], 'approve' => [ 'writes_database', 'external_requests' ] ][ $c['status'] ?? '' ] ?? [ 'writes_database' ], 90 'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ], 87 91 'label' => 'Moderate Comment', 88 92 'description' => 'Change a comment\'s moderation status. Allowed statuses: approve, hold, spam, trash. Hard deletion is intentionally not supported.', … … 123 127 'category' => 'comments', 124 128 'phase' => 'runtime', 129 'effects' => [ 'writes_database' ], 125 130 'label' => 'Create Comment', 126 131 'description' => 'Insert a comment (or a threaded reply via parent_id) on a post. Content supports {{variable}} placeholders.', -
clautron/trunk/includes/primitives/class-primitive-cookie.php
r3691441 r3784837 37 37 'category' => 'data', 38 38 'phase' => 'runtime', 39 'effects' => [], 40 'reversible' => false, 39 41 'safe' => false, 40 42 'label' => 'Write Cookie', … … 83 85 'category' => 'data', 84 86 'phase' => 'runtime', 87 'effects' => [], 85 88 'label' => 'Read Cookie', 86 89 'description' => 'Read a clautron_-prefixed cookie into a workflow variable. Resolves to null if the cookie is not set.', -
clautron/trunk/includes/primitives/class-primitive-data.php
r3582869 r3784837 36 36 'category' => 'data', 37 37 'phase' => 'runtime', 38 'effects' => [], 38 39 'label' => 'Read Post Field', 39 40 'description' => 'Read a field from the current post (ID, title, content, status, author, etc.) and store it in a variable.', … … 85 86 'category' => 'data', 86 87 'phase' => 'runtime', 88 'effects' => [ 'writes_database' ], 87 89 'label' => 'Write Meta', 88 90 'description' => 'Write a meta value to a post, user, or term.', … … 122 124 'category' => 'data', 123 125 'phase' => 'runtime', 126 'effects' => [], 124 127 'label' => 'Read Meta', 125 128 'description' => 'Read a meta value from a post, user, or term and store it in a variable.', … … 161 164 'category' => 'data', 162 165 'phase' => 'runtime', 166 'effects' => [ 'writes_database' ], 163 167 'safe' => false, 164 168 'label' => 'Write Option', … … 187 191 'category' => 'data', 188 192 'phase' => 'runtime', 193 'effects' => [], 189 194 'label' => 'Read Option', 190 195 'description' => 'Read a WordPress option and store it in a variable.', … … 324 329 'category' => 'wordpress', 325 330 'phase' => 'compile', 331 'effects' => [ 'writes_database' ], 326 332 'label' => 'Gate Publish', 327 333 'description' => 'Block or warn when publishing a post that fails completeness checks.', … … 407 413 echo '</p></div>'; 408 414 } ); 415 }, 416 'verify' => static function ( array $config ): array { 417 if ( has_action( 'admin_notices' ) ) { 418 return [ [ 'status' => 'passed', 'label' => 'gate_publish' ] ]; 419 } 420 return [ [ 'status' => 'warning', 'label' => 'gate_publish', 'reason' => 'admin_notices hook not detected in current context.' ] ]; 409 421 }, 410 422 ] ); … … 478 490 'category' => 'data', 479 491 'phase' => 'runtime', 492 'effects' => [ 'writes_database' ], 480 493 'label' => 'Check Fields Completeness', 481 494 'description' => 'Evaluate a list of post fields and calculate a completeness score (0-100). Saves the score to a meta key and an output variable.', … … 563 576 'category' => 'data', 564 577 'phase' => 'runtime', 578 'effects' => [], 565 579 'label' => 'Run Report', 566 580 'description' => 'Execute a saved report blueprint and store its result rows in a context variable. Use with foreach_query_result to process each row.', -
clautron/trunk/includes/primitives/class-primitive-elementor.php
r3592659 r3784837 39 39 'category' => 'elementor', 40 40 'phase' => 'compile', 41 'effects' => [ 'executes_code' ], 41 42 'label' => 'Elementor Widget', 42 43 'description' => 'Create a custom Elementor widget with content controls, style tab sections, and a server-rendered HTML template. Appears under the "Clautron" category in the Elementor panel.', … … 97 98 'category' => 'wordpress', 98 99 'phase' => 'compile', 100 'effects' => [ 'executes_code' ], 99 101 'label' => 'WordPress Widget', 100 102 'description' => 'Register a native WordPress sidebar widget (WP_Widget) usable in Appearance > Widgets and any theme sidebar. Supports text, textarea, number, url, color, checkbox, and select controls. Use {{key}} (escaped), {{{key}}} (HTML), {{key.url}} (URL), and {{#if key}}...{{/if}} in the template.', -
clautron/trunk/includes/primitives/class-primitive-email.php
r3691441 r3784837 20 20 'category' => 'communication', 21 21 'phase' => 'runtime', 22 'effects' => [ 'external_requests' ], 22 23 'label' => 'Send Email', 23 24 'description' => 'Send an email via wp_mail(). All fields support {{variable}} template syntax.', … … 69 70 'category' => 'communication', 70 71 'phase' => 'runtime', 72 'effects' => [ 'external_requests' ], 71 73 'label' => 'Notify User', 72 74 'description' => 'Send a notification email to a WordPress user by their user ID. Subject and body support {{variable}} template syntax.', … … 129 131 'category' => 'communication', 130 132 'phase' => 'compile', 133 'effects' => [ 'external_requests' ], 131 134 'safe' => false, 132 135 'label' => 'Configure SMTP', -
clautron/trunk/includes/primitives/class-primitive-events.php
r3691441 r3784837 32 32 'category' => 'events', 33 33 'phase' => 'runtime', 34 'effects' => [ 'writes_database' ], 34 35 'label' => 'Track Event', 35 36 'description' => 'Write one event to the clautron_events time-series store. Use this inside any blueprint trigger to record that something happened (a pageview, a login failure, an order, a cron run, etc.).', … … 78 79 'category' => 'events', 79 80 'phase' => 'runtime', 81 'effects' => [], 80 82 'label' => 'Query Events', 81 83 'description' => 'Read raw event rows from the clautron_events store. Results are stored in output_variable for use in subsequent primitives (foreach_query_result, send_email, etc.).', … … 120 122 'category' => 'events', 121 123 'phase' => 'runtime', 124 'effects' => [], 122 125 'label' => 'Aggregate Events', 123 126 'description' => 'Group and aggregate event data: total counts, sums, averages, grouped by time period or object. Returns an array of {period, value} rows in output_variable.', … … 160 163 'category' => 'events', 161 164 'phase' => 'runtime', 165 'effects' => [ 'writes_database', 'deletes_data' ], 162 166 'label' => 'Purge Events', 163 167 'description' => 'Delete old events for retention management. Use inside a recurring job (e.g. weekly). Returns the count of deleted rows in output_variable.', … … 201 205 'category' => 'events', 202 206 'phase' => 'compile', 207 'effects' => [ 'writes_database' ], 203 208 'safe' => false, 204 209 'label' => 'Log All Outgoing Email', … … 256 261 'category' => 'events', 257 262 'phase' => 'compile', 263 'effects' => [ 'writes_database' ], 258 264 'safe' => false, 259 265 'label' => 'Log Site Activity', -
clautron/trunk/includes/primitives/class-primitive-filter.php
r3691441 r3784837 33 33 'category' => 'frontend', 34 34 'phase' => 'compile', 35 'effects' => [], 35 36 'label' => 'Filter Query by Request Parameters', 36 37 'description' => 'Restrict frontend main queries (archives, search, home) using one or more filter rules read from GET parameters: taxonomy facets, exact meta match, or numeric meta range. Built for shop/archive filter sidebars (category, in-stock, price range). Never touches wp-admin or REST requests.', -
clautron/trunk/includes/primitives/class-primitive-frontend.php
r3691441 r3784837 26 26 'category' => 'frontend', 27 27 'phase' => 'compile', 28 'effects' => [ 'executes_code' ], 28 29 'label' => 'Inject into <head>', 29 30 'description' => 'Output HTML into wp_head on singular post/page views. Supports {{post.field}}, {{site.field}}, and {{_meta_key}} placeholders. Optionally outputs a Schema.org JSON-LD block via json_ld config.', … … 118 119 }, $priority ); 119 120 }, 121 'verify' => static function ( array $config ): array { 122 $priority = (int) ( $config['priority'] ?? 5 ); 123 if ( has_action( 'wp_head' ) ) { 124 return [ [ 'status' => 'passed', 'label' => "inject_wp_head (priority:{$priority})" ] ]; 125 } 126 return [ [ 'status' => 'failed', 'label' => 'inject_wp_head', 'reason' => 'no actions registered on wp_head.' ] ]; 127 }, 120 128 ] ); 121 129 } … … 127 135 'category' => 'frontend', 128 136 'phase' => 'compile', 137 'effects' => [ 'executes_code' ], 129 138 'label' => 'Inject before </body>', 130 139 'description' => 'Output HTML into wp_footer. Same {{variable}} resolution as inject_wp_head (post.*, site.*, meta keys). Leave post_types empty to render on every frontend page (banners, popups, share widgets) — set it to restrict to singular views of those post types, where {{post.*}}/meta-key tokens resolve against the viewed post. Set suppress_if_cookie to hide the whole block once a clautron_-prefixed cookie (e.g. one set by write_cookie) has any non-empty value — the standard pattern for "don\'t show this again after accept/dismiss".', … … 180 189 }, $priority ); 181 190 }, 191 'verify' => static function ( array $config ): array { 192 if ( has_action( 'wp_footer' ) ) { 193 return [ [ 'status' => 'passed', 'label' => 'inject_wp_footer' ] ]; 194 } 195 return [ [ 'status' => 'failed', 'label' => 'inject_wp_footer', 'reason' => 'no actions registered on wp_footer.' ] ]; 196 }, 182 197 ] ); 183 198 } … … 189 204 'category' => 'frontend', 190 205 'phase' => 'compile', 206 'effects' => [ 'executes_code' ], 191 207 'label' => 'Register Shortcode', 192 208 'description' => 'Register a WordPress [shortcode] rendered from an HTML template. Attributes become template tokens: {{attr}} escaped text, {{{attr}}} sanitized HTML, {{attr.url}} URL, {{#if attr}}...{{/if}} conditionals. Enclosed content is available as {{{content}}}.', … … 272 288 'category' => 'frontend', 273 289 'phase' => 'compile', 290 'effects' => [], 274 291 'label' => 'Enqueue Registered Asset', 275 292 'description' => 'Load CSS/JS assets that are ALREADY registered on the site (by core, the theme, or plugins) onto frontend pages, by handle. Never loads external URLs or new code — only activates assets the site already ships (e.g. dashicons, wp-block-library, swiper, theme handles). Use widget/shortcode templates with the classes those assets provide.', -
clautron/trunk/includes/primitives/class-primitive-http.php
r3582869 r3784837 24 24 'category' => 'http', 25 25 'phase' => 'runtime', 26 'effects' => [ 'external_requests' ], 27 'safe' => false, 26 28 'label' => 'HTTP Request', 27 29 'description' => 'Send an HTTP request (GET, POST, PUT, DELETE, PATCH) and store the response body and status code in context variables.', … … 112 114 'category' => 'http', 113 115 'phase' => 'runtime', 116 'effects' => [ 'external_requests', 'writes_database' ], // SimplePie caches the feed in transients 117 'safe' => false, 114 118 'label' => 'Fetch RSS/Atom Feed', 115 119 'description' => 'Fetch an RSS or Atom feed and store items as an array of {title, link, description, pubdate} objects.', 116 'safe' => true,117 120 'schema' => [ 118 121 'url' => [ 'type' => 'string', 'required' => true, 'description' => 'Feed URL. Supports {{variable}}.' ], … … 170 173 'category' => 'http', 171 174 'phase' => 'runtime', 175 'effects' => [ 'external_requests' ], 172 176 'label' => 'Send Webhook', 173 177 'description' => 'POST a JSON payload to a webhook URL. Supports {{variable}} in URL and payload values.', -
clautron/trunk/includes/primitives/class-primitive-language.php
r3691441 r3784837 29 29 'category' => 'frontend', 30 30 'phase' => 'compile', 31 'effects' => [], 31 32 'label' => 'Filter Query by Language', 32 33 'description' => 'Restrict frontend main queries (archives, search, home, singular) to posts tagged with one term of a language taxonomy. Language is resolved from a URL query var, falling back to a cookie, falling back to a configured default. Never touches wp-admin or REST requests.', -
clautron/trunk/includes/primitives/class-primitive-logic.php
r3582869 r3784837 30 30 'category' => 'logic', 31 31 'phase' => 'runtime', 32 'effects' => [], 32 33 'label' => 'If / Else', 33 34 'description' => 'Evaluate a condition. Execute pass_branch nodes if true, fail_branch nodes if false.', … … 74 75 'category' => 'logic', 75 76 'phase' => 'runtime', 77 'effects' => [], 76 78 'label' => 'Stop Execution', 77 79 'description' => 'Halt workflow execution immediately. No further nodes will run.', … … 89 91 'category' => 'logic', 90 92 'phase' => 'runtime', 93 'effects' => [], 91 94 'label' => 'Set Variable', 92 95 'description' => 'Assign a literal or resolved value to a context variable for use in later nodes.', … … 111 114 'category' => 'logic', 112 115 'phase' => 'runtime', 116 'effects' => [], 113 117 'label' => 'For Each Row', 114 118 'description' => 'Iterate over rows from a query result stored in a context variable. Each row\'s columns become available as {{row.column_name}} for the body nodes.', … … 181 185 'category' => 'logic', 182 186 'phase' => 'runtime', 187 'effects' => [], 183 188 'label' => 'Switch / Case', 184 189 'description' => 'Evaluate a value against multiple cases and execute the matching branch. Falls through to default_branch if no case matches.', -
clautron/trunk/includes/primitives/class-primitive-media.php
r3691441 r3784837 27 27 'category' => 'media', 28 28 'phase' => 'runtime', 29 'effects' => [ 'writes_database', 'writes_files', 'external_requests' ], 29 30 'label' => 'Create Media from URL', 30 31 'description' => 'Download an image from a URL and add it to the WordPress media library. Returns the new attachment ID.', … … 81 82 'category' => 'media', 82 83 'phase' => 'runtime', 84 'effects' => [ 'writes_database' ], 83 85 'label' => 'Set Featured Image', 84 86 'description' => 'Set the featured image (thumbnail) of a post to a given attachment ID.', … … 120 122 'category' => 'media', 121 123 'phase' => 'runtime', 124 'effects' => [], 122 125 'label' => 'Get Image URL', 123 126 'description' => 'Get the URL of an attachment image at a given size.', … … 150 153 'category' => 'media', 151 154 'phase' => 'runtime', 155 'effects' => [ 'writes_database', 'writes_files' ], 152 156 'safe' => false, 153 157 'label' => 'Handle File Upload', … … 162 166 'handler' => static function ( array $config, CLAUTRON_Workflow_Context $ctx ): int|false { 163 167 $out_var = $config['output_variable'] ?? 'uploaded_attachment_id'; 164 $fail = static function () use ( $ctx, $out_var ): false{168 $fail = static function () use ( $ctx, $out_var ): bool { 165 169 if ( $out_var ) { 166 170 $ctx->write_variable( $out_var, false ); -
clautron/trunk/includes/primitives/class-primitive-query.php
r3691441 r3784837 32 32 'category' => 'query', 33 33 'phase' => 'runtime', 34 'effects' => [], 34 35 'label' => 'Query Posts', 35 36 'description' => 'Query WordPress posts with flexible filters. Returns array of post objects (id, title, content, excerpt, status, type, author, date, permalink) stored in a context variable.', … … 97 98 'category' => 'query', 98 99 'phase' => 'runtime', 100 'effects' => [], 99 101 'label' => 'Get Post', 100 102 'description' => 'Fetch a single post by ID and store its fields in a context variable.', … … 133 135 'category' => 'query', 134 136 'phase' => 'runtime', 137 'effects' => [], 135 138 'label' => 'Count Posts', 136 139 'description' => 'Count posts matching given criteria. Writes integer to output_variable.', … … 191 194 'category' => 'query', 192 195 'phase' => 'runtime', 196 'effects' => [], 193 197 'label' => 'Query Users', 194 198 'description' => 'Query WordPress users with flexible filters. Returns array of user objects.', … … 246 250 'category' => 'query', 247 251 'phase' => 'runtime', 252 'effects' => [], 248 253 'label' => 'Get User', 249 254 'description' => 'Fetch a single user by ID and store their data in a context variable.', … … 282 287 'category' => 'query', 283 288 'phase' => 'runtime', 289 'effects' => [], 284 290 'label' => 'Query Terms', 285 291 'description' => 'Query taxonomy terms. Returns array of term objects.', -
clautron/trunk/includes/primitives/class-primitive-redirect.php
r3691441 r3784837 20 20 'category' => 'frontend', 21 21 'phase' => 'compile', 22 'effects' => [], 23 // Browsers cache permanent redirects indefinitely; removing the rule doesn't reach them. 24 'reversible' => static fn( array $c ): bool => ! in_array( (int) ( $c['status'] ?? 301 ), [ 301, 308 ], true ), 22 25 'label' => 'Redirect URL', 23 26 'description' => 'Redirect requests from one path to another. Refuses redirects that would break wp-admin/login/REST access or create a loop.', -
clautron/trunk/includes/primitives/class-primitive-request.php
r3691441 r3784837 25 25 'category' => 'data', 26 26 'phase' => 'runtime', 27 'effects' => [], 27 28 'safe' => false, 28 29 'label' => 'Read Request Parameter', -
clautron/trunk/includes/primitives/class-primitive-security.php
r3691441 r3784837 3 3 4 4 /** 5 * Security Primitives — abuse throttling .5 * Security Primitives — abuse throttling and information-disclosure hardening. 6 6 * 7 7 * Registered primitives: 8 * - rate_limit_check Fixed-window counter (transient-backed) keyed by an 9 * identifier (defaults to client IP). Use as a 10 * condition to gate login/comment/form actions. 8 * - rate_limit_check Fixed-window counter (transient-backed) keyed by an 9 * identifier (defaults to client IP). Use as a 10 * condition to gate login/comment/form actions. 11 * - harden_user_enumeration Stop anonymous visitors from discovering usernames 12 * (REST users endpoint, author archives, oEmbed 13 * author, login error messages). 14 * - security_headers Send Referrer-Policy, X-Content-Type-Options, 15 * X-Frame-Options and Permissions-Policy on 16 * frontend responses, and HSTS on HTTPS (opt-in). 17 * - hide_wp_version Remove generator tags, mask the WordPress 18 * version in core asset URLs and, opt-in, 19 * delete readme.html. 20 * 21 * The hardening primitives hook named static methods rather than closures 22 * wherever the callback needs no config: add_filter() with the same callable 23 * and priority is idempotent, so two blueprints declaring the same primitive 24 * do not stack duplicate callbacks, and verify can check the exact callable 25 * with has_filter() instead of "something is hooked here". 11 26 */ 12 27 class CLAUTRON_Primitive_Security extends CLAUTRON_Abstract_Primitive { 13 28 29 /** Login error codes that reveal whether a username/email exists. */ 30 private const LOGIN_ENUMERATION_CODES = [ 'invalid_username', 'invalid_email', 'incorrect_password' ]; 31 32 private const REFERRER_POLICIES = [ 33 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', 34 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', 35 ]; 36 37 private const FRAME_OPTIONS = [ 'SAMEORIGIN', 'DENY' ]; 38 39 private const PERMISSIONS_POLICY = 'camera=(), microphone=()'; 40 41 /** WordPress version readme.html was last removed for (see remove_core_readme()). */ 42 private const README_OPTION = 'clautron_readme_removed_for'; 43 44 /** Headers merged from every active security_headers structure; the first one to set a header wins. */ 45 private static array $security_headers = []; 46 14 47 public static function register_all(): void { 15 48 self::register_rate_limit_check(); 49 self::register_harden_user_enumeration(); 50 self::register_security_headers(); 51 self::register_hide_wp_version(); 16 52 } 17 53 … … 22 58 'category' => 'security', 23 59 'phase' => 'runtime', 60 'effects' => [ 'writes_database' ], 24 61 'safe' => false, 25 62 'label' => 'Rate Limit Check', … … 86 123 return false; 87 124 } 125 126 // ── harden_user_enumeration ────────────────────────────────────────────── 127 128 private static function register_harden_user_enumeration(): void { 129 CLAUTRON_Primitive_Registry::register( 'harden_user_enumeration', [ 130 'category' => 'security', 131 'phase' => 'compile', 132 'effects' => [], 133 'safe' => false, 134 'label' => 'Harden User Enumeration', 135 'description' => 'Stop anonymous visitors from discovering usernames. Blocks the /wp/v2/users REST endpoints for logged-out requests (logged-in users, including the block editor author picker, keep working); returns 404 for every author archive (?author=N, ?author_name=, /author/slug/ and their feeds), drops the users sitemap and points author links at the homepage; replaces the post author in oEmbed responses with the site name; and gives the same login error for an unknown username and a wrong password. Each part can be switched off individually.', 136 'schema' => [ 137 'block_rest_users' => [ 'type' => 'boolean', 'default' => true, 'description' => '401 on every /wp/v2/users route for logged-out requests.' ], 138 'disable_author_archives' => [ 'type' => 'boolean', 'default' => true, 'description' => '404 on all author archives for everyone, remove the users sitemap, point author links to the homepage.' ], 139 'strip_oembed_author' => [ 'type' => 'boolean', 'default' => true, 'description' => 'oEmbed responses show the site name and homepage instead of the post author.' ], 140 'generic_login_errors' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Same login error whether the username/email is unknown or the password is wrong.' ], 141 ], 142 'examples' => [ 143 [], 144 [ 'disable_author_archives' => false ], 145 ], 146 'handler' => static function ( array $config ): void { 147 if ( $config['block_rest_users'] ?? true ) { 148 add_filter( 'rest_request_before_callbacks', [ self::class, 'gate_rest_users' ], 10, 3 ); 149 } 150 if ( $config['disable_author_archives'] ?? true ) { 151 add_filter( 'request', [ self::class, 'block_author_request' ] ); 152 add_filter( 'wp_sitemaps_add_provider', [ self::class, 'drop_users_sitemap' ], 10, 2 ); 153 add_filter( 'author_link', [ self::class, 'neutral_author_link' ] ); 154 } 155 if ( $config['strip_oembed_author'] ?? true ) { 156 add_filter( 'oembed_response_data', [ self::class, 'neutral_oembed_author' ] ); 157 } 158 if ( $config['generic_login_errors'] ?? true ) { 159 // After every core authenticator (20) and the spam check (99). 160 add_filter( 'authenticate', [ self::class, 'generic_login_error' ], 100 ); 161 } 162 }, 163 'verify' => static function ( array $config ): array { 164 $items = []; 165 $checks = [ 166 'block_rest_users' => [ 'rest_request_before_callbacks', 'gate_rest_users' ], 167 'disable_author_archives' => [ 'request', 'block_author_request' ], 168 'strip_oembed_author' => [ 'oembed_response_data', 'neutral_oembed_author' ], 169 'generic_login_errors' => [ 'authenticate', 'generic_login_error' ], 170 ]; 171 foreach ( $checks as $option => [ $hook, $method ] ) { 172 if ( ! ( $config[ $option ] ?? true ) ) { 173 continue; 174 } 175 $items[] = false !== has_filter( $hook, [ self::class, $method ] ) 176 ? [ 'status' => 'passed', 'label' => "harden_user_enumeration:{$option}" ] 177 : [ 'status' => 'failed', 'label' => "harden_user_enumeration:{$option}", 'reason' => "not hooked on {$hook}." ]; 178 } 179 180 if ( $config['block_rest_users'] ?? true ) { 181 $status = self::anonymous_rest_status( '/wp/v2/users' ); 182 $items[] = in_array( $status, [ 401, 403 ], true ) 183 ? [ 'status' => 'passed', 'label' => "anonymous GET /wp/v2/users → {$status}" ] 184 : [ 'status' => 'failed', 'label' => 'anonymous GET /wp/v2/users', 'reason' => "expected 401, got {$status}." ]; 185 } 186 187 if ( $config['disable_author_archives'] ?? true ) { 188 $vars = self::author_query_vars( [ 'author' => '1' ], false ); 189 $items[] = ( $vars['error'] ?? '' ) === '404' 190 ? [ 'status' => 'passed', 'label' => '?author=1 resolves to 404' ] 191 : [ 'status' => 'failed', 'label' => '?author=1', 'reason' => 'author query was not turned into a 404.' ]; 192 } 193 194 return $items; 195 }, 196 ] ); 197 } 198 199 /** 200 * rest_request_before_callbacks: refuse every route served by the core 201 * users controller to logged-out requests. 202 * 203 * Keyed on the controller that matched, not on the URL: WordPress matches 204 * routes case-insensitively (/wp/v2/USERS works) and also accepts 205 * ?rest_route=, so a path check would have to replicate the router to be 206 * airtight. Whatever spelling reached the users controller is caught here. 207 */ 208 public static function gate_rest_users( mixed $response, array $handler, WP_REST_Request $request ): mixed { 209 $callback = $handler['callback'] ?? null; 210 if ( ! is_array( $callback ) || ! ( $callback[0] ?? null ) instanceof WP_REST_Users_Controller ) { 211 return $response; 212 } 213 if ( is_user_logged_in() ) { 214 return $response; 215 } 216 return new WP_Error( 217 'rest_forbidden', 218 __( 'Sorry, you are not allowed to do that.', 'clautron' ), 219 [ 'status' => rest_authorization_required_code() ] 220 ); 221 } 222 223 /** request filter: wiring for author_query_vars(). */ 224 public static function block_author_request( array $query_vars ): array { 225 return self::author_query_vars( $query_vars, is_admin() ); 226 } 227 228 /** 229 * Replace any author-archive query with a plain 404. Public for unit 230 * testing (see CLAUTRON_Primitive_WordPress::build_cpt_args()). 231 * 232 * Runs on `request`, before WP_Query exists, so every way of asking for an 233 * author archive is covered by one check: ?author=N, ?author_name=slug, 234 * /author/slug/ (rewrite sets author_name) and their /feed/ variants. 235 * Blocking only the canonical redirect is not enough: an author without 236 * published posts gets no redirect, but the archive still prints the slug 237 * in <title> and body_class. 238 * 239 * Left alone: 240 * - wp-admin, because edit.php?author=N calls wp() and goes through this 241 * same filter to filter the posts list; 242 * - REST requests, because /wp-json/wp/v2/posts?author=N is a legitimate 243 * filter and `author` is a public query var. 244 */ 245 public static function author_query_vars( array $query_vars, bool $in_admin ): array { 246 if ( $in_admin || isset( $query_vars['rest_route'] ) ) { 247 return $query_vars; 248 } 249 if ( empty( $query_vars['author'] ) && empty( $query_vars['author_name'] ) ) { 250 return $query_vars; 251 } 252 // Same shape WP::parse_request() produces for an unmatched URL, so 253 // WP::send_headers() sends the 404 status and nocache headers itself. 254 return [ 'error' => '404' ]; 255 } 256 257 /** wp_sitemaps_add_provider: the users sitemap only lists author archive URLs. */ 258 public static function drop_users_sitemap( mixed $provider, string $name ): mixed { 259 return 'users' === $name ? false : $provider; 260 } 261 262 /** author_link: author archives are gone, and the link itself carries the slug. */ 263 public static function neutral_author_link(): string { 264 return home_url( '/' ); 265 } 266 267 /** 268 * oembed_response_data: use the values core itself falls back to when a 269 * post has no author, instead of display_name + author archive URL. 270 */ 271 public static function neutral_oembed_author( array $data ): array { 272 if ( isset( $data['author_name'] ) || isset( $data['author_url'] ) ) { 273 $data['author_name'] = get_bloginfo( 'name' ); 274 $data['author_url'] = get_home_url(); 275 } 276 return $data; 277 } 278 279 /** 280 * authenticate: collapse "unknown username", "unknown email" and "wrong 281 * password" into one error. 282 * 283 * Reuses core's own 'invalidcombo' code: it is already in wp-login.php's 284 * shake list, and it keeps wp-login.php from pre-filling the username 285 * field only on a wrong password, which would otherwise leak the same 286 * information through the form instead of the message. 287 */ 288 public static function generic_login_error( mixed $user ): mixed { 289 if ( ! is_wp_error( $user ) || ! array_intersect( $user->get_error_codes(), self::LOGIN_ENUMERATION_CODES ) ) { 290 return $user; 291 } 292 return new WP_Error( 293 'invalidcombo', 294 __( '<strong>Error:</strong> The username, email address or password is incorrect.', 'clautron' ) 295 ); 296 } 297 298 /** Dispatch a GET as a logged-out visitor and return the HTTP status. */ 299 private static function anonymous_rest_status( string $route ): int { 300 $previous_user = get_current_user_id(); 301 wp_set_current_user( 0 ); 302 try { 303 return rest_do_request( new WP_REST_Request( 'GET', $route ) )->get_status(); 304 } finally { 305 wp_set_current_user( $previous_user ); 306 } 307 } 308 309 // ── security_headers ───────────────────────────────────────────────────── 310 311 private static function register_security_headers(): void { 312 CLAUTRON_Primitive_Registry::register( 'security_headers', [ 313 'category' => 'security', 314 'phase' => 'compile', 315 'effects' => [], 316 // Browsers keep honouring HSTS for max-age after the header stops. 317 'reversible' => static fn( array $c ): bool => (int) ( $c['hsts_max_age'] ?? 0 ) <= 0, 318 'safe' => false, 319 'label' => 'Security Headers', 320 'description' => 'Send Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy on frontend responses, plus Strict-Transport-Security (HSTS) on HTTPS when opted in. A header already set by another plugin through wp_headers is left as is. Headers are sent from PHP, so pages served straight from a page cache or CDN without running WordPress will not carry them; verify loads the homepage to detect that.', 321 'schema' => [ 322 'referrer_policy' => [ 'type' => 'string', 'default' => 'strict-origin-when-cross-origin', 'description' => 'Any standard Referrer-Policy value. Empty = do not send. strict-origin-when-cross-origin matches what WordPress core sends in wp-admin; no-referrer breaks YouTube embeds and referral analytics.' ], 323 'content_type_options' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Send X-Content-Type-Options: nosniff.' ], 324 'frame_options' => [ 'type' => 'string', 'default' => 'SAMEORIGIN', 'description' => 'SAMEORIGIN, DENY, or empty to not send. SAMEORIGIN keeps the Customizer and page-builder previews working.' ], 325 'permissions_policy' => [ 'type' => 'string', 'default' => self::PERMISSIONS_POLICY, 'description' => 'Permissions-Policy value, e.g. "camera=(), microphone=(), geolocation=(self)". Empty = do not send; an invalid value is not sent. The default turns off camera and microphone only: blocking geolocation would break store/branch locators, and payment would break Apple Pay / Google Pay buttons.' ], 326 'hsts_max_age' => [ 'type' => 'integer', 'default' => 0, 'description' => 'Opt-in Strict-Transport-Security max-age in seconds (31536000 = one year). 0 = do not send. Only sent over HTTPS. Browsers then refuse plain HTTP for that long and deactivating does not undo it, so turn it on only when the whole site works over HTTPS.' ], 327 'hsts_include_subdomains' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Add includeSubDomains to HSTS. Only when every subdomain serves HTTPS.' ], 328 ], 329 'examples' => [ 330 [], 331 [ 'permissions_policy' => 'camera=(), microphone=(), geolocation=(self)', 'hsts_max_age' => 31536000 ], 332 ], 333 'handler' => static function ( array $config ): void { 334 $headers = self::build_security_headers( $config ); 335 if ( ! $headers ) { 336 return; 337 } 338 self::$security_headers = self::merge_headers( self::$security_headers, $headers ); 339 add_filter( 'wp_headers', [ self::class, 'send_security_headers' ] ); 340 }, 341 'verify' => static function ( array $config ): array { 342 $expected = self::build_security_headers( $config ); 343 if ( ! $expected ) { 344 return []; 345 } 346 if ( false === has_filter( 'wp_headers', [ self::class, 'send_security_headers' ] ) ) { 347 return [ [ 'status' => 'failed', 'label' => 'security_headers', 'reason' => 'not hooked on wp_headers.' ] ]; 348 } 349 350 $items = []; 351 foreach ( $expected as $name => $value ) { 352 if ( ( self::$security_headers[ $name ] ?? null ) !== $value ) { 353 $items[] = [ 'status' => 'warning', 'label' => "security_headers:{$name}", 'reason' => 'another security_headers capability already sets it to "' . ( self::$security_headers[ $name ] ?? '' ) . '"; the first one wins.' ]; 354 } 355 } 356 357 $https = 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME ); 358 if ( isset( $expected['Strict-Transport-Security'] ) && ! $https ) { 359 $items[] = [ 'status' => 'warning', 'label' => 'security_headers:Strict-Transport-Security', 'reason' => 'HSTS is only sent over HTTPS and this site\'s address is http://.' ]; 360 unset( $expected['Strict-Transport-Security'] ); 361 } 362 363 $response = wp_remote_get( home_url( '/' ), [ 'timeout' => 5, 'redirection' => 3 ] ); 364 if ( is_wp_error( $response ) ) { 365 $items[] = [ 'status' => 'warning', 'label' => 'security_headers:live', 'reason' => 'could not load the homepage to confirm the headers reach visitors: ' . $response->get_error_message() ]; 366 return $items; 367 } 368 foreach ( $expected as $name => $value ) { 369 $items[] = '' !== wp_remote_retrieve_header( $response, $name ) 370 ? [ 'status' => 'passed', 'label' => "{$name} on homepage" ] 371 : [ 'status' => 'warning', 'label' => "{$name} on homepage", 'reason' => 'set by WordPress but missing from the live response; a page cache, CDN or proxy is serving the page without PHP or stripping the header. Set it at that layer too.' ]; 372 } 373 return $items; 374 }, 375 ] ); 376 } 377 378 /** 379 * wp_headers: everything active security_headers structures asked for. 380 * One named callback (not a closure per structure) so verify can check 381 * that this primitive, not just any plugin, is hooked. 382 */ 383 public static function send_security_headers( array $headers ): array { 384 $ours = self::$security_headers; 385 if ( ! is_ssl() ) { 386 unset( $ours['Strict-Transport-Security'] ); 387 } 388 return self::merge_headers( $headers, $ours ); 389 } 390 391 /** 392 * Header name → value from a security_headers config. Invalid values are 393 * dropped rather than sent, since a malformed Referrer-Policy or 394 * X-Frame-Options is ignored by browsers anyway. Public for unit testing. 395 */ 396 public static function build_security_headers( array $config ): array { 397 $headers = []; 398 399 $referrer = strtolower( trim( (string) ( $config['referrer_policy'] ?? 'strict-origin-when-cross-origin' ) ) ); 400 if ( in_array( $referrer, self::REFERRER_POLICIES, true ) ) { 401 $headers['Referrer-Policy'] = $referrer; 402 } 403 404 if ( $config['content_type_options'] ?? true ) { 405 $headers['X-Content-Type-Options'] = 'nosniff'; 406 } 407 408 $frame = strtoupper( trim( (string) ( $config['frame_options'] ?? 'SAMEORIGIN' ) ) ); 409 if ( in_array( $frame, self::FRAME_OPTIONS, true ) ) { 410 $headers['X-Frame-Options'] = $frame; 411 } 412 413 $permissions = trim( (string) ( $config['permissions_policy'] ?? self::PERMISSIONS_POLICY ) ); 414 $directive = '[a-z0-9-]+=(?:\*|\((?:[ \t]*(?:self|src|\*|"https?:\/\/[^"\s]+"))*[ \t]*\))'; 415 if ( preg_match( '/^' . $directive . '(?:[ \t]*,[ \t]*' . $directive . ')*$/D', $permissions ) ) { 416 $headers['Permissions-Policy'] = $permissions; 417 } 418 419 $max_age = min( 63072000, (int) ( $config['hsts_max_age'] ?? 0 ) ); 420 if ( $max_age > 0 ) { 421 $headers['Strict-Transport-Security'] = "max-age={$max_age}" . ( empty( $config['hsts_include_subdomains'] ) ? '' : '; includeSubDomains' ); 422 } 423 424 return $headers; 425 } 426 427 /** 428 * Add $ours to $existing without overriding a header someone else already 429 * set (case-insensitive, like HTTP header names). Overriding could weaken 430 * a stricter choice, e.g. a security plugin's X-Frame-Options: DENY. 431 */ 432 public static function merge_headers( array $existing, array $ours ): array { 433 $taken = array_change_key_case( $existing, CASE_LOWER ); 434 foreach ( $ours as $name => $value ) { 435 if ( ! isset( $taken[ strtolower( $name ) ] ) ) { 436 $existing[ $name ] = $value; 437 } 438 } 439 return $existing; 440 } 441 442 // ── hide_wp_version ────────────────────────────────────────────────────── 443 444 private static function register_hide_wp_version(): void { 445 CLAUTRON_Primitive_Registry::register( 'hide_wp_version', [ 446 'category' => 'security', 447 'phase' => 'compile', 448 'effects' => static fn( array $c ): array => empty( $c['remove_readme'] ) ? [] : [ 'writes_files', 'writes_database' ], 449 'effects_max' => [ 'writes_files', 'writes_database' ], 450 'safe' => false, 451 'label' => 'Hide WordPress Version', 452 'description' => 'Stop advertising the WordPress version. Removes the generator meta tag and the feed <generator> element (which also drops WooCommerce\'s generator line), turns off Elementor\'s generator tag without changing its saved setting, and replaces ?ver=<WordPress version> on asset URLs with a site-specific hash that still changes on every update, so browser caches are still refreshed. readme.html in the site root also states the version, but the web server serves it without WordPress, so nothing above hides it: set remove_readme to delete it (opt-in, a file deletion that deactivating does not undo).', 453 'schema' => [ 454 'remove_generator' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Remove generator tags (WordPress, WooCommerce, Elementor) from pages and feeds.' ], 455 'mask_asset_versions' => [ 'type' => 'boolean', 'default' => true, 'description' => 'Replace ?ver=<WordPress version> on script/style URLs with a hash of it.' ], 456 'remove_readme' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Delete readme.html from the site root, and again after every WordPress update (which restores it). Works on any web server. Deactivating does not bring the file back; the next WordPress update does.' ], 457 ], 458 'examples' => [ 459 [], 460 [ 'remove_readme' => true ], 461 ], 462 'handler' => static function ( array $config ): void { 463 if ( ! empty( $config['remove_readme'] ) ) { 464 add_action( 'init', [ self::class, 'maybe_remove_core_readme' ] ); 465 } 466 if ( $config['remove_generator'] ?? true ) { 467 add_filter( 'the_generator', '__return_empty_string' ); 468 // Elementor prints its own tag on wp_head unless this option is '1'. 469 // pre_option_ overrides it for as long as the feature is active and 470 // leaves the stored value untouched when it is deactivated. 471 add_filter( 'pre_option_elementor_meta_generator_tag', [ self::class, 'elementor_generator_off' ] ); 472 } 473 if ( $config['mask_asset_versions'] ?? true ) { 474 add_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] ); 475 add_filter( 'style_loader_src', [ self::class, 'mask_core_version' ] ); 476 } 477 }, 478 'verify' => static function ( array $config ): array { 479 $items = []; 480 if ( $config['remove_generator'] ?? true ) { 481 $items[] = false !== has_filter( 'the_generator', '__return_empty_string' ) 482 ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_generator' ] 483 : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_generator', 'reason' => 'not hooked on the_generator.' ]; 484 } 485 if ( $config['mask_asset_versions'] ?? true ) { 486 $version = get_bloginfo( 'version' ); 487 $masked = self::mask_core_version( includes_url( 'js/jquery/jquery.min.js?ver=' . $version ) ); 488 $items[] = false !== has_filter( 'script_loader_src', [ self::class, 'mask_core_version' ] ) && ! str_contains( $masked, $version ) 489 ? [ 'status' => 'passed', 'label' => 'hide_wp_version:mask_asset_versions' ] 490 : [ 'status' => 'failed', 'label' => 'hide_wp_version:mask_asset_versions', 'reason' => 'asset URLs still carry the WordPress version.' ]; 491 } 492 if ( ! empty( $config['remove_readme'] ) ) { 493 $items[] = ! file_exists( ABSPATH . 'readme.html' ) 494 ? [ 'status' => 'passed', 'label' => 'hide_wp_version:remove_readme' ] 495 : [ 'status' => 'failed', 'label' => 'hide_wp_version:remove_readme', 'reason' => 'readme.html is still in the site root; WordPress could not delete it (file permissions). Delete it by hand or deny it at the web server.' ]; 496 } 497 return $items; 498 }, 499 ] ); 500 } 501 502 /** init: wiring for remove_core_readme(). */ 503 public static function maybe_remove_core_readme(): void { 504 self::remove_core_readme( ABSPATH . 'readme.html', (string) get_bloginfo( 'version' ) ); 505 } 506 507 /** 508 * Delete $path once per WordPress version. Every core update (one-click, 509 * WP-CLI or files copied by hand) puts readme.html back and changes the 510 * version, so keying on the version catches all of them for the price of 511 * one autoloaded option read per request, not a file stat. A failed 512 * delete is not recorded, so it is retried. Public for unit testing. 513 */ 514 public static function remove_core_readme( string $path, string $version ): bool { 515 if ( get_option( self::README_OPTION ) === $version ) { 516 return false; 517 } 518 if ( file_exists( $path ) ) { 519 wp_delete_file( $path ); 520 } 521 if ( file_exists( $path ) ) { 522 return false; 523 } 524 update_option( self::README_OPTION, $version, true ); 525 return true; 526 } 527 528 /** pre_option_elementor_meta_generator_tag: '1' is Elementor's "disabled". */ 529 public static function elementor_generator_off(): string { 530 return '1'; 531 } 532 533 /** 534 * script_loader_src / style_loader_src: swap ver=<WordPress version> for a 535 * salted hash of it. Assets enqueued without an explicit version get the 536 * WordPress version by default, so they are masked too. Other versions 537 * (a plugin's own) are left alone. Public for unit testing. 538 */ 539 public static function mask_core_version( mixed $src ): mixed { 540 if ( ! is_string( $src ) || '' === $src ) { 541 return $src; 542 } 543 $version = get_bloginfo( 'version' ); 544 parse_str( (string) wp_parse_url( $src, PHP_URL_QUERY ), $query ); 545 if ( ( $query['ver'] ?? null ) !== $version ) { 546 return $src; 547 } 548 return add_query_arg( 'ver', substr( wp_hash( $version ), 0, 8 ), $src ); 549 } 88 550 } -
clautron/trunk/includes/primitives/class-primitive-sitemap.php
r3691441 r3784837 30 30 'category' => 'frontend', 31 31 'phase' => 'compile', 32 'effects' => [], 32 33 'safe' => false, 33 34 'label' => 'Register XML Sitemap', -
clautron/trunk/includes/primitives/class-primitive-taxonomy.php
r3582869 r3784837 28 28 'category' => 'taxonomy', 29 29 'phase' => 'runtime', 30 'effects' => [ 'writes_database' ], 30 31 'label' => 'Set Post Terms', 31 32 'description' => 'Assign taxonomy terms to a post. Accepts term IDs, slugs, or names.', … … 72 73 'category' => 'taxonomy', 73 74 'phase' => 'runtime', 75 'effects' => [], 74 76 'label' => 'Get Post Terms', 75 77 'description' => 'Get all terms assigned to a post for a given taxonomy.', … … 117 119 'category' => 'taxonomy', 118 120 'phase' => 'runtime', 121 'effects' => [ 'writes_database' ], 119 122 'label' => 'Create Term', 120 123 'description' => 'Create a new taxonomy term if it does not already exist. Returns the term ID.', … … 176 179 'category' => 'taxonomy', 177 180 'phase' => 'runtime', 181 'effects' => [], 178 182 'label' => 'Term Exists', 179 183 'description' => 'Check if a term exists in a taxonomy. Writes term ID (int) or 0 if not found.', -
clautron/trunk/includes/primitives/class-primitive-transform.php
r3582869 r3784837 61 61 'category' => 'data', 62 62 'phase' => 'runtime', 63 'effects' => [], 63 64 'label' => 'Parse JSON', 64 65 'description' => 'Decode a JSON string into an associative array and store it in a context variable.', … … 90 91 'category' => 'data', 91 92 'phase' => 'runtime', 93 'effects' => [], 92 94 'label' => 'To JSON', 93 95 'description' => 'Encode a context variable as a JSON string and store it in another variable.', … … 119 121 'category' => 'data', 120 122 'phase' => 'runtime', 123 'effects' => [], 121 124 'label' => 'Map Fields', 122 125 'description' => 'Remap an array of objects to a new shape. Each output key\'s value is a source field name or a {{expr}} template evaluated per item.', … … 168 171 'category' => 'data', 169 172 'phase' => 'runtime', 173 'effects' => [], 170 174 'label' => 'Filter Array', 171 175 'description' => 'Filter an array of objects by a field condition. Supported operators: eq, neq, gt, gte, lt, lte, contains, not_contains, empty, not_empty.', … … 214 218 'category' => 'data', 215 219 'phase' => 'runtime', 220 'effects' => [], 216 221 'label' => 'Array Pluck', 217 222 'description' => 'Extract a single field from each item in an array, producing a flat array of values.', … … 256 261 'category' => 'data', 257 262 'phase' => 'runtime', 263 'effects' => [], 258 264 'label' => 'Array Merge', 259 265 'description' => 'Merge multiple array variables into one combined array.', … … 290 296 'category' => 'data', 291 297 'phase' => 'runtime', 298 'effects' => [], 292 299 'label' => 'Array Unique', 293 300 'description' => 'Remove duplicate values from a flat array and reindex.', … … 319 326 'category' => 'data', 320 327 'phase' => 'runtime', 328 'effects' => [], 321 329 'label' => 'Array Sort', 322 330 'description' => 'Sort an array. If field is set, sort an array of objects by that field. Otherwise sort a flat array.', … … 375 383 'category' => 'data', 376 384 'phase' => 'runtime', 385 'effects' => [], 377 386 'label' => 'Array Slice', 378 387 'description' => 'Return a portion of an array starting at offset for a given length.', … … 408 417 'category' => 'data', 409 418 'phase' => 'runtime', 419 'effects' => [], 410 420 'label' => 'Count Array', 411 421 'description' => 'Count the number of items in an array variable and store the integer.', … … 437 447 'category' => 'data', 438 448 'phase' => 'runtime', 449 'effects' => [], 439 450 'label' => 'String Format', 440 451 'description' => 'Render a template string with {{variable}} placeholders and store the result.', … … 465 476 'category' => 'data', 466 477 'phase' => 'runtime', 478 'effects' => [], 467 479 'label' => 'String Truncate', 468 480 'description' => 'Truncate a string to a maximum character length, appending a suffix when truncated.', … … 501 513 'category' => 'data', 502 514 'phase' => 'runtime', 515 'effects' => [], 503 516 'label' => 'String Replace', 504 517 'description' => 'Find and replace a substring within a string.', … … 533 546 'category' => 'data', 534 547 'phase' => 'runtime', 548 'effects' => [], 535 549 'label' => 'String Split', 536 550 'description' => 'Split a string by a delimiter into an array. Empty elements are removed.', … … 564 578 'category' => 'data', 565 579 'phase' => 'runtime', 580 'effects' => [], 566 581 'label' => 'String Join', 567 582 'description' => 'Join an array of strings into a single string using a glue separator.', … … 595 610 'category' => 'data', 596 611 'phase' => 'runtime', 612 'effects' => [], 597 613 'label' => 'Date Format', 598 614 'description' => 'Format a date string or timestamp using a PHP date format string.', … … 626 642 'category' => 'data', 627 643 'phase' => 'runtime', 644 'effects' => [], 628 645 'label' => 'Date Diff', 629 646 'description' => 'Calculate the absolute difference between two dates in the requested unit.', … … 675 692 'category' => 'data', 676 693 'phase' => 'runtime', 694 'effects' => [], 677 695 'label' => 'Date Add', 678 696 'description' => 'Add a specified amount of time to a date and format the result.', … … 719 737 'category' => 'data', 720 738 'phase' => 'runtime', 739 'effects' => [], 721 740 'label' => 'Slugify', 722 741 'description' => 'Convert a string to a URL-friendly slug using WordPress sanitize_title().', … … 747 766 'category' => 'data', 748 767 'phase' => 'runtime', 768 'effects' => [], 749 769 'label' => 'Math Calculate', 750 770 'description' => 'Perform a numeric math operation between two values.', -
clautron/trunk/includes/primitives/class-primitive-ui.php
r3691441 r3784837 41 41 'category' => 'ui', 42 42 'phase' => 'compile', 43 'effects' => [ 'writes_database' ], 43 44 'label' => 'Add Metabox', 44 45 'description' => 'Add a custom metabox panel to the post edit screen for one or more post types.', … … 109 110 } 110 111 }, 112 'verify' => static function ( array $config ): array { 113 $box_id = sanitize_key( $config['id'] ?? '' ); 114 if ( ! $box_id ) { 115 return []; 116 } 117 // Handler registers with the 'clautron-' prefix (see above) — 118 // the check must look for the same id it was actually 119 // registered under. 120 $mbid = 'clautron-' . $box_id; 121 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 122 $pt = $pts[0] ?? 'post'; 123 if ( ! is_admin() ) { 124 return [ [ 'status' => 'warning', 'label' => "add_metabox:{$mbid}", 'reason' => 'skipped (requires admin context; will work correctly on post edit screens).' ] ]; 125 } 126 $hook_name = 'add_meta_boxes_' . $pt; 127 do_action( $hook_name, null ); 128 do_action( 'add_meta_boxes', $pt, null ); 129 global $wp_meta_boxes; 130 $found = false; 131 foreach ( $wp_meta_boxes[ $pt ] ?? [] as $context_boxes ) { 132 foreach ( $context_boxes as $priority_boxes ) { 133 if ( isset( $priority_boxes[ $mbid ] ) ) { 134 $found = true; 135 break 2; 136 } 137 } 138 } 139 if ( $found ) { 140 return [ [ 'status' => 'passed', 'label' => "add_metabox:{$mbid}" ] ]; 141 } 142 return [ [ 'status' => 'failed', 'label' => "add_metabox:{$mbid}", 'reason' => "not found in wp_meta_boxes for post type '{$pt}'." ] ]; 143 }, 111 144 ] ); 112 145 } … … 118 151 'category' => 'ui', 119 152 'phase' => 'compile', 153 'effects' => [], 120 154 'label' => 'Add Admin Column', 121 155 'description' => 'Add a custom column to the WordPress admin list table for a post type.', … … 215 249 } 216 250 }, 251 'verify' => static function ( array $config ): array { 252 $pts = (array) ( $config['post_types'] ?? [] ); 253 $items = []; 254 foreach ( $pts as $pt ) { 255 $filter = "manage_{$pt}_posts_columns"; 256 if ( has_filter( $filter ) ) { 257 $items[] = [ 'status' => 'passed', 'label' => "add_admin_column:{$pt}" ]; 258 } else { 259 $items[] = [ 'status' => 'failed', 'label' => "add_admin_column:{$pt}", 'reason' => "filter '{$filter}' not hooked." ]; 260 } 261 } 262 return $items; 263 }, 217 264 ] ); 218 265 } … … 224 271 'category' => 'ui', 225 272 'phase' => 'compile', 273 'effects' => [], 226 274 'label' => 'Add Admin Filter', 227 275 'description' => 'Add a custom dropdown filter to the WordPress admin post list table.', … … 325 373 'category' => 'ui', 326 374 'phase' => 'runtime', 375 'effects' => [ 'executes_code' ], 376 'safe' => false, 327 377 'label' => 'Add Admin Notice', 328 378 'description' => 'Display an admin notice (info, success, warning, error).', … … 359 409 'category' => 'ui', 360 410 'phase' => 'compile', 411 'effects' => [ 'writes_database' ], 361 412 'label' => 'Add Bulk Action', 362 413 'description' => 'Add a custom bulk action to a post type list table.', … … 423 474 'category' => 'ui', 424 475 'phase' => 'compile', 476 'effects' => [], 425 477 'label' => 'Add Report Page', 426 478 'description' => 'Add a stats/report section to the Clautron Reports admin page. Renders metric cards with post counts based on configurable queries.', … … 986 1038 'category' => 'ui', 987 1039 'phase' => 'compile', 1040 'effects' => [ 'writes_database' ], 988 1041 'label' => 'Add Row Action', 989 1042 'description' => 'Add a custom action link to post row actions in the list table (e.g. Clone).', … … 1041 1094 } ); 1042 1095 }, 1096 'verify' => static function ( array $config ): array { 1097 $pts = (array) ( $config['post_types'] ?? [] ); 1098 $items = []; 1099 foreach ( $pts as $pt ) { 1100 if ( has_filter( 'post_row_actions' ) || has_filter( "{$pt}_row_actions" ) ) { 1101 $items[] = [ 'status' => 'passed', 'label' => "add_row_action:{$pt}" ]; 1102 } else { 1103 $items[] = [ 'status' => 'warning', 'label' => "add_row_action:{$pt}", 'reason' => 'row action filter not detected (may still work).' ]; 1104 } 1105 } 1106 return $items; 1107 }, 1043 1108 ] ); 1044 1109 } … … 1122 1187 'category' => 'ui', 1123 1188 'phase' => 'compile', 1189 'effects' => [ 'writes_database' ], 1124 1190 'label' => 'Add Options Page', 1125 1191 'description' => 'Add a site-wide settings page under wp-admin (not tied to any single post). Uses the same field types as add_metabox\'s fields template: text, textarea, checkbox, select, radio, image, repeater. Each field is saved as its own option: clautron_opt_{menu_slug}_{field_key}.', -
clautron/trunk/includes/primitives/class-primitive-user.php
r3582869 r3784837 32 32 'category' => 'user', 33 33 'phase' => 'runtime', 34 'effects' => [], 34 35 'label' => 'Read User Meta', 35 36 'description' => 'Read a user meta field and store the value in a context variable.', … … 62 63 'category' => 'user', 63 64 'phase' => 'runtime', 65 'effects' => [ 'writes_database' ], 64 66 'label' => 'Write User Meta', 65 67 'description' => 'Write or update a user meta value. All fields support {{variable}} syntax.', … … 103 105 'category' => 'user', 104 106 'phase' => 'runtime', 107 'effects' => [ 'writes_database' ], 105 108 'label' => 'Update User Role', 106 109 'description' => 'Change a user\'s WordPress role. Allowed roles: subscriber, contributor, author, editor. Cannot promote to administrator.', … … 152 155 'category' => 'user', 153 156 'phase' => 'runtime', 157 'effects' => [ 'writes_database', 'deletes_data' ], 154 158 'label' => 'Delete User (Safe)', 155 159 'description' => 'Delete a WordPress user. By default, skipped if the user has any published posts or approved comments. Set force=true to override.', … … 230 234 'category' => 'user', 231 235 'phase' => 'runtime', 236 'effects' => [], 232 237 'label' => 'Get Current User', 233 238 'description' => 'Store the currently logged-in user\'s data (id, login, email, display_name, roles) in a context variable.', … … 270 275 'category' => 'user', 271 276 'phase' => 'runtime', 277 'effects' => [], 272 278 'label' => 'Check User Capability', 273 279 'description' => 'Check if a user has a specific capability. Writes true/false to output_variable.', … … 305 311 'category' => 'user', 306 312 'phase' => 'runtime', 313 'effects' => [], 307 314 'label' => 'Check User Role', 308 315 'description' => 'Check if a user has a specific role. Writes true/false to output_variable.', -
clautron/trunk/includes/primitives/class-primitive-webhook.php
r3691441 r3784837 38 38 'category' => 'http', 39 39 'phase' => 'compile', 40 'effects' => [ 'writes_database', 'changes_schema' ], 40 41 'safe' => false, 41 42 'label' => 'Register Payment Webhook', -
clautron/trunk/includes/primitives/class-primitive-woocommerce.php
r3691441 r3784837 36 36 'category' => 'woocommerce', 37 37 'phase' => 'runtime', 38 'effects' => [], 38 39 'label' => 'Query Orders', 39 40 'description' => 'Query WooCommerce orders with filters. Returns array of orders (id, status, total, currency, customer_id, customer_email, date_created, items_count) stored in a context variable.', … … 109 110 'category' => 'woocommerce', 110 111 'phase' => 'runtime', 112 'effects' => [], 111 113 'label' => 'Query Products', 112 114 'description' => 'Query WooCommerce products with filters. Returns array of products (id, name, sku, price, regular_price, stock_status, stock_quantity, type, status) stored in a context variable.', … … 180 182 'category' => 'woocommerce', 181 183 'phase' => 'runtime', 184 'effects' => [ 'writes_database', 'external_requests' ], 182 185 'label' => 'Update Order Status', 183 186 'description' => 'Move a WooCommerce order to another standard status (pending, processing, on-hold, completed, cancelled, refunded, failed). Adds an order note attributing the change to the Clautron workflow. Trashing/deleting orders is intentionally not supported.', -
clautron/trunk/includes/primitives/class-primitive-wordpress.php
r3691441 r3784837 46 46 'category' => 'wordpress', 47 47 'phase' => 'compile', 48 // A custom capability_type grants its caps to administrator, which 49 // WordPress stores in wp_user_roles: that outlives deactivation. 50 'effects' => static fn( array $c ): array => in_array( trim( explode( ',', (string) ( $c['capability_type'] ?? 'post' ) )[0] ), [ 'post', 'page' ], true ) ? [ 'changes_schema' ] : [ 'changes_schema', 'writes_database' ], 51 'effects_max' => [ 'changes_schema', 'writes_database' ], 48 52 'label' => 'Register Custom Post Type', 49 53 'description' => 'Register a new custom post type in WordPress.', … … 78 82 } ); 79 83 }, 84 'verify' => static function ( array $config ): array { 85 $pt = $config['post_type'] ?? ''; 86 if ( ! $pt ) { 87 return []; 88 } 89 if ( get_post_type_object( $pt ) ) { 90 return [ [ 'status' => 'passed', 'label' => "register_cpt:{$pt}" ] ]; 91 } 92 return [ [ 'status' => 'failed', 'label' => "register_cpt:{$pt}", 'reason' => 'post type not registered.' ] ]; 93 }, 80 94 ] ); 81 95 } … … 89 103 * type they just asked for. Grant the mapped capabilities to 90 104 * administrator; add_cap() is idempotent, safe to call every request. 105 * 106 * Only primitive capabilities. edit_post, read_post and delete_post are 107 * meta capabilities that map_meta_cap() resolves per post; a role must 108 * never hold them literally. WP_Role::has_cap() can't see them either, 109 * so granting them would rewrite wp_user_roles on every request, even 110 * for capability_type "post". 91 111 */ 92 112 public static function grant_cpt_capabilities_to_admin( \WP_Post_Type $post_type_obj ): void { … … 95 115 return; 96 116 } 97 foreach ( (array) $post_type_obj->cap as $cap ) { 117 $primitive = array_diff_key( (array) $post_type_obj->cap, array_flip( [ 'edit_post', 'read_post', 'delete_post' ] ) ); 118 foreach ( $primitive as $cap ) { 98 119 if ( is_string( $cap ) && ! $role->has_cap( $cap ) ) { 99 120 $role->add_cap( $cap ); 100 121 } 101 122 } 123 } 124 125 /** 126 * Effects of giving a post $status: trash removes it from the site, and 127 * publishing makes WordPress ping (update services, pingbacks, 128 * enclosures). An empty status leaves it unchanged. A value with a 129 * {{placeholder}} is only known at run time (and sanitize_key() turns 130 * "{{trash}}" into "trash"), so it gets the worst case. Core re-pinging 131 * when an already published post is saved again is not counted. 132 */ 133 public static function post_status_effects( string $status ): array { 134 if ( str_contains( $status, '{{' ) ) { 135 return [ 'writes_database', 'deletes_data', 'external_requests' ]; 136 } 137 return [ 138 'trash' => [ 'writes_database', 'deletes_data' ], 139 'publish' => [ 'writes_database', 'external_requests' ], 140 ][ sanitize_key( $status ) ] ?? [ 'writes_database' ]; 102 141 } 103 142 … … 142 181 'category' => 'wordpress', 143 182 'phase' => 'compile', 183 'effects' => [ 'changes_schema' ], 144 184 'label' => 'Register Taxonomy', 145 185 'description' => 'Register a custom taxonomy for one or more post types.', … … 167 207 register_taxonomy( $taxonomy, $post_types, self::build_taxonomy_args( $config, $label, $taxonomy ) ); 168 208 } ); 209 }, 210 'verify' => static function ( array $config ): array { 211 $tax = $config['taxonomy'] ?? ''; 212 if ( ! $tax ) { 213 return []; 214 } 215 if ( taxonomy_exists( $tax ) ) { 216 return [ [ 'status' => 'passed', 'label' => "register_taxonomy:{$tax}" ] ]; 217 } 218 return [ [ 'status' => 'failed', 'label' => "register_taxonomy:{$tax}", 'reason' => 'taxonomy not registered.' ] ]; 169 219 }, 170 220 ] ); … … 194 244 'category' => 'wordpress', 195 245 'phase' => 'compile', 246 'effects' => [ 'changes_schema' ], 196 247 'label' => 'Register Meta Field', 197 248 'description' => 'Register a meta field for posts, users, or terms with type and schema.', … … 231 282 } ); 232 283 }, 284 'verify' => static function ( array $config ): array { 285 $key = $config['meta_key'] ?? ''; 286 if ( ! $key ) { 287 return []; 288 } 289 $pts = (array) ( $config['post_types'] ?? [ 'post' ] ); 290 $registered = registered_meta_key_exists( 'post', $key, $pts[0] ?? '' ); 291 if ( $registered ) { 292 return [ [ 'status' => 'passed', 'label' => "register_meta:{$key}" ] ]; 293 } 294 return [ [ 'status' => 'warning', 'label' => "register_meta:{$key}", 'reason' => 'not found in registered meta (may still work via update_post_meta).' ] ]; 295 }, 233 296 ] ); 234 297 } … … 240 303 'category' => 'wordpress', 241 304 'phase' => 'compile', 305 'effects' => [ 'changes_schema' ], 242 306 'safe' => false, 243 307 'label' => 'Register REST Route', … … 286 350 } ); 287 351 }, 352 'verify' => static function ( array $config ): array { 353 $route = $config['route'] ?? ''; 354 if ( ! $route ) { 355 return []; 356 } 357 $routes = rest_get_server()->get_routes(); 358 $full_path = '/clautron/v1/' . ltrim( $route, '/' ); 359 if ( isset( $routes[ $full_path ] ) ) { 360 return [ [ 'status' => 'passed', 'label' => "register_rest_route:{$route}" ] ]; 361 } 362 return [ [ 'status' => 'warning', 'label' => "register_rest_route:{$route}", 'reason' => 'not found in registered routes (REST server may not be initialized yet).' ] ]; 363 }, 288 364 ] ); 289 365 } … … 295 371 'category' => 'wordpress', 296 372 'phase' => 'compile', 373 'effects' => [ 'writes_database' ], 297 374 'label' => 'Schedule Post Expiry', 298 375 'description' => 'Automatically change post status on its expiry date (stored in meta).', … … 353 430 'category' => 'wordpress', 354 431 'phase' => 'runtime', 432 // The handler only allows draft/publish/pending/private (anything else 433 // becomes draft), so a new post can be published but never trashed. 434 'effects' => static fn( array $c ): array => array_values( array_diff( self::post_status_effects( (string) ( $c['post_status'] ?? 'draft' ) ), [ 'deletes_data' ] ) ), 435 'effects_max' => [ 'writes_database', 'external_requests' ], 436 'safe' => false, 355 437 'label' => 'Create Post', 356 438 'description' => 'Create a new WordPress post (any type / status). Supports deduplication by post meta key + value to avoid creating duplicate posts across job runs.', … … 450 532 'category' => 'wordpress', 451 533 'phase' => 'runtime', 534 'effects' => static fn( array $c ): array => self::post_status_effects( (string) ( $c['post_status'] ?? '' ) ), 535 'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ], 452 536 'label' => 'Update Post', 453 537 'description' => 'Update fields on an existing WordPress post. Only provided fields are changed.', … … 523 607 'category' => 'wordpress', 524 608 'phase' => 'runtime', 609 'effects' => [ 'writes_database', 'deletes_data' ], 525 610 'label' => 'Delete Post', 526 611 'description' => 'Move a post to trash (force=false) or permanently delete it (force=true).', … … 562 647 'category' => 'wordpress', 563 648 'phase' => 'runtime', 649 'effects' => static fn( array $c ): array => self::post_status_effects( (string) ( $c['status'] ?? '' ) ), 650 'effects_max' => [ 'writes_database', 'deletes_data', 'external_requests' ], 564 651 'label' => 'Set Post Status', 565 652 'description' => 'Change the status of a post (draft, publish, pending, private, trash).', … … 602 689 'category' => 'wordpress', 603 690 'phase' => 'runtime', 691 'effects' => [], 604 692 'label' => 'Check WooCommerce HPOS', 605 693 'description' => 'Detects whether WooCommerce High Performance Order Storage (HPOS) is active. Stores true/false in a context variable. Use with if_else to branch between HPOS and legacy wp_posts order queries.', … … 625 713 'category' => 'wordpress', 626 714 'phase' => 'runtime', 715 'effects' => [ 'writes_database' ], 627 716 'label' => 'Schedule Single Event', 628 717 'description' => 'Schedule a one-time WP-Cron event to fire at a future time. The hook name must start with "clautron_" for safety. The arg is resolved as an integer (post/order ID).', -
clautron/trunk/includes/primitives/class-primitive-workflow.php
r3582869 r3784837 28 28 'category' => 'workflow', 29 29 'phase' => 'runtime', 30 'effects' => [], 30 31 'label' => 'Validate Field', 31 32 'description' => 'Validate a value against rules (required, min_length, max_length, email, url, numeric, regex). Writes true/false to output_variable. Writes error message to error_variable if set.', … … 105 106 'category' => 'workflow', 106 107 'phase' => 'runtime', 108 'effects' => [ 'writes_database' ], 107 109 'label' => 'Request Review', 108 110 'description' => 'Flag a post for editorial review by setting its status to \'pending\' and storing a review request note in meta.', … … 144 146 'category' => 'workflow', 145 147 'phase' => 'runtime', 148 'effects' => [ 'writes_database', 'deletes_data', 'external_requests' ], // deletes the review note; publishing pings 146 149 'label' => 'Approve Content', 147 150 'description' => 'Approve content by publishing a pending post and clearing the review note.', … … 177 180 'category' => 'workflow', 178 181 'phase' => 'runtime', 182 'effects' => [ 'writes_database' ], 179 183 'label' => 'Reject Content', 180 184 'description' => 'Reject content by reverting a pending post to draft and storing a rejection reason.', -
clautron/trunk/includes/registry/class-primitive-registry.php
r3582869 r3784837 36 36 * @type string $description One-sentence description for the AI prompt catalog. 37 37 * @type string $dependency null | 'woocommerce' | 'acf' | ... 38 * @type callable|null $verify function( array $config ): array — used by 39 * CLAUTRON_Smoke_Tester. Returns zero or more 40 * check items { status: passed|failed|warning, 41 * label: string, reason?: string }. Return [] 42 * when there's nothing meaningful to check for 43 * a given config (e.g. a required field is 44 * empty). Omit entirely (leave null) if this 45 * primitive type has no verification yet — it 46 * will show up as 'unavailable' rather than 47 * being silently skipped. 48 * @type string[]|Closure|null $effects Which of self::EFFECTS the primitive 49 * has, e.g. [ 'writes_database' ]; [] = none. 50 * A Closure( array $config ): string[] when 51 * they depend on config. null (omitted) = 52 * undeclared, which consumers must treat as 53 * "could be anything". An unknown effect name 54 * is reported with _doing_it_wrong() and makes 55 * the primitive undeclared, never "no effect". 56 * @type string[] $effects_max Required with a Closure: every effect any 57 * config can produce. The catalog publishes 58 * this worst case; the abilities still answer 59 * per call from the real config. 60 * @type bool|Closure|null $reversible Whether deactivating removes every 61 * trace. Omitted = derived from effects (no 62 * data written, deleted or sent away). Set it 63 * when something outlives deactivation that 64 * effects can't express (a cookie, HSTS). 65 * @type array[] $examples Example configs. Omitted = the 66 * clautron/primitives-list ability falls back 67 * to configs found in the shipped demos. 38 68 * } 39 69 */ … … 53 83 'description' => '', 54 84 'dependency' => null, 85 'verify' => null, 86 'effects' => null, 87 'effects_max' => null, 88 'reversible' => null, 89 'examples' => [], 55 90 ] ); 56 91 57 92 self::$primitives[ $type ]['type'] = $type; 93 self::check_effects( $type ); 94 } 95 96 /** 97 * A typo in an effect name must not quietly read as "no effect" (and so 98 * as reversible): report it and treat the primitive as undeclared. 99 */ 100 private static function check_effects( string $type ): void { 101 $def = self::$primitives[ $type ]; 102 $unknown = []; 103 foreach ( [ 'effects', 'effects_max' ] as $key ) { 104 if ( is_array( $def[ $key ] ) ) { 105 $unknown = array_merge( $unknown, array_diff( $def[ $key ], self::EFFECTS ) ); 106 } 107 } 108 if ( $unknown ) { 109 self::$primitives[ $type ]['effects'] = null; 110 self::doing_it_wrong( "Primitive '{$type}' declares unknown effects: " . implode( ', ', array_unique( $unknown ) ) . '. Allowed: ' . implode( ', ', self::EFFECTS ) . '.' ); 111 } elseif ( $def['effects'] instanceof Closure && ! is_array( $def['effects_max'] ) ) { 112 self::doing_it_wrong( "Primitive '{$type}' computes its effects from config but declares no effects_max; the catalog will list its effects as unknown." ); 113 } 114 } 115 116 private static function doing_it_wrong( string $message ): void { 117 if ( function_exists( '_doing_it_wrong' ) ) { 118 _doing_it_wrong( 'CLAUTRON_Primitive_Registry::register', esc_html( $message ), '1.5.0' ); 119 } 120 } 121 122 /** 123 * Effect vocabulary, the same keys a capability manifest's `effects` uses 124 * (Capability Contract v1), so a consumer reads both the same way: 125 * writes_database creates or changes content, meta, options, users, 126 * terms, comments, orders, events or transients 127 * writes_files writes or deletes files on disk 128 * deletes_data removes or trashes site data 129 * external_requests sends data off the site (HTTP, email, AI provider), 130 * including what WordPress itself sends because of 131 * it: pings when a post gets published, the post 132 * author's email when a comment is approved 133 * executes_code outputs markup or script taken from its config 134 * changes_schema changes the content model or API surface (post 135 * types, taxonomies, registered meta, REST routes) 136 */ 137 public const EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests', 'executes_code', 'changes_schema' ]; 138 139 /** Effects that make deactivation an incomplete undo when nothing else is declared. */ 140 private const PERSISTENT_EFFECTS = [ 'writes_database', 'writes_files', 'deletes_data', 'external_requests' ]; 141 142 /** 143 * Effects of one node, as EFFECTS => bool, or null when the primitive 144 * (or the type itself) is unknown or declares none. 145 */ 146 public static function node_effects( string $type, array $config = [] ): ?array { 147 $def = self::get( $type ); 148 if ( ! $def || null === $def['effects'] ) { 149 return null; 150 } 151 $list = $def['effects'] instanceof Closure ? (array) ( $def['effects'] )( $config ) : (array) $def['effects']; 152 if ( array_diff( $list, self::EFFECTS ) ) { 153 self::doing_it_wrong( "Primitive '{$type}' computed unknown effects: " . implode( ', ', array_diff( $list, self::EFFECTS ) ) . '.' ); 154 return null; 155 } 156 return array_combine( self::EFFECTS, array_map( static fn( string $e ): bool => in_array( $e, $list, true ), self::EFFECTS ) ); 157 } 158 159 /** 160 * Every effect the primitive can have with any config: effects_max for a 161 * config-dependent declaration, the static list otherwise. null when 162 * undeclared or when a Closure has no effects_max. 163 * 164 * @return string[]|null 165 */ 166 public static function max_effects( string $type ): ?array { 167 $def = self::get( $type ); 168 if ( ! $def || null === $def['effects'] ) { 169 return null; 170 } 171 if ( $def['effects'] instanceof Closure ) { 172 return is_array( $def['effects_max'] ) ? array_values( $def['effects_max'] ) : null; 173 } 174 return array_values( (array) $def['effects'] ); 175 } 176 177 /** Whether deactivating undoes the primitive with every config (false when any config can't). */ 178 private static function always_reversible( array $def, ?array $max ): bool { 179 if ( null === $max || $def['reversible'] instanceof Closure ) { 180 return false; 181 } 182 if ( null !== $def['reversible'] ) { 183 return (bool) $def['reversible']; 184 } 185 return ! array_intersect( $max, self::PERSISTENT_EFFECTS ); 186 } 187 188 /** 189 * Combined effects of every node in a blueprint (structures, conditions, 190 * actions), whether deactivating it would undo all of them, and which 191 * node types declare no effects (consumers should treat those as risky). 192 * 193 * @return array{effects: array<string,bool>, reversible: bool, undeclared: string[]} 194 */ 195 public static function effects_of( array $blueprint ): array { 196 $effects = array_fill_keys( self::EFFECTS, false ); 197 $reversible = true; 198 $undeclared = []; 199 200 foreach ( [ 'structures', 'conditions', 'actions' ] as $list ) { 201 foreach ( (array) ( $blueprint[ $list ] ?? [] ) as $node ) { 202 $type = (string) ( $node['type'] ?? '' ); 203 $config = (array) ( $node['config'] ?? [] ); 204 $node_effects = self::node_effects( $type, $config ); 205 if ( null === $node_effects ) { 206 $undeclared[] = $type; 207 $reversible = false; 208 continue; 209 } 210 foreach ( $node_effects as $effect => $has ) { 211 $effects[ $effect ] = $effects[ $effect ] || $has; 212 } 213 $reversible = $reversible && self::node_reversible( $type, $config, $node_effects ); 214 } 215 } 216 217 return [ 'effects' => $effects, 'reversible' => $reversible, 'undeclared' => array_values( array_unique( $undeclared ) ) ]; 218 } 219 220 private static function node_reversible( string $type, array $config, array $effects ): bool { 221 $declared = self::$primitives[ $type ]['reversible']; 222 if ( $declared instanceof Closure ) { 223 return (bool) $declared( $config ); 224 } 225 if ( null !== $declared ) { 226 return (bool) $declared; 227 } 228 return ! array_filter( array_intersect_key( $effects, array_flip( self::PERSISTENT_EFFECTS ) ) ); 58 229 } 59 230 … … 120 291 121 292 /** 122 * Build the capability catalog injected into the AI system prompt. 123 * Returns a lightweight array — type, category, description, schema. 293 * Build the capability catalog injected into the AI system prompt and 294 * returned by the clautron/primitives-list ability. 295 * 296 * `safe` false = needs explicit approval before use; `verifiable` = the 297 * primitive declares a verify check, so a smoke test can confirm it. 298 * `effects` is the worst case over every config (null when undeclared or 299 * unknown) and `reversible` is true only when deactivating undoes it with 300 * every config; `effects_vary` = some config values are milder, so ask 301 * effects_of() for the real config (the abilities return it per call). 124 302 * 125 303 * @return array[] … … 128 306 $catalog = []; 129 307 foreach ( self::$primitives as $type => $def ) { 308 $max = self::max_effects( $type ); 130 309 $catalog[] = [ 131 'type' => $type, 132 'category' => $def['category'], 133 'phase' => $def['phase'], 134 'label' => $def['label'], 135 'description' => $def['description'], 136 'schema' => $def['schema'], 137 'dependency' => $def['dependency'], 310 'type' => $type, 311 'category' => $def['category'], 312 'phase' => $def['phase'], 313 'label' => $def['label'], 314 'description' => $def['description'], 315 'schema' => $def['schema'], 316 'dependency' => $def['dependency'], 317 'safe' => (bool) $def['safe'], 318 'verifiable' => is_callable( $def['verify'] ), 319 'effects' => null === $max ? null : array_values( array_intersect( self::EFFECTS, $max ) ), 320 'effects_vary' => $def['effects'] instanceof Closure || $def['reversible'] instanceof Closure, 321 'reversible' => self::always_reversible( $def, $max ), 322 'examples' => array_values( (array) $def['examples'] ), 138 323 ]; 139 324 } -
clautron/trunk/readme.txt
r3691450 r3784837 3 3 Tags: capability, automation, security, no-code, ai 4 4 Requires at least: 7.0 5 Tested up to: 7. 06 Stable tag: 1. 2.05 Tested up to: 7.1 6 Stable tag: 1.5.0 7 7 Requires PHP: 8.1 8 8 License: GPL-2.0-or-later … … 21 21 **One secure layer. Thousands of capabilities.** 22 22 23 Every capability Clautron builds can be activated , deactivated, and rolled back the same predictable way. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes.23 Every capability Clautron builds can be activated and deactivated the same predictable way, without touching any data it wrote while it was on. Before you turn one on, you can see what it reads, what it writes, what it connects to, and how much it changes. 24 24 25 25 **Key features:** … … 28 28 * **Search first, generate second:** Clautron checks what already exists before generating anything new; when it does generate something, you review it before it runs 29 29 * **Know what runs on your site:** every capability is built from a declared, inspectable set of primitives 30 * **Reversible by design:** activate, deactivate, or roll back any capability without digging through code 30 * **Deactivate without data loss:** turn any capability off without digging through code. Nothing it wrote while active is touched, and turning it back on reactivates the same capability instead of starting over 31 * **Machine-readable capability manifest:** each capability can declare its own version, requirements, required permissions, and effects (what it reads, writes, or connects to). External tools like AICOM can inspect and manage capabilities through this same contract, without hardcoded per-capability logic 32 * **Know what a capability does before you turn it on:** every building block declares whether it writes data, deletes data, sends data off the site or outputs code, and whether turning it off fully undoes it 33 * **Security hardening without a security plugin:** stop username discovery, add the missing security headers and hide the WordPress version, each as a small capability you can switch off again 34 * **Ready for AI agents:** every Clautron operation is a WordPress Ability with its own schema, so AI agents and tools like AICOM can find and use them without custom integration code 31 35 * **Scheduled jobs and reports:** recurring automations and on-demand data queries, without a separate plugin for each 32 36 * **Safe by construction:** a safety policy layer blocks dangerous operations before they run … … 51 55 = What are primitives, and what's a capability? = 52 56 53 Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate , deactivate, or roll back as one unit.57 Primitives are the small, reusable building blocks Clautron composes things from: custom post types, meta fields, admin columns, scheduled tasks, and more. A capability is what you actually get: a specific piece of functionality (a redirect, an admin column, a WooCommerce rule) built from one or more primitives, that you can activate or deactivate as one unit. Some capabilities also carry a manifest (a declared version, requirements, and effects) that lets external tools install, inspect, update, and deactivate them without capability-specific code. 54 58 55 59 = Does this plugin store data in the database? = 56 60 57 Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, and execution logs. All data is removed if you uninstall the plugin.61 Yes. Clautron creates its own database tables (prefixed with `wp_clautron_`) to store blueprints, conversation history, scheduled jobs, workflow state, capability install state, and execution logs. All data is removed if you uninstall the plugin. 58 62 59 63 = What permissions are required? = 60 64 61 Clautron is accessible only to administrators (`manage_options` capability). No frontend functionality or public-facing pages are added to your site.65 Clautron's own screens are only available to administrators (`manage_options` capability). Clautron adds nothing to the frontend by itself: visitors only see changes from capabilities you activate, such as a consent banner, a redirect or security headers. 62 66 63 67 == External Services == … … 75 79 76 80 == Changelog == 81 82 = 1.5.0 = 83 * Added harden_user_enumeration: stops anonymous visitors from discovering usernames. Logged-out requests to the /wp/v2/users REST endpoints get a 401 (logged-in users and the block editor keep working), every author archive returns a 404, the users sitemap is removed, oEmbed shows the site instead of the author, and the login form gives the same error for an unknown username and a wrong password. 84 * Added security_headers: sends Referrer-Policy, X-Content-Type-Options, X-Frame-Options and Permissions-Policy, plus Strict-Transport-Security (HSTS) on HTTPS when you opt in. Its check loads your homepage to confirm the headers actually reach visitors, so a page cache or CDN that drops them is reported. 85 * Added hide_wp_version: removes the WordPress, WooCommerce and Elementor generator tags from pages and feeds, and masks the WordPress version in asset URLs while keeping browser caches working. Optionally deletes readme.html, and deletes it again after each WordPress update. 86 * Added a ready-made Security Hardening capability built from these three primitives. 87 * Every Clautron operation (catalog, install, inspect, update, deactivate, blueprints, events) is now a WordPress Ability with its own input schema and behavior annotations, so AI agents and management tools such as AICOM can discover and call them without Clautron-specific code. New: primitive-apply, which applies a single primitive as its own capability, validated and verified. 88 * Every primitive now declares its effects: whether it writes data, writes files, deletes data, sends data off the site, outputs code from its configuration, or changes the content model, and whether turning it off fully undoes it. Installing, applying or ensuring a capability reports these effects, in a dry run too, together with the step that reverses the change. 89 * Fixed ensuring a deactivated custom capability creating a second copy of it: it is now reactivated. 90 * Fixed register_cpt writing the edit_post, read_post and delete_post meta capabilities into the administrator role on every page load. 91 * http_request, fetch_feed, create_post and add_admin_notice are now flagged as needing approval, like the other primitives that can send data off the site, delete data or output code. 92 93 = 1.4.0 = 94 * Added consent_banner: a category-based cookie-consent banner (necessary/analytics/marketing) with an anonymous REST endpoint to record the visitor's choice, a full audit trail (who consented to what, when, from which source) in the events store, and automatic opt-out when the visitor's browser sends the Global Privacy Control (Sec-GPC) signal. 95 * Added gate_script_by_consent: outputs a script or HTML block only once its required consent category has been granted, reading the same cookie consent_banner writes. 96 * consent_banner can emit Google Consent Mode v2 signals (gtag consent default/update), mapping consent categories to Google's ad_storage/analytics_storage/ad_user_data/ad_personalization signals for GA4 and Ads. 97 * Fixed handle_upload's internal failure closure declaring a standalone `false` return type, valid only since PHP 8.2, which fataled on activation despite the plugin declaring Requires PHP 8.1. 98 99 = 1.3.0 = 100 * Added a machine-readable capability manifest (version, platform/PHP/WordPress requirements, required permissions, declared effects, reversibility), kept separate from a capability's blueprint and joined only by capability_id. Lets external tools like AICOM install, inspect, update, and deactivate capabilities through one consistent contract instead of capability-specific code. 101 * Added a new `wp_clautron_capabilities` table tracking each capability's installed version and status separately from its blueprint. This enables comparing installed vs. available version and reinstalling a deactivated capability without losing its edit history. 102 * Added a deactivate operation: stops a capability from being re-applied on future requests without touching any data it wrote while active. Reinstalling afterward reactivates the same capability rather than creating a duplicate. 103 * Added an update operation: re-syncs an installed capability to its manifest's current declared version in place. 104 * Primitive verification (used to confirm a compiled capability is correctly wired into WordPress) is no longer a fixed, hardcoded checklist covering a handful of primitive types: each primitive now declares its own check, and any primitive without one is reported explicitly rather than silently skipped. 105 * Fixed the metabox verification check to look up the actual registered meta box ID (it was checking an ID that never matched, so it could never report success). 77 106 78 107 = 1.2.0 = … … 132 161 == Upgrade Notice == 133 162 163 = 1.5.0 = 164 Adds security hardening primitives, WordPress Abilities for every operation, and declared effects for every primitive. No breaking changes. 165 166 = 1.3.0 = 167 Adds a capability manifest, deactivate/update operations, and per-primitive verification. No breaking changes. 168 134 169 = 1.1.0 = 135 170 Adds Elementor and WordPress widget primitives. No breaking changes. -
clautron/trunk/uninstall.php
r3582869 r3784837 16 16 17 17 delete_option( 'clautron_settings' ); 18 delete_option( 'clautron_readme_removed_for' ); // hide_wp_version's remove_readme bookkeeping 18 19 delete_transient( 'clautron_active_workflows' );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)