version === 1 && in_array($s->state, ['starting', 'waiting', 'account_approved', 'configuring', 'collecting', 'pending_consent', 'consented', 'completed'], true), 'setup_state_unavailable', 503); schema('uuid', $s->id); schema('uuid', $s->installationId); schema('apiBase', $s->apiBase); if ($s->policy !== null) schema('policy', $s->policy); demand(is_int($s->adminUserId) && is_int($s->expiresAt) && is_string($s->encryptedVerifier) && $s->serviceBase === browser_setup_base(), 'setup_state_unavailable', 503); return $s; } function browser_setup_authorize($s, $nonce) { admin_authorize($nonce); demand($s && $s->adminUserId === get_current_user_id() && $s->installationId === option_read('publisher_connector_installation') && $s->apiBase === api_base(), 'setup_owner_changed', 403); demand(time() < $s->expiresAt, 'setup_expired', 409); if ($s->policy !== null) validate_policy($s->policy); } function browser_setup_post($route, $input) { $response = wp_safe_remote_post(browser_setup_base() . 'connector-setup/' . $route, ['timeout' => 15, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 65536, 'headers' => ['Content-Type' => 'application/json'], 'body' => canonical($input), 'cookies' => []]); demand(!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200, 'setup_outcome_unknown', 503); return parse_json(wp_remote_retrieve_body($response)); } function browser_setup_proof($s) { return rtrim(strtr(base64_encode(secret_open_bound($s->encryptedVerifier, browser_setup_bound($s))), '+/', '-_'), '='); } // These permissions are independent opt-ins; existing connections are never widened. function browser_setup_capabilities($publishing, $publishedUpdating = false) { demand(is_bool($publishing) && is_bool($publishedUpdating), 'invalid_selection'); $caps = ['draft.create', 'draft.update', 'media.upload', 'operation.read', 'site.read']; if ($publishing) $caps[] = 'publication.publish'; if ($publishedUpdating) $caps[] = 'published.update'; sort($caps); return $caps; } function browser_setup_policy($execution, $author, $category, $publishing) { foreach ([$execution, $author, $category] as $id) demand(is_string($id) && preg_match('/^[1-9][0-9]{0,9}$/D', $id) && (int) $id <= 2147483647, 'invalid_selection'); demand(is_bool($publishing), 'invalid_selection'); $caps = ['site.read', 'operation.read', 'media.upload', 'draft.create', 'draft.update']; if ($publishing) $caps[] = 'publication.publish'; sort($caps); $policy = obj(['revision' => 1, 'execution_user_id' => (int) $execution, 'allowed_author_ids' => [(int) $author], 'allowed_category_ids' => [(int) $category], 'capabilities' => $caps, 'html_profile' => 'publisher-post-html-v1']); validate_policy($policy); $b = obj(['policy' => $policy]); demand(canonical(effective_policy($b)) === canonical($policy), 'local_permissions_insufficient', 403); return $policy; } function browser_setup_start($policy, $nonce) { admin_authorize($nonce); return with_setup_lock(function () use ($policy, $nonce) { admin_authorize($nonce); if ($policy !== null) validate_policy($policy); secret_key_prerequisite(); browser_setup_base(); demand(in_array(binding_read()->state, ['disconnected', 'revoked'], true), 'disconnect_required', 409); $before = browser_setup_read(); demand($before === null || $before->state === 'completed' || time() >= $before->expiresAt, 'setup_already_started', 409); consume_rate_limit(hash('sha256', option_read('publisher_connector_installation') . '/browser/' . get_current_user_id()), 'bootstrap_user', 5, 600); $secret = random_bytes(32); $s = obj(['version' => 1, 'id' => wp_generate_uuid4(), 'installationId' => option_read('publisher_connector_installation'), 'apiBase' => api_base(), 'adminUserId' => get_current_user_id(), 'policy' => $policy, 'expiresAt' => time() + 600, 'state' => 'starting', 'serviceBase' => browser_setup_base()]); $s->encryptedVerifier = secret_seal_bound($secret, browser_setup_bound($s)); clear_secret($secret); if ($before === null) demand(add_option('publisher_connector_browser_setup', $s, '', false), 'state_conflict', 409); else option_cas('publisher_connector_browser_setup', $before, $s); // Reserve before HTTP. An unknown start is inspected; never sent again. $input = obj(['requestId' => $s->id, 'verifier' => browser_setup_proof($s), 'installationId' => $s->installationId, 'apiBase' => $s->apiBase, 'name' => mb_substr(trim(wp_strip_all_tags(get_bloginfo('name'))), 0, 120), 'policy' => $policy]); if ($input->name === '') $input->name = (string) wp_parse_url($s->apiBase, PHP_URL_HOST); $result = browser_setup_post('start', $input); $expected = browser_setup_base() . '#wordpress-setup=' . $s->id; demand($result->setupId === $s->id && $result->authorizationUrl === $expected, 'setup_response_mismatch'); $after = clone $s; $after->state = 'waiting'; option_cas('publisher_connector_browser_setup', $s, $after); return $expected; }); } function browser_setup_start_handler() { try { 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); $rawPublishing = array_key_exists('allow_publishing', $_POST) ? wp_unslash($_POST['allow_publishing']) : '0'; demand(in_array($rawPublishing, ['0', '1'], true), 'invalid_selection'); $rawPublishedUpdating = array_key_exists('allow_published_updates', $_POST) ? wp_unslash($_POST['allow_published_updates']) : '0'; demand(in_array($rawPublishedUpdating, ['0', '1'], true), 'invalid_selection'); $step = array_key_exists('setup_step', $_POST) ? $_POST['setup_step'] : 'start'; demand(is_string($step), 'invalid_selection'); $step = wp_unslash($step); demand(in_array($step, ['start', 'configure'], true), 'invalid_selection'); if ($step === 'configure') { $url = browser_setup_configure($nonce, $rawPublishing === '1', $rawPublishedUpdating === '1'); } else { $url = browser_setup_start(null, $nonce); } // The URL is constructed from the trusted server configuration, never a request callback. wp_redirect($url, 303); exit; } catch (\Throwable $e) { wp_die(esc_html('Connection setup could not be verified. Return to AICPub Publisher Connector settings and check the existing setup before trying again.')); } } /** Preserve unchanged policy revisions; advance changed grants before requesting a one-use code. */ function browser_setup_next_policy($policy, $binding) { $next = clone $policy; if (!isset($binding->policy)) return $next; schema('policy', $binding->policy); $next->revision = max($next->revision, $binding->policy->revision); if (canonical($next) !== canonical($binding->policy) && $next->revision <= $binding->policy->revision) { demand($next->revision < 2147483647, 'policy_revision_exhausted', 409); $next->revision++; } return $next; } function browser_setup_configure($nonce, $publishing, $publishedUpdating = false) { admin_authorize($nonce); return with_setup_lock(function () use ($nonce, $publishing, $publishedUpdating) { demand(wp_verify_nonce($nonce, 'publisher_connector_admin'), 'admin_denied', 403); $s = browser_setup_read(); browser_setup_authorize($s, $nonce); demand($s->state === 'account_approved' && $s->policy === null, 'configuration_not_ready', 409); $execution = wp_unslash($_POST['execution_user'] ?? ''); demand(is_string($execution) && preg_match('/^[1-9][0-9]{0,9}$/D', $execution) && (int)$execution <= 2147483647, 'invalid_selection'); $authors = wp_unslash($_POST['authors'] ?? []); $categories = wp_unslash($_POST['categories'] ?? []); demand(is_array($authors) && count($authors) >= 1 && count($authors) <= 100 && is_array($categories) && count($categories) <= 100, 'invalid_selection'); foreach (array_merge($authors, $categories) as $id) demand(is_string($id) && preg_match('/^[1-9][0-9]{0,9}$/D', $id) && (int)$id <= 2147483647, 'invalid_selection'); $authors = array_map('intval', $authors); $categories = array_map('intval', $categories); sort($authors, SORT_NUMERIC); sort($categories, SORT_NUMERIC); demand(count(array_unique($authors)) === count($authors) && count(array_unique($categories)) === count($categories), 'invalid_selection'); $scope = wp_unslash($_POST['category_scope'] ?? 'all_accessible'); demand(in_array($scope, ['all_accessible', 'selected'], true), 'invalid_selection'); demand($scope === 'all_accessible' || count($categories) >= 1, 'invalid_selection'); $caps = browser_setup_capabilities($publishing, $publishedUpdating); $policy = obj(['revision' => 2, 'execution_user_id' => (int)$execution, 'allowed_author_ids' => $authors, 'allowed_category_ids' => $scope === 'all_accessible' ? [] : $categories, 'capabilities' => $caps, 'html_profile' => 'publisher-post-html-v1']); if ($scope === 'all_accessible') $policy->category_scope = 'all_accessible'; $policy = browser_setup_next_policy($policy, binding_read()); validate_policy($policy); $effective = effective_policy(obj(['policy' => $policy])); demand($effective->capabilities === $caps && $effective->allowed_author_ids === $authors && ($scope === 'all_accessible' || $effective->allowed_category_ids === $categories), 'local_permissions_insufficient', 403); // Freeze locally before HTTP. An unknown response cannot change or resend this policy. $after = clone $s; $after->policy = $policy; $after->state = 'configuring'; option_cas('publisher_connector_browser_setup', $s, $after); $result = browser_setup_post('configure', obj(['setupId' => $s->id, 'verifier' => browser_setup_proof($s), 'policy' => $policy])); demand($result->setupId === $s->id && $result->state === 'waiting', 'setup_response_mismatch'); $saved = clone $after; $saved->state = 'waiting'; option_cas('publisher_connector_browser_setup', $after, $saved); return browser_setup_base() . '#wordpress-setup=' . $s->id; }); } function browser_setup_configuration_form($s) { echo '

