PluginProbe
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor / 2.0.13
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor v2.0.13
2.0.13 2.0.12 2.0.11 2.0.10 2.0.9 trunk 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8
blockenberg / assets / php / ai-external.php

ai-external.php in Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor 2.0.13, at assets/php/ai-external.php

275 lines 24.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /** Temporary, page-scoped external agent sessions. No provider credentials leave WordPress. */
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 require_once __DIR__ . '/ai-conversations.php';
6
7 function bkbg_external_error( $message, $status = 400 ) {
8 return new WP_Error( 'bkbg_external', $message, array( 'status' => $status ) );
9 }
10 function bkbg_external_key( $id ) { return 'bkbg_ext_' . $id; }
11 function bkbg_external_tools() {
12 return array( 'search_blocks', 'get_block_schema', 'get_page_structure', 'get_block_details', 'build_section', 'insert_blocks', 'insert_html_section', 'update_block', 'replace_block', 'remove_blocks', 'move_block', 'set_post_title', 'set_custom_css', 'unwrap_block', 'create_image', 'capture_section' );
13 }
14 function bkbg_external_read( $id ) {
15 if ( ! is_string( $id ) || ! preg_match( '/^[a-f0-9]{32}$/D', $id ) ) { return false; }
16 $session = get_option( bkbg_external_key( $id ) );
17 if ( ! is_array( $session ) ) { return false; }
18 if ( $session['expires'] <= time() || ! user_can( $session['user'], 'edit_post', $session['post'] ) ) {
19 delete_option( bkbg_external_key( $id ) );
20 return false;
21 }
22 return $session;
23 }
24 /** Compare-and-swap prevents concurrent claims, duplicate delivery and lost acknowledgements. */
25 function bkbg_external_change( $id, $change ) {
26 global $wpdb;
27 for ( $attempt = 0; $attempt < 5; $attempt++ ) {
28 $old = bkbg_external_read( $id );
29 if ( ! $old ) { return bkbg_external_error( 'Session expired or disconnected.', 410 ); }
30 $next = $old;
31 $result = $change( $next );
32 if ( is_wp_error( $result ) ) { return $result; }
33 if ( $next === $old ) { return $result; }
34 $updated = $wpdb->query( $wpdb->prepare(
35 "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s",
36 maybe_serialize( $next ), bkbg_external_key( $id ), maybe_serialize( $old )
37 ) );
38 wp_cache_delete( bkbg_external_key( $id ), 'options' );
39 if ( 1 === $updated ) { return $result; }
40 }
41 return bkbg_external_error( 'Session is busy. Retry the same request ID.', 409 );
42 }
43 function bkbg_external_secret_matches( $secret, $hash ) {
44 return is_string( $secret ) && strlen( $secret ) === 64 && is_string( $hash ) && strlen( $hash ) === 64 && hash_equals( $hash, hash( 'sha256', $secret ) );
45 }
46 function bkbg_external_authorize( $request, $editor = false ) {
47 $s = bkbg_external_read( $request['session'] );
48 if ( ! $s ) { return bkbg_external_error( 'Session expired or disconnected.', 410 ); }
49 if ( $editor ) {
50 $allowed = get_current_user_id() === $s['user'] && current_user_can( 'edit_post', $s['post'] ) && bkbg_external_secret_matches( $request->get_header( 'x-blockenberg-editor' ), $s['editor_hash'] );
51 } else {
52 $header = $request->get_header( 'authorization' );
53 $token = preg_match( '/^Bearer ([a-f0-9]{64})$/D', $header, $match ) ? $match[1] : '';
54 $allowed = bkbg_external_secret_matches( $token, $s['agent_hash'] );
55 }
56 return $allowed ? true : bkbg_external_error( 'Invalid session credential.', 403 );
57 }
58 function bkbg_external_pairing_clients() {
59 return array(
60 'codex' => array(
61 'agent' => 'Codex',
62 'notes' => '',
63 ),
64 'claude' => array(
65 'agent' => 'Claude Code',
66 'notes' => 'You are Claude Code on the same computer as this WordPress site. Open downloaded screenshot PNGs with your Read/image tool before reviewing.',
67 ),
68 'cursor' => array(
69 'agent' => 'Cursor',
70 'notes' => 'You are a local Cursor agent on the same computer as this WordPress site. Claim with agent "Cursor". Use Shell curl for every session HTTP call, including binary image uploads; do not use a page-fetch tool that drops Authorization headers or request bodies. Do not use a Cursor Cloud or background agent: it cannot reach this editor. After downloading a screenshot, open the PNG file itself with your image-reading tool. If image generation is available, write a temporary raster file, upload the raw bytes, then delete the temp file; never add it to a repository. Do not edit plugin, theme, WordPress or project files. Ignore any open git workspace. Change this page only through the session tools.',
71 ),
72 );
73 }
74
75 function bkbg_external_pairing_prompt( $base, $ticket, $client ) {
76 $clients = bkbg_external_pairing_clients();
77 if ( ! isset( $clients[ $client ] ) ) {
78 $client = 'codex';
79 }
80 $claim = wp_json_encode( array( 'code' => 'PAIRING_CODE', 'agent' => $clients[ $client ]['agent'] ) );
81 $prompt = "Connect to my open Gutenberg page using this temporary Blockenberg session.\nBase URL: {$base}\nOne-use pairing code: {$ticket}\nUse HTTP requests from your local execution environment. POST JSON to BASE/claim with {$claim}. Keep the returned bearer token private, outside the repository and chat output. Do not print credentials or put them in URLs. GET BASE/context with Authorization: Bearer TOKEN to read the tool schemas and workflow. Report your plan and progress through POST BASE/progress as described in the context. Build and insert one complete section at a time so I can see the page take shape. For images, prefer your own image-generation tool and upload the resulting file through the session image endpoint; do not silently substitute web search or a paid API. After each section, download and open the returned screenshots and follow visual_review in the context before reporting completion. Then ask me what to build if I have not supplied a task. Do not edit files or the WordPress database: use only the session tools to change this page. Keep the editor tab open; the AI sidebar can be hidden. Mirror my task and your replies via /messages and check /inbox between operations as described in /context. This session expires after one hour. Do not start an indefinite waiting loop when the task is finished.";
82 if ( $clients[ $client ]['notes'] ) {
83 $prompt .= "\n" . $clients[ $client ]['notes'];
84 }
85 return $prompt;
86 }
87
88 function bkbg_external_create( $request ) {
89 $host = wp_parse_url( home_url(), PHP_URL_HOST );
90 if ( ! is_ssl() && ! preg_match( '/(^localhost$|^127\.0\.0\.1$|\.(local|test)$)/i', (string) $host ) ) {
91 return bkbg_external_error( 'External sessions require HTTPS, except on local development sites.', 400 );
92 }
93 $post = absint( $request['post_id'] );
94 if ( ! $post || ! current_user_can( 'edit_post', $post ) || ! get_post( $post ) ) { return bkbg_external_error( 'You cannot edit this page.', 403 ); }
95 $scope = $request['scope'];
96 if ( ! is_array( $scope ) || ! in_array( $scope['mode'] ?? '', array( 'page', 'selection' ), true ) || ! isset( $scope['rootIds'] ) || ! is_array( $scope['rootIds'] ) || ( 'selection' === $scope['mode'] && ! count( $scope['rootIds'] ) ) ) {
97 return bkbg_external_error( 'Choose a page or a block selection.' );
98 }
99 if ( count( $scope['rootIds'] ) > 100 || array_filter( $scope['rootIds'], function ( $id ) { return ! is_string( $id ) || strlen( $id ) > 100; } ) || ! is_string( $request['instructions'] ) || ! is_array( $request['tools'] ) ) {
100 return bkbg_external_error( 'Invalid editor context.' );
101 }
102 if ( strlen( $request->get_body() ) > 500000 ) { return bkbg_external_error( 'Session context is too large.', 413 ); }
103 $id = bin2hex( random_bytes( 16 ) );
104 $ticket = bin2hex( random_bytes( 32 ) );
105 $editor = bin2hex( random_bytes( 32 ) );
106 $s = array(
107 'user' => get_current_user_id(), 'post' => $post, 'scope' => $scope,
108 'chat_id' => bkbg_chat_valid_id( $request['chat_id'] ) ? $request['chat_id'] : '', 'messages' => array(),
109 'expires' => time() + HOUR_IN_SECONDS, 'heartbeat' => time(),
110 'editor_hash' => hash( 'sha256', $editor ), 'ticket_hash' => hash( 'sha256', $ticket ), 'agent_hash' => '',
111 'agent' => '', 'commands' => array(), 'images' => array(), 'progress' => null, 'agent_seen' => 0, 'instructions' => (string) $request['instructions'],
112 'tools' => array_values( array_filter( (array) $request['tools'], function ( $tool ) {
113 return is_array( $tool ) && in_array( $tool['function']['name'] ?? '', bkbg_external_tools(), true );
114 } ) ),
115 );
116 add_option( bkbg_external_key( $id ), $s, '', false );
117 $slot = 'bkbg_ext_owner_' . $s['user'] . '_' . $post;
118 $previous = get_option( $slot );
119 update_option( $slot, $id, false );
120 if ( $previous ) { delete_option( bkbg_external_key( $previous ) ); }
121 wp_schedule_single_event( $s['expires'], 'bkbg_external_cleanup', array( $id ) );
122 $base = rest_url( 'blockenberg/v1/ai/external/' . $id );
123 $prompts = array();
124 foreach ( array_keys( bkbg_external_pairing_clients() ) as $client ) {
125 $prompts[ $client ] = bkbg_external_pairing_prompt( $base, $ticket, $client );
126 }
127 return array(
128 'id' => $id,
129 'editor_token' => $editor,
130 'expires' => $s['expires'],
131 'url' => $base,
132 'prompt' => $prompts['codex'],
133 'prompts' => $prompts,
134 );
135 }
136 function bkbg_external_claim( $request ) {
137 return bkbg_external_change( $request['session'], function ( &$s ) use ( $request ) {
138 if ( ! bkbg_external_secret_matches( $request['code'], $s['ticket_hash'] ) ) { return bkbg_external_error( 'Pairing code is invalid or already used.', 403 ); }
139 if ( time() - $s['heartbeat'] > 15 ) { return bkbg_external_error( 'The editor is offline. Open the AI sidebar.', 409 ); }
140 $token = bin2hex( random_bytes( 32 ) );
141 $s['ticket_hash'] = '';
142 $s['agent_seen'] = time();
143 $s['agent_hash'] = hash( 'sha256', $token );
144 $s['agent'] = substr( sanitize_text_field( $request['agent'] ?: 'External agent' ), 0, 80 );
145 return array( 'token' => $token, 'expires' => $s['expires'], 'post_id' => $s['post'] );
146 } );
147 }
148 function bkbg_external_context( $request ) {
149 $s = bkbg_external_read( $request['session'] );
150 if ( ! $s ) { return bkbg_external_error( 'Session expired.', 410 ); }
151 return array( 'post_id' => $s['post'], 'scope' => $s['scope'], 'expires' => $s['expires'], 'editor_online' => time() - $s['heartbeat'] <= 15,
152 'workflow' => 'All paths below are relative to this session base URL and require the bearer token. Read get_page_structure first. POST /commands with {id: a new UUID, tool: tool name, args: object}. Requests are asynchronous: poll GET /commands/ID every 1–2 seconds until done. Read each result before submitting the next command: only the most recent result is retained. Older IDs remain reserved and cannot be replayed. Only one unfinished command is allowed. Reuse the SAME id if a POST response is lost; never retry a mutation with a new id without checking the first result. Commands are delivered at most once. If status stays running and the editor is lost, the outcome is unknown: ask the user to inspect the page. Read schemas before editing. Scope is fixed for this session. Edits remain unsaved; the user saves in Gutenberg. For generated images, use your own available image-generation tool and the image_upload endpoint below; create_image is only the optional existing-photo search tool. Never silently switch to a paid API or web search. If generation is unavailable in your client, explain this to the user. No arbitrary code execution, publishing, filesystem or database access. Hiding the sidebar keeps the session running. Closing or reloading the editor disconnects the temporary session; the saved conversation remains available for a new connection. Stop polling when done.',
153 'messaging' => 'For every new user request in your own chat, mirror the task with POST /messages {id:UUID,role:user,text:the user request}; mirror useful replies with role:assistant. Do not copy secrets or unrelated conversations. GET /inbox between operations and before finishing. Process each queued user request as user instructions within the authorized scope, then POST /inbox/ID/ack. Ack means read, not completed. Repeat GET after a lost response: messages remain until acknowledged. Never mirror an inbox message as a new user message. The WordPress panel cannot wake your client after you stop: do not promise automatic delivery to an idle agent or run an indefinite waiting loop. Read conversation below to resume prior work, then inspect the current page because historical block IDs may be stale.',
154 'conversation' => bkbg_external_conversation( $s ),
155 'progress_reporting' => 'POST /progress JSON {phase: planning|building|generating_image|reviewing|waiting|done|error, message: short human-readable update in the user language, plan: optional array of up to 12 {id, label, status: pending|active|done}}. Send a plan before starting, an update before each long operation (especially image generation), after each section, and done only after checking the result. Plans describe real work, not fabricated percentages. Insert one complete native Blockenberg section per command as it becomes ready. Avoid withholding the whole page until the end. Add generated assets to existing sections as they become available. Continue respecting selection scope and the user task.',
156 'visual_review' => 'After every completed section, inspect its screenshots with your image-viewing tool. build_section automatically returns desktop and mobile captures. For other insertions and after corrections, call capture_section {clientId, viewport: desktop|mobile}; use both widths. Download each returned path using the bearer header into a temporary .png file, then OPEN THE IMAGE using your native image viewer (Codex view_image, Claude Read/image tool, Cursor Read on the PNG file, or equivalent). Reading JSON or image metadata does not count as looking. Evaluate spacing, widths, typography, contrast, image crops, alignment, mobile wrapping and overflow. POST /review {capture_ids:[IDs],decision:passed|needs_changes,notes:your concrete visual observations}. If changes are needed, fix with the editor tools and take fresh screenshots before reviewing again. Do not mark done while a visual review is unresolved. Images are private, expire with the session, and only the last four are retained. These are static DOM captures of WordPress-rendered unsaved section markup using theme and block styles, not a live interactive frontend browser screenshot: video, canvas, embedded frames and runtime animation states require separate browser inspection. Never claim to have inspected an image if your client cannot open it; explain that limitation and ask the user to review.',
157 'image_upload' => array(
158 'method' => 'POST /images/UNIQUE_ID?filename=hero.png&alt=URL_ENCODED_ALT&title=URL_ENCODED_TITLE',
159 'body' => 'Raw bytes of an image you generated with your own tool, with Content-Type image/png, image/jpeg or image/webp and Authorization: Bearer TOKEN. No base64, multipart, URLs, SVG or executable files. Maximum 20 MB and 40 megapixels; server limits may be lower. Keep the token out of command output and URLs.',
160 'result' => 'Returns status done and image {id,url,alt,width,height,mime}. Use these in native block attributes after reading the schema: image ID and URL for photos, background URL for section backgrounds. Uploading does not insert a block or save the page.',
161 'retry' => 'Retry exactly the SAME ID, bytes and metadata after a lost response, or GET /images/ID. Never use a new ID to retry an uncertain upload. One image upload at a time. No OpenRouter requests are made. Generate actual raster assets with your image tool; do not substitute procedural drawings for requested generated images.',
162 ),
163 'instructions' => $s['instructions'] . '
164 External-session image workflow overrides the generic create_image advice above: prefer original images from your own image-generation tool and upload them via image_upload. create_image only searches existing photos when requested. Use the uploaded URL and ID in the matching native image or background attributes. Report progress and insert complete sections incrementally as described above.', 'tools' => $s['tools'] );
165 }
166 function bkbg_external_submit( $request ) {
167 $id = $request['id']; $tool = $request['tool']; $args = $request['args'];
168 if ( ! is_string( $id ) || ! preg_match( '/^[a-zA-Z0-9_-]{8,80}$/D', $id ) || ! in_array( $tool, bkbg_external_tools(), true ) || ( ! is_array( $args ) && ! is_object( $args ) ) ) { return bkbg_external_error( 'Supply an id, a supported tool and object args.' ); }
169 if ( strlen( $request->get_body() ) > 128000 ) { return bkbg_external_error( 'Command is too large.', 413 ); }
170 $args = (array) $args;
171 if ( 'create_image' === $tool ) {
172 if ( ! empty( $args['source'] ) && 'search' !== $args['source'] ) { return bkbg_external_error( 'create_image only searches existing photos. Generate with your own image tool, then use the session image upload endpoint.' ); }
173 $args['source'] = 'search';
174 }
175 $fingerprint = hash( 'sha256', wp_json_encode( array( $tool, $args ) ) );
176 return bkbg_external_change( $request['session'], function ( &$s ) use ( $id, $tool, $args, $fingerprint ) {
177 foreach ( $s['commands'] as $command ) {
178 if ( $command['id'] === $id ) {
179 return $command['fingerprint'] === $fingerprint ? array( 'id' => $id, 'status' => $command['status'] ) : bkbg_external_error( 'This id belongs to another command.', 409 );
180 }
181 }
182 if ( time() - $s['heartbeat'] > 15 ) { return bkbg_external_error( 'The editor is offline. Open the AI sidebar.', 409 ); }
183 foreach ( $s['commands'] as $command ) {
184 if ( 'done' !== $command['status'] ) { return bkbg_external_error( 'Wait for the previous command.', 409 ); }
185 }
186 // Keep IDs for the full session so retries can never replay an older mutation.
187 if ( count( $s['commands'] ) >= 500 ) { return bkbg_external_error( 'Session command capacity reached. Start a new session.', 409 ); }
188 foreach ( $s['commands'] as &$old ) { unset( $old['result'], $old['args'] ); } unset( $old );
189 $s['agent_seen'] = time();
190 if ( in_array( $tool, array( 'build_section', 'insert_blocks', 'insert_html_section', 'update_block', 'replace_block', 'remove_blocks', 'move_block', 'set_custom_css', 'unwrap_block' ), true ) ) { $s['visual_revision'] = ( $s['visual_revision'] ?? 0 ) + 1; }
191 bkbg_external_run_clock( $s );
192 $s['commands'][] = array( 'id' => $id, 'tool' => $tool, 'args' => $args, 'fingerprint' => $fingerprint, 'status' => 'queued' );
193 return array( 'id' => $id, 'status' => 'queued' );
194 } );
195 }
196 function bkbg_external_result( $request ) {
197 $s = bkbg_external_read( $request['session'] );
198 if ( ! $s ) { return bkbg_external_error( 'Session expired.', 410 ); }
199 foreach ( $s['commands'] as $command ) {
200 if ( $command['id'] === $request['command'] ) { return array( 'id' => $command['id'], 'status' => $command['status'], 'result' => $command['result'] ?? null, 'result_retained' => isset( $command['result'] ) ); }
201 }
202 return bkbg_external_error( 'Unknown command.', 404 );
203 }
204 function bkbg_external_poll( $request ) {
205 if ( strlen( $request->get_body() ) > 600000 ) { return bkbg_external_error( 'Tool result is too large.', 413 ); }
206 return bkbg_external_change( $request['session'], function ( &$s ) use ( $request ) {
207 $s['heartbeat'] = time();
208 $ack = $request['ack'];
209 if ( is_array( $ack ) && isset( $ack['id'] ) ) {
210 foreach ( $s['commands'] as &$command ) {
211 if ( $command['id'] === $ack['id'] && 'running' === $command['status'] ) {
212 $command['status'] = 'done'; $command['result'] = $ack['result'] ?? array( 'error' => 'No result supplied.' );
213 $command['failed'] = ! empty( $command['result']['error'] );
214 }
215 } unset( $command );
216 }
217 $delivery = null;
218 foreach ( $s['commands'] as &$command ) {
219 if ( 'queued' === $command['status'] ) { $command['status'] = 'running'; $delivery = $command; break; }
220 } unset( $command );
221 $done = 0; $sections = 0;
222 foreach ( $s['commands'] as $item ) {
223 if ( 'done' === $item['status'] ) { $done++; }
224 if ( 'done' === $item['status'] && 'build_section' === $item['tool'] && empty( $item['failed'] ) ) { $sections++; }
225 }
226 $images = array_values( array_map( function ( $item ) { return $item['image']; }, array_filter( $s['images'] ?? array(), function ( $item ) { return 'done' === $item['status']; } ) ) );
227 return array( 'agent' => $s['agent'], 'expires' => $s['expires'], 'command' => $delivery,
228 'progress' => $s['progress'] ?? null, 'agent_seen' => $s['agent_seen'] ?? 0, 'server_time' => microtime( true ), 'run' => $s['run'] ?? null,
229 'visual_review' => $s['visual_review'] ?? null,
230 'messages' => array_values( $s['messages'] ?? array() ),
231 'counts' => array( 'actions' => count( $s['commands'] ), 'completed' => $done, 'sections' => $sections, 'images' => count( $images ) ),
232 'images' => array_slice( $images, -4 ) );
233 } );
234 }
235 require_once __DIR__ . '/ai-external-messages.php';
236 require_once __DIR__ . '/ai-external-media.php';
237 require_once __DIR__ . '/ai-external-capture.php';
238
239 function bkbg_external_revoke( $request ) {
240 $s = bkbg_external_read( $request['session'] );
241 if ( $s ) {
242 foreach ( $s['messages'] ?? array() as $message ) {
243 if ( 'queued' === $message['status'] ) { $message['status'] = 'interrupted'; bkbg_chat_append( $s, $message ); }
244 }
245 }
246 delete_option( bkbg_external_key( $request['session'] ) );
247 return array( 'disconnected' => true );
248 }
249 add_action( 'bkbg_external_cleanup', function ( $id ) { delete_option( bkbg_external_key( $id ) ); } );
250 add_action( 'rest_api_init', function () {
251 $prefix = '/ai/external';
252 register_rest_route( 'blockenberg/v1', $prefix, array( 'methods' => 'POST', 'callback' => 'bkbg_external_create', 'permission_callback' => function () { return bkbg_ai_user_can_use(); } ) );
253 $base = $prefix . '/(?P<session>[a-f0-9]{32})';
254 register_rest_route( 'blockenberg/v1', $base . '/claim', array( 'methods' => 'POST', 'callback' => 'bkbg_external_claim', 'permission_callback' => '__return_true' ) );
255 foreach ( array( 'context' => array( 'GET', 'bkbg_external_context' ), 'progress' => array( 'POST', 'bkbg_external_progress' ), 'review' => array( 'POST', 'bkbg_external_review_capture' ), 'commands' => array( 'POST', 'bkbg_external_submit' ), 'commands/(?P<command>[a-zA-Z0-9_-]{8,80})' => array( 'GET', 'bkbg_external_result' ) ) as $route => $spec ) {
256 register_rest_route( 'blockenberg/v1', $base . '/' . $route, array( 'methods' => $spec[0], 'callback' => $spec[1], 'permission_callback' => function ( $r ) { return bkbg_external_authorize( $r ); } ) );
257 }
258 register_rest_route( 'blockenberg/v1', $base . '/images/(?P<upload>[a-zA-Z0-9_-]{8,80})', array(
259 array( 'methods' => 'POST', 'callback' => 'bkbg_external_upload_image', 'permission_callback' => function ( $r ) { return bkbg_external_authorize( $r ); } ),
260 array( 'methods' => 'GET', 'callback' => 'bkbg_external_image_result', 'permission_callback' => function ( $r ) { return bkbg_external_authorize( $r ); } ),
261 ) );
262 register_rest_route( 'blockenberg/v1', $base . '/captures/(?P<capture>[a-zA-Z0-9_-]{8,100})', array(
263 array( 'methods' => 'POST', 'callback' => 'bkbg_external_save_capture', 'permission_callback' => function ( $r ) { return bkbg_external_authorize( $r, true ); } ),
264 array( 'methods' => 'GET', 'callback' => 'bkbg_external_get_capture', 'permission_callback' => function ( $r ) { return bkbg_external_authorize( $r ); } ),
265 ) );
266 foreach ( array( 'render-section' => 'bkbg_external_render_section', 'poll' => 'bkbg_external_poll', 'revoke' => 'bkbg_external_revoke' ) as $route => $callback ) {
267 register_rest_route( 'blockenberg/v1', $base . '/' . $route, array( 'methods' => 'POST', 'callback' => $callback, 'permission_callback' => function ( $r ) { return bkbg_external_authorize( $r, true ); } ) );
268 }
269 } );
270 // Never let a browser or proxy cache a session credential, context or tool result.
271 add_filter( 'rest_post_dispatch', function ( $response, $server, $request ) {
272 if ( strpos( $request->get_route(), '/blockenberg/v1/ai/external' ) === 0 && $response instanceof WP_REST_Response ) { $response->header( 'Cache-Control', 'no-store, private' ); }
273 return $response;
274 }, 10, 3 );
275