configure_service_locked($base, $nonce)); } function configure_service_locked($base, $nonce) { admin_authorize($nonce); demand(!defined('PUBLISHER_CONNECTOR_SERVICE_BASE'), 'configuration_locked', 409); demand(in_array(binding_read()->state, ['disconnected', 'revoked'], true), 'disconnect_required', 409); validate_base($base); demand(wp_http_validate_url($base) !== false, 'invalid_base'); $before = option_read('publisher_connector_service_base'); if ($before === $base) return; if ($before === null) demand(add_option('publisher_connector_service_base', $base, '', false), 'state_conflict', 409); else option_cas('publisher_connector_service_base', $before, $base); demand(option_read('publisher_connector_service_base') === $base, 'storage_unavailable', 503); } function category_exists($id) { wp_cache_delete($id, 'terms'); $term = get_term($id, 'category'); return $term && !is_wp_error($term); } /** Local administrators pair the site; remote content never runs as one. */ function content_execution_user_allowed($id) { clean_user_cache($id); $user = get_userdata($id); if (!$user || in_array('administrator', $user->roles, true) || (is_multisite() && is_super_admin($id))) return false; foreach (['manage_options', 'edit_users', 'create_users', 'delete_users', 'promote_users', 'remove_users', 'install_plugins', 'activate_plugins', 'update_plugins', 'delete_plugins', 'edit_plugins', 'install_themes', 'switch_themes', 'update_themes', 'delete_themes', 'edit_themes', 'update_core', 'manage_network', 'manage_network_options', 'manage_sites'] as $cap) { if (!empty($user->allcaps[$cap]) || user_can($user, $cap)) return false; } return user_can($user, 'edit_posts'); } /** Capabilities needed by post/media APIs only; no unfiltered HTML or custom admin caps. */ function content_execution_capabilities() { return ['read', 'edit_posts', 'edit_others_posts', 'edit_published_posts', 'edit_private_posts', 'publish_posts', 'read_private_posts', 'upload_files']; } function validate_policy($policy) { schema('policy', $policy); foreach (['allowed_author_ids', 'allowed_category_ids', 'capabilities'] as $key) { $sorted = $policy->{$key}; sort($sorted); demand($sorted === $policy->{$key}, 'policy_unsorted'); } demand(in_array('site.read', $policy->capabilities, true) && in_array('operation.read', $policy->capabilities, true), 'read_grants_required'); demand(content_execution_user_allowed($policy->execution_user_id), 'execution_user_denied', 403); foreach ($policy->allowed_author_ids as $id) demand(get_userdata($id) && user_can($id, 'edit_posts'), 'author_denied', 403); foreach ($policy->allowed_category_ids as $id) demand(category_exists($id), 'category_denied', 403); return $policy; } function can_create_posts($user) { $type = get_post_type_object('post'); return $type && isset($type->cap->create_posts) && is_string($type->cap->create_posts) && user_can($user, $type->cap->create_posts); } function effective_policy($b) { $p = clone $b->policy; $user = $p->execution_user_id; clean_user_cache($user); $content_user = content_execution_user_allowed($user); foreach ($p->allowed_author_ids as $author) clean_user_cache($author); $p->capabilities = array_values(array_filter($p->capabilities, function ($cap) use ($user, $content_user) { if (!in_array($cap, ['site.read', 'operation.read'], true) && !$content_user) return false; if ($cap === 'media.upload') return user_can($user, 'upload_files'); if ($cap === 'draft.create') return user_can($user, 'edit_posts') && can_create_posts($user); if ($cap === 'draft.update') return user_can($user, 'edit_posts'); if ($cap === 'published.update') return user_can($user, 'edit_posts'); if ($cap === 'publication.publish') return user_can($user, 'edit_posts') && user_can($user, 'publish_posts'); return user_can($user, 'read'); })); $p->allowed_author_ids = array_values(array_filter($p->allowed_author_ids, function ($id) use ($user) { return get_userdata($id) && user_can($id, 'edit_posts') && ($id === $user || user_can($user, 'edit_others_posts')); })); if (($p->category_scope ?? null) === 'all_accessible') { $ids = get_terms(['taxonomy' => 'category', 'hide_empty' => false, 'fields' => 'ids']); demand(!is_wp_error($ids) && count($ids) <= 10000, 'category_scope_unavailable', 503); $p->allowed_category_ids = array_map('intval', $ids); sort($p->allowed_category_ids, SORT_NUMERIC); } $p->allowed_category_ids = array_values(array_filter($p->allowed_category_ids, function ($id) use ($user) { return category_exists($id) && user_can($user, 'assign_term', $id); })); return $p; } function current_binding($expected = null) { storage_ready(); $b = binding_read(); demand(in_array($b->state, ['pending_consent', 'consented', 'active'], true), 'authentication_failed', 401); $identity_changed = $b->installation_id !== option_read('publisher_connector_installation') || ($b->api_base !== rest_url()) || (defined('PUBLISHER_CONNECTOR_API_BASE') && $b->api_base !== PUBLISHER_CONNECTOR_API_BASE); if ($identity_changed) { revoke_local(); throw new Failure('authentication_failed', 401); } demand($b->api_base === api_base(), 'authentication_failed', 401); demand(semantic_hash($b->policy) === $b->policy_hash, 'policy_changed', 403); if ($b->state !== 'active') demand(utc_seconds($b->expires_at) >= microtime(true), 'authentication_failed', 401); if ($expected) foreach (['tenant_id', 'site_id', 'installation_id', 'connection_id', 'key_id', 'policy_hash'] as $key) demand($b->{$key} === $expected->{$key}, 'binding_changed', 403); return $b; } function service_post($route, $body, $headers = []) { $response = wp_safe_remote_post(service_base() . ltrim($route, '/'), ['timeout' => 15, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 65536, 'headers' => $headers + ['Content-Type' => 'application/json'], 'body' => canonical($body), 'cookies' => []]); demand(!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200, 'service_outcome_unknown', 503); return wp_remote_retrieve_body($response); } function admin_exchange($code, $policy, $nonce) { admin_authorize($nonce); return with_setup_lock(fn() => exchange_locked($code, $policy, $nonce)); } function exchange_locked($code, $policy, $nonce) { admin_authorize($nonce); consume_rate_limit(hash('sha256', option_read('publisher_connector_installation') . '/admin/' . get_current_user_id()), 'bootstrap_user', 10, 600); // REMOTE_ADDR is the direct peer; never trust a caller-supplied forwarding header. $peer = $_SERVER['REMOTE_ADDR'] ?? 'local'; consume_rate_limit(hash_hmac('sha256', 'bootstrap-peer/' . $peer, independent_secret_key(RATE_KEY_OPTION, true)), 'bootstrap_ip', 30, 600); validate_policy($policy); secret_key_prerequisite(); service_base(); $before = binding_read(); demand(in_array($before->state, ['disconnected', 'revoked'], true), 'disconnect_required', 409); $request = obj(['protocol_version' => 1, 'code' => $code, 'installation_id' => option_read('publisher_connector_installation'), 'api_base' => api_base(), 'policy' => $policy]); schema('codeExchange', $request); // Reserve the admin action before sending a one-use code. Never automatically retry. demand(journal_insert(hash('sha256', $request->installation_id), 'exchange:' . hash('sha256', $code), obj(['state' => 'executing', 'at' => utc_now()])), 'exchange_already_attempted', 409); $b = schema('codeExchangeResponse', parse_json(service_post('/connector-pairings/exchange', $request))); demand($b->installation_id === $request->installation_id && $b->api_base === $request->api_base && canonical($b->policy) === canonical($policy) && $b->policy_hash === semantic_hash($policy), 'exchange_binding_mismatch'); if (isset($before->tenant_id) && $before->tenant_id === $b->tenant_id && $before->site_id === $b->site_id && canonical($before->policy) !== canonical($policy)) { demand($policy->revision > $before->policy->revision, 'policy_revision_required'); } demand(utc_seconds($b->expires_at) > microtime(true) && utc_seconds($b->expires_at) <= time() + 600, 'exchange_expiry'); $secret = base64_decode(strtr($b->secret, '-_', '+/') . '=', true); demand(is_string($secret) && strlen($secret) === 32 && rtrim(strtr(base64_encode($secret), '+/', '-_'), '=') === $b->secret, 'invalid_secret'); unset($b->secret); $b->encrypted_secret = secret_seal($secret, $b); clear_secret($secret); $b->state = 'pending_consent'; $b->consent_hash = null; $b->challenge_hash = null; $b->confirmation_key = null; option_cas('publisher_connector_binding', $before, $b); return pairing_status($b); } function secret_key_prerequisite() { encryption_key(); } function admin_consent($nonce) { admin_authorize($nonce); $before = current_binding(); demand($before->state === 'pending_consent', 'consent_already_recorded', 409); validate_policy($before->policy); $after = clone $before; $record = pick($before, ['tenant_id', 'site_id', 'installation_id', 'connection_id', 'api_base', 'policy_hash']); $record->admin_user_id = get_current_user_id(); $record->consented_at = utc_now(); $after->consent_hash = semantic_hash($record); $after->challenge = bin2hex(random_bytes(32)); $after->challenge_hash = hash('sha256', $after->challenge); $after->admin_user_id = $record->admin_user_id; $after->consented_at = $record->consented_at; $after->state = 'consented'; option_cas('publisher_connector_binding', $before, $after); $notice = clone $record; $notice->protocol_version = 1; $notice->operation = 'pairing.consent'; $notice->consent_hash = $after->consent_hash; $notice->challenge = $after->challenge; $notice->challenge_hash = $after->challenge_hash; $notice->expires_at = $after->expires_at; $notice->idempotency_key = semantic_hash(pick($notice, ['operation', 'connection_id', 'consent_hash', 'challenge_hash'])); schema('consentNotice', $notice); $route = '/connector-pairings/' . $after->connection_id . '/consent'; $headers = sign_headers($after, secret_open($after), 'POST', $route, canonical($notice), $notice->idempotency_key, 'publisher-service-control-v1', service_base()); service_post($route, $notice, $headers); return pairing_status($after); } function pairing_status($b) { return schema('pairingStatus', pick($b, ['protocol_version', 'tenant_id', 'site_id', 'installation_id', 'connection_id', 'key_id', 'api_base', 'state', 'policy_hash', 'expires_at', 'consent_hash', 'challenge_hash', 'confirmation_key'])); } function confirm_pairing($b, $body) { global $wpdb; $expected = semantic_hash(pick($body, ['operation', 'tenant_id', 'site_id', 'installation_id', 'connection_id', 'policy_hash', 'consent_hash', 'challenge_hash'])); demand($body->idempotency_key === $expected && hash('sha256', $body->challenge) === $body->challenge_hash, 'confirmation_mismatch', 409); db_ok($wpdb->query('START TRANSACTION')); try { // Lock the binding row so control uniqueness and activation commit together. db_ok($wpdb->query($wpdb->prepare("SELECT option_value FROM %i WHERE option_name=%s FOR UPDATE", $wpdb->options, 'publisher_connector_binding'))); $fresh = current_binding($b); foreach (['consent_hash', 'challenge_hash', 'expires_at', 'policy_hash'] as $key) demand($fresh->{$key} === $body->{$key}, 'confirmation_mismatch', 409); if ($fresh->state === 'active') { demand($fresh->confirmation_key === $expected, 'confirmation_mismatch', 409); db_ok($wpdb->query('COMMIT')); return pairing_status($fresh); } demand($fresh->state === 'consented' && hash_equals($fresh->challenge, $body->challenge), 'consent_required', 403); $scope = hash('sha256', $fresh->installation_id . '/' . $fresh->connection_id); demand(journal_insert($scope, 'confirm:' . $expected, obj(['state' => 'completed', 'confirmation_key' => $expected])), 'confirmation_conflict', 409); $after = clone $fresh; $after->state = 'active'; $after->confirmation_key = $expected; unset($after->challenge); option_cas('publisher_connector_binding', $fresh, $after); db_ok($wpdb->query('COMMIT')); return pairing_status($after); } catch (\Throwable $error) { $wpdb->query('ROLLBACK'); throw $error; } } function disconnect($nonce) { admin_authorize($nonce); $before = binding_read(); $secret = null; try { if (isset($before->encrypted_secret)) $secret = secret_open($before); } catch (Failure $e) {} revoke_local(); if ($secret !== null) { try { $notice = pick($before, ['protocol_version', 'tenant_id', 'site_id', 'installation_id', 'connection_id']); $notice->operation = 'pairing.disconnect'; $notice->revoked_at = utc_now(); $notice->idempotency_key = semantic_hash(pick($notice, ['operation', 'tenant_id', 'site_id', 'installation_id', 'connection_id', 'revoked_at'])); schema('disconnectNotice', $notice); $route = '/connector-pairings/' . $before->connection_id . '/disconnect'; $headers = sign_headers($before, $secret, 'POST', $route, canonical($notice), $notice->idempotency_key, 'publisher-service-control-v1', service_base()); service_post($route, $notice, $headers); } catch (\Throwable $e) { /* Local revocation wins even if remote outcome is unknown. */ } clear_secret($secret); } return obj(['state' => 'revoked']); } function settings_menu() { add_options_page('AICPub Publisher Connector', 'AICPub Publisher Connector', 'manage_options', 'publisher-connector', __NAMESPACE__ . '\\settings_page'); } function settings_page() { if (!current_user_can('manage_options')) return; echo '
Connect this site to AICPub. You’ll choose who can publish and what the connection can do.
'; try { if (($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') { demand(($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && current_user_can('manage_options'), 'admin_denied', 403); $raw_nonce = $_POST['_wpnonce'] ?? ''; demand(is_string($raw_nonce), 'admin_denied', 403); $nonce = sanitize_text_field(wp_unslash($raw_nonce)); demand(wp_verify_nonce($nonce, 'publisher_connector_admin'), 'admin_denied', 403); admin_authorize($nonce); $action = isset($_POST['publisher_action']) ? wp_unslash($_POST['publisher_action']) : ''; if ($action === 'configure_service') admin_configure_service(trim(wp_unslash($_POST['service_base'] ?? '')), $nonce); elseif ($action === 'exchange') admin_exchange(wp_unslash($_POST['code'] ?? ''), parse_json(wp_unslash($_POST['policy'] ?? '')), $nonce); elseif ($action === 'consent') admin_consent($nonce); elseif ($action === 'browser_continue') browser_setup_continue($nonce); elseif ($action === 'disconnect') disconnect($nonce); else throw new Failure('invalid_action', 400); echo 'Connection state updated.
'; } $b = binding_read(); $connection_status = [ 'disconnected' => ['Not connected', 'Start setup to choose your account and permissions.'], 'pending_consent' => ['Approval needed', 'Review the site and the permissions before connecting.'], 'consented' => ['Awaiting confirmation', 'AICPub still needs to confirm the connection.'], 'active' => ['Connected', 'Each article still needs your approval.'], 'revoked' => ['Connection closed', 'Start setup to connect again.'], ]; [$label, $detail] = $connection_status[$b->state] ?? ['Connection status unavailable', 'Review the connection before continuing.']; echo '' . esc_html($label) . ' ' . esc_html($detail) . '
'; if (isset($b->policy) && !content_execution_user_allowed($b->policy->execution_user_id)) { echo 'Content writes are paused. The publishing user must have content permissions without site administration permissions. Review outstanding operations in AICPub, then disconnect and reconnect using a non-administrator publishing user. Existing content and operation records are retained.
'; } browser_setup_render($b); $configuredService = defined('PUBLISHER_CONNECTOR_SERVICE_BASE') ? PUBLISHER_CONNECTOR_SERVICE_BASE : option_read('publisher_connector_service_base'); echo 'For manual setup, save the service address before entering a pairing code.
Tenant: ' . esc_html($b->tenant_label) . '
Site: ' . esc_html($b->api_base) . '
Saving drafts and publishing publicly require separate permissions. Each content change needs its own approval in AICPub.
'; } catch (\Throwable $e) { echo 'Connector action could not be verified. Inspect local connection state before starting a new action.
'; } echo ''; }