Choose WordPress permissions

Your AICPub account has been approved. Now choose how this WordPress site can be used.

'; echo '
'; wp_nonce_field('publisher_connector_admin'); $users = get_users(['capability' => 'edit_posts', 'number' => 200, 'orderby' => 'display_name']); echo '

Choose a publishing user without site administration permissions, such as an Author or Editor. Remote operations use only content and media permissions.

Available article authors

Select at least one author. Choose an author for each article in AICPub.

'; foreach ($users as $u) if (user_can($u->ID, 'edit_posts')) echo '

'; echo '
Category access

'; $terms = get_terms(['taxonomy' => 'category', 'hide_empty' => false, 'number' => 200]); if (!is_wp_error($terms)) foreach ($terms as $term) echo '

'; echo '

Public publishing is off by default and requires a preview and approval for every article.

Updates to published articles are off by default and require a separate correction preview and approval.

'; } function browser_setup_continue($nonce) { admin_authorize($nonce); return with_setup_lock(function () use ($nonce) { $s = browser_setup_read(); browser_setup_authorize($s, $nonce); $status = browser_setup_post('inspect', obj(['setupId' => $s->id, 'verifier' => browser_setup_proof($s)])); demand($status->setupId === $s->id, 'setup_response_mismatch'); if ($status->state === 'account_approved') { demand($s->policy === null, 'setup_response_mismatch'); $after = clone $s; $after->state = 'account_approved'; option_cas('publisher_connector_browser_setup', $s, $after); return; } if ($s->state === 'configuring' && $status->state === 'waiting') { $after = clone $s; $after->state = 'waiting'; option_cas('publisher_connector_browser_setup', $s, $after); return; } if ($status->state === 'completed') { demand(binding_read()->state === 'active', 'active_binding_required', 409); $after = clone $s; $after->state = 'completed'; option_cas('publisher_connector_browser_setup', $s, $after); return; } if ($status->state === 'handed_off') { $b = binding_read(); if (in_array($b->state, ['pending_consent', 'consented', 'active'], true) && $b->installation_id === $s->installationId && $b->api_base === $s->apiBase && $b->policy_hash === semantic_hash($s->policy)) { if ($s->state === 'collecting') { $after = clone $s; $after->state = 'pending_consent'; option_cas('publisher_connector_browser_setup', $s, $after); } return; // Recover from the durable local exchange result, without another POST. } throw new Failure('inspect_setup_before_retry', 409); } demand($status->state === 'ready' && in_array($s->state, ['starting', 'waiting'], true), 'inspect_setup_before_retry', 409); $collecting = clone $s; $collecting->state = 'collecting'; option_cas('publisher_connector_browser_setup', $s, $collecting); $handoff = browser_setup_post('collect', obj(['setupId' => $s->id, 'verifier' => browser_setup_proof($s)])); schema('uuid', $handoff->tenantId); schema('uuid', $handoff->siteId); demand($handoff->serviceBase === browser_setup_base() . 'customer-connectors/' . $handoff->tenantId . '/' . $handoff->siteId . '/' && canonical($handoff->policy) === canonical($s->policy), 'setup_response_mismatch'); if (defined('PUBLISHER_CONNECTOR_SERVICE_BASE')) demand(PUBLISHER_CONNECTOR_SERVICE_BASE === $handoff->serviceBase, 'configuration_locked', 409); else configure_service_locked($handoff->serviceBase, $nonce); exchange_locked($handoff->pairingCode, $s->policy, $nonce); $b = binding_read(); demand($b->tenant_id === $handoff->tenantId && $b->site_id === $handoff->siteId && $b->installation_id === $s->installationId, 'setup_response_mismatch'); $after = clone $collecting; $after->state = 'pending_consent'; option_cas('publisher_connector_browser_setup', $collecting, $after); }); } function browser_setup_render($binding) { $s = browser_setup_read(); if ($s && $s->state === 'completed' && $binding->state === 'active' && $binding->installation_id === $s->installationId && $binding->api_base === $s->apiBase && $binding->policy_hash === semantic_hash($s->policy)) { echo '

Connected to AICPub.

'; return; } echo '

Connect to AICPub

Use an AICPub account with access to the service. Your WordPress password stays with WordPress.

'; if ($s && $s->state !== 'completed' && time() < $s->expiresAt) { if ($s->adminUserId !== get_current_user_id()) { echo '

Another administrator started this connection. Ask that administrator to finish it or wait until this setup expires.

'; return; } if ($s->state === 'account_approved') { browser_setup_configuration_form($s); return; } echo '

Review account and site in AICPub

'; if ($s->state === 'completed' && $binding->state === 'active') { echo '

Connected to AICPub.

'; return; } echo '

After approving the account and site in AICPub, return here to continue. If an operation outcome is uncertain, inspect its status instead of starting another setup.

'; wp_nonce_field('publisher_connector_admin'); echo '
'; return; } if (!in_array($binding->state, ['disconnected', 'revoked'], true)) return; if ($binding->state === 'revoked') echo '

Your previous connection is closed locally. Connect with the AICPub account you want to use. AICPub will check the old connection and pending work before approving the change. Existing articles and history stay with their original account.

'; echo '
'; wp_nonce_field('publisher_connector_admin'); echo '

Sign in to AICPub first. You will choose your WordPress publishing user, authors and category access in the next setup step.

'; } /** Read-only proof for the service, outside the content protocol namespace. * No verifier, credential or pairing secret is exposed. A remote caller cannot * create the local setup: that still requires manage_options and a nonce. */ function browser_setup_installation_proof($request) { try { demand($request->get_method() === 'GET' && is_ssl() && !$request->get_query_params() && ($request->get_body() ?? '') === '', 'setup_proof_denied', 403); $id = $request['setup_id']; $nonce = $request['challenge']; schema('uuid', $id); demand(is_string($nonce) && preg_match('/^[0-9a-f]{32}$/D', $nonce), 'setup_proof_denied', 403); demand(($_SERVER['REQUEST_URI'] ?? '') === wp_parse_url(api_base(), PHP_URL_PATH) . 'aicpub/v1/setup-proof/' . $id . '/' . $nonce, 'setup_proof_denied', 403); return with_setup_lock(function () use ($id, $nonce) { global $wpdb; storage_ready(); $s = browser_setup_read(); $b = binding_read(); demand($s && $s->id === $id && in_array($s->state, ['starting', 'waiting'], true) && time() < $s->expiresAt && $s->installationId === option_read('publisher_connector_installation') && $s->apiBase === api_base() && user_can($s->adminUserId, 'manage_options') && in_array($b->state, ['disconnected', 'revoked'], true), 'setup_proof_denied', 403); consume_rate_limit(hash('sha256', $s->id), 'setup_proof', 20, 600); $count = $wpdb->get_var($wpdb->prepare('SELECT count(*) FROM %i WHERE slot LIKE %s AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(data,"$.state")),"") NOT IN ("completed","cancelled")', table(), 'operation:%')); demand(!$wpdb->last_error && $count !== null, 'storage_unavailable', 503); $previous = null; if (isset($b->tenant_id, $b->site_id, $b->connection_id, $b->policy_hash)) { demand($b->installation_id === $s->installationId && $b->api_base === $s->apiBase, 'setup_proof_denied', 403); $previous = obj(['tenantId' => $b->tenant_id, 'siteId' => $b->site_id, 'installationId' => $b->installation_id, 'connectorConnectionId' => $b->connection_id, 'policyHash' => $b->policy_hash]); } $proof = obj(['version' => 1, 'setupId' => $id, 'nonce' => $nonce, 'installationId' => $s->installationId, 'apiBase' => $s->apiBase, 'policyHash' => semantic_hash($s->policy), 'localState' => $b->state, 'blocked' => (int)$count > 0, 'previousBinding' => $previous]); $verifier = browser_setup_proof($s); $key = hash('sha256', $verifier, true); $mac = hash_hmac('sha256', 'publisher-setup-proof-v1:' . semantic_hash($proof), $key); clear_secret($verifier); clear_secret($key); return new \WP_REST_Response(obj(['proof' => $proof, 'mac' => $mac]), 200, ['Cache-Control' => 'no-store']); }); } catch (\Throwable $error) { return new \WP_Error('setup_proof_unavailable', 'Return to AICPub Publisher Connector and check your connection.', ['status' => 409]); } } function browser_setup_permissions($policy) { $execution = get_userdata($policy->execution_user_id); $authors = []; $categories = []; foreach ($policy->allowed_author_ids as $id) { $u = get_userdata($id); $authors[] = $u ? $u->display_name : 'Unavailable'; } foreach ($policy->allowed_category_ids as $id) { $t = get_term($id, 'category'); $categories[] = $t && !is_wp_error($t) ? $t->name : 'Unavailable'; } $category = ($policy->category_scope ?? null) === 'all_accessible' ? 'All accessible categories, including future categories' : implode(', ', $categories); echo '

Connection permissions

'; echo '
Technical permission details
' . esc_html(canonical($policy)) . '
'; }