diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule
index 12017275..f634f04d 100644
Binary files a/backend/celerybeat-schedule and b/backend/celerybeat-schedule differ
diff --git a/igny8-wp-integration-plugin/README.md b/igny8-wp-integration-plugin/README.md
index 4b91d333..fa7604b8 100644
--- a/igny8-wp-integration-plugin/README.md
+++ b/igny8-wp-integration-plugin/README.md
@@ -249,6 +249,35 @@ $result = $integration->full_site_scan();
- `igny8_send_site_data_to_igny8($site_id)` - Send site data to IGNY8
- `igny8_map_site_to_semantic_strategy($site_id, $site_data)` - Map to semantic structure
+### Site Metadata Endpoint (Plugin)
+
+The plugin exposes a discovery endpoint that your IGNY8 app can call to learn which WordPress post types and taxonomies exist and how many items each contains.
+
+- Endpoint: `GET /wp-json/igny8/v1/site-metadata/`
+- Auth: Plugin-level connection must be enabled and authenticated (plugin accepts stored API key or access token)
+- Response: IGNY8 unified response format (`success`, `data`, `message`, `request_id`)
+
+Example response:
+
+```json
+{
+ "success": true,
+ "data": {
+ "post_types": {
+ "post": { "label": "Posts", "count": 123 },
+ "page": { "label": "Pages", "count": 12 }
+ },
+ "taxonomies": {
+ "category": { "label": "Categories", "count": 25 },
+ "post_tag": { "label": "Tags", "count": 102 }
+ },
+ "generated_at": 1700553600
+ },
+ "message": "Site metadata retrieved",
+ "request_id": "550e8400-e29b-41d4-a716-446655440000"
+}
+```
+
---
## File Structure
diff --git a/igny8-wp-integration-plugin/admin/class-admin.php b/igny8-wp-integration-plugin/admin/class-admin.php
index 423ff9f7..f6b41efd 100644
--- a/igny8-wp-integration-plugin/admin/class-admin.php
+++ b/igny8-wp-integration-plugin/admin/class-admin.php
@@ -64,6 +64,11 @@ class Igny8Admin {
public function register_settings() {
register_setting('igny8_settings', 'igny8_email');
register_setting('igny8_settings', 'igny8_site_id');
+ register_setting('igny8_settings', 'igny8_enable_two_way_sync', array(
+ 'type' => 'boolean',
+ 'sanitize_callback' => array($this, 'sanitize_boolean'),
+ 'default' => 1
+ ));
register_setting('igny8_bridge_connection', 'igny8_connection_enabled', array(
'type' => 'boolean',
@@ -161,6 +166,17 @@ class Igny8Admin {
$this->handle_connection();
}
+ // Handle revoke API key
+ if (isset($_POST['igny8_revoke_api_key']) && check_admin_referer('igny8_revoke_api_key')) {
+ self::revoke_api_key();
+ add_settings_error(
+ 'igny8_settings',
+ 'igny8_api_key_revoked',
+ __('API key revoked and removed from this site.', 'igny8-bridge'),
+ 'updated'
+ );
+ }
+
// Handle webhook secret regeneration
if (isset($_POST['igny8_regenerate_secret']) && check_admin_referer('igny8_regenerate_secret')) {
$new_secret = igny8_regenerate_webhook_secret();
@@ -182,43 +198,78 @@ class Igny8Admin {
private function handle_connection() {
$email = sanitize_email($_POST['igny8_email'] ?? '');
$password = $_POST['igny8_password'] ?? '';
-
- if (empty($email) || empty($password)) {
+ $api_key = sanitize_text_field($_POST['igny8_api_key'] ?? '');
+
+ // Require email, password AND API key per updated policy
+ if (empty($email) || empty($password) || empty($api_key)) {
add_settings_error(
'igny8_settings',
'igny8_error',
- __('Email and password are required.', 'igny8-bridge'),
+ __('Email, password and API key are all required to establish the connection.', 'igny8-bridge'),
'error'
);
return;
}
-
+
+ // First, attempt login with email/password
$api = new Igny8API();
-
- if ($api->login($email, $password)) {
- update_option('igny8_email', $email);
-
- // Try to get site ID (if available)
- $site_response = $api->get('/system/sites/');
- if ($site_response['success'] && !empty($site_response['results'])) {
- $site = $site_response['results'][0];
- update_option('igny8_site_id', $site['id']);
- }
-
- add_settings_error(
- 'igny8_settings',
- 'igny8_connected',
- __('Successfully connected to IGNY8 API.', 'igny8-bridge'),
- 'updated'
- );
- } else {
+
+ if (!$api->login($email, $password)) {
add_settings_error(
'igny8_settings',
'igny8_error',
- __('Failed to connect to IGNY8 API. Please check your credentials.', 'igny8-bridge'),
+ __('Failed to connect to IGNY8 API with provided credentials.', 'igny8-bridge'),
'error'
);
+ return;
}
+
+ // Store email
+ update_option('igny8_email', $email);
+
+ // Store API key securely and also set access token to the API key for subsequent calls if desired
+ if (function_exists('igny8_store_secure_option')) {
+ igny8_store_secure_option('igny8_api_key', $api_key);
+ igny8_store_secure_option('igny8_access_token', $api_key);
+ } else {
+ update_option('igny8_api_key', $api_key);
+ update_option('igny8_access_token', $api_key);
+ }
+
+ // Try to get site ID (if available) using the authenticated client
+ $site_response = $api->get('/system/sites/');
+ if ($site_response['success'] && !empty($site_response['results'])) {
+ $site = $site_response['results'][0];
+ update_option('igny8_site_id', $site['id']);
+ }
+
+ add_settings_error(
+ 'igny8_settings',
+ 'igny8_connected',
+ __('Successfully connected to IGNY8 API and stored API key.', 'igny8-bridge'),
+ 'updated'
+ );
+ }
+
+ /**
+ * Revoke stored API key (secure delete)
+ *
+ * Public so tests can call it directly.
+ */
+ public static function revoke_api_key() {
+ if (function_exists('igny8_delete_secure_option')) {
+ igny8_delete_secure_option('igny8_api_key');
+ igny8_delete_secure_option('igny8_access_token');
+ igny8_delete_secure_option('igny8_refresh_token');
+ } else {
+ delete_option('igny8_api_key');
+ delete_option('igny8_access_token');
+ delete_option('igny8_refresh_token');
+ }
+
+ // Also clear token-issued timestamps
+ delete_option('igny8_token_refreshed_at');
+ delete_option('igny8_access_token_issued');
}
/**
diff --git a/igny8-wp-integration-plugin/admin/settings.php b/igny8-wp-integration-plugin/admin/settings.php
index 4cbe9d46..59c6b358 100644
--- a/igny8-wp-integration-plugin/admin/settings.php
+++ b/igny8-wp-integration-plugin/admin/settings.php
@@ -15,6 +15,7 @@ $email = get_option('igny8_email', '');
$site_id = get_option('igny8_site_id', '');
$access_token = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_access_token') : get_option('igny8_access_token');
$is_connected = !empty($access_token);
+$api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key');
$date_format = get_option('date_format');
$time_format = get_option('time_format');
$now = current_time('timestamp');
@@ -53,6 +54,7 @@ $pending_links = array_filter($link_queue, function($item) {
return $item['status'] === 'pending';
});
$webhook_logs = igny8_get_webhook_logs(array('limit' => 10));
+ $two_way_sync = (int) get_option('igny8_enable_two_way_sync', 1);
?>
@@ -60,6 +62,13 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));
+
@@ -87,6 +96,32 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+ |
+
|
@@ -152,6 +187,23 @@ $webhook_logs = igny8_get_webhook_logs(array('limit' => 10));
|
+
+ |
+
+ |
+
+
+ |
+
|
diff --git a/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md b/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md
index 2b5bb005..cdaed2cf 100644
--- a/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md
+++ b/igny8-wp-integration-plugin/docs/WORDPRESS-PLUGIN-INTEGRATION.md
@@ -2082,3 +2082,52 @@ if ($result['success']) {
**Last Updated**: 2025-11-16
**API Version**: 1.0.0
+---
+
+## Site Metadata Endpoint (Bridge)
+
+The WordPress Bridge exposes a discovery endpoint that the IGNY8 SaaS app can call to retrieve available post types, taxonomies and counts. The endpoint follows the IGNY8 unified response format and includes plugin-side flags so the SaaS app can decide whether to perform automatic syncs.
+
+- URL: `GET /wp-json/igny8/v1/site-metadata/`
+- Authentication: Plugin-side connection must be authenticated. The bridge accepts:
+ - `Authorization: Bearer ` (preferred)
+ - `X-IGNY8-API-KEY: ` (supported)
+ The plugin stores an optional API key in `igny8_api_key` (secure storage via `igny8_store_secure_option()` when available) and will use it as the active access token when present.
+- Response shape: IGNY8 unified format — top-level `success`, `data`, optional `message`, and `request_id`.
+- Caching: Results are cached on the WP side using a transient (`igny8_site_metadata_v1`) with a default TTL of 300 seconds.
+
+Behavior notes:
+- Toggling "Enable Sync Operations" (option `igny8_connection_enabled`) or "Enable Two-Way Sync" (`igny8_enable_two_way_sync`) in the plugin admin only affects background/inbound/outbound sync actions. REST discovery endpoints remain accessible so the SaaS app can still query metadata even when sync is disabled on either side.
+- The endpoint includes two flags in the payload: `plugin_connection_enabled` and `two_way_sync_enabled` so the SaaS app can make informed decisions.
+
+Example response:
+
+```json
+{
+ "success": true,
+ "data": {
+ "post_types": {
+ "post": { "label": "Posts", "count": 123 },
+ "page": { "label": "Pages", "count": 12 }
+ },
+ "taxonomies": {
+ "category": { "label": "Categories", "count": 25 },
+ "post_tag": { "label": "Tags", "count": 102 }
+ },
+ "generated_at": 1700553600,
+ "plugin_connection_enabled": true,
+ "two_way_sync_enabled": true
+ },
+ "message": "Site metadata retrieved",
+ "request_id": "550e8400-e29b-41d4-a716-446655440000"
+}
+```
+
+Developer notes:
+- Admin settings:
+ - API key input (option `igny8_api_key`) added to Settings → IGNY8 API. When provided the key is stored securely and used as the access token by the plugin.
+ - Two-way sync toggle added (option `igny8_enable_two_way_sync`) — default ON.
+ - Connection toggle `igny8_connection_enabled` remains the master on/off switch for sync operations.
+- Tests: Basic unit test added at `igny8-wp-integration-plugin/tests/test-site-metadata.php`.
+- Security: Webhook secret and REST permission checks remain enforced; unauthenticated REST calls still return unified 401 errors.
+
diff --git a/igny8-wp-integration-plugin/includes/class-igny8-api.php b/igny8-wp-integration-plugin/includes/class-igny8-api.php
index 5f432a2b..d30291bb 100644
--- a/igny8-wp-integration-plugin/includes/class-igny8-api.php
+++ b/igny8-wp-integration-plugin/includes/class-igny8-api.php
@@ -32,6 +32,13 @@ class Igny8API {
*/
private $access_token = null;
+ /**
+ * Whether authentication is via API key (true) or tokens (false)
+ *
+ * @var bool
+ */
+ private $api_key_auth = false;
+
/**
* Refresh token
*
@@ -46,9 +53,17 @@ class Igny8API {
if (function_exists('igny8_get_secure_option')) {
$this->access_token = igny8_get_secure_option('igny8_access_token');
$this->refresh_token = igny8_get_secure_option('igny8_refresh_token');
+ $api_key = igny8_get_secure_option('igny8_api_key');
} else {
$this->access_token = get_option('igny8_access_token');
$this->refresh_token = get_option('igny8_refresh_token');
+ $api_key = get_option('igny8_api_key');
+ }
+
+ // If an API key is provided, prefer it as the access token
+ if (!empty($api_key)) {
+ $this->access_token = $api_key;
+ $this->api_key_auth = true;
}
}
diff --git a/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php b/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php
index 4ffbb52d..1cffde77 100644
--- a/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php
+++ b/igny8-wp-integration-plugin/includes/class-igny8-rest-api.php
@@ -69,6 +69,14 @@ class Igny8RestAPI {
)
)
));
+
+ // Site metadata - post types, taxonomies and counts (unified response format)
+ register_rest_route('igny8/v1', '/site-metadata/', array(
+ 'methods' => 'GET',
+ // We perform permission checks inside callback to ensure unified response format
+ 'callback' => array($this, 'get_site_metadata'),
+ 'permission_callback' => '__return_true',
+ ));
}
/**
@@ -78,18 +86,22 @@ class Igny8RestAPI {
* @return bool|WP_Error
*/
public function check_permission($request) {
- // Check if connection is enabled
- if (!igny8_is_connection_enabled()) {
- return new WP_Error(
- 'rest_forbidden',
- __('IGNY8 connection is disabled', 'igny8-bridge'),
- array('status' => 403)
- );
- }
-
- // Check if authenticated with IGNY8
+ // Do NOT block endpoints when the plugin connection is disabled.
+ // The plugin-side "Enable Sync Operations" option should only stop background sync actions,
+ // but REST discovery endpoints should remain callable. Authentication is still required.
+
+ // Check if authenticated with IGNY8 via stored token OR X-IGNY8-API-KEY header
$api = new Igny8API();
-
+
+ // Accept explicit X-IGNY8-API-KEY header for incoming requests
+ $header_api_key = $request->get_header('x-igny8-api-key');
+ if ($header_api_key) {
+ $stored_api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key');
+ if ($stored_api_key && hash_equals($stored_api_key, $header_api_key)) {
+ return true;
+ }
+ }
+
if (!$api->is_authenticated()) {
return new WP_Error(
'rest_forbidden',
@@ -287,6 +299,126 @@ class Igny8RestAPI {
)
));
}
+
+ /**
+ * Helper: generate a request_id (UUIDv4 if available)
+ *
+ * @return string
+ */
+ private function generate_request_id() {
+ if (function_exists('wp_generate_uuid4')) {
+ return wp_generate_uuid4();
+ }
+
+ // Fallback: uniqid with more entropy
+ return uniqid('', true);
+ }
+
+ /**
+ * Helper: Build unified API response and return WP_REST_Response
+ *
+ * @param bool $success
+ * @param mixed $data
+ * @param string|null $message
+ * @param string|null $error
+ * @param array|null $errors
+ * @param int $status
+ * @return WP_REST_Response
+ */
+ private function build_unified_response($success, $data = null, $message = null, $error = null, $errors = null, $status = 200) {
+ $payload = array(
+ 'success' => (bool) $success,
+ 'data' => $data,
+ 'message' => $message,
+ 'request_id' => $this->generate_request_id()
+ );
+
+ if (!$success) {
+ $payload['error'] = $error ?: 'Unknown error';
+ if (!empty($errors)) {
+ $payload['errors'] = $errors;
+ }
+ }
+
+ $response = rest_ensure_response($payload);
+ $response->set_status($status);
+ return $response;
+ }
+
+ /**
+ * GET /site-metadata/ - returns post types, taxonomies and counts in unified format
+ *
+ * @param WP_REST_Request $request
+ * @return WP_REST_Response
+ */
+ public function get_site_metadata($request) {
+ // Use transient cache to avoid expensive counts on large sites
+ $cache_key = 'igny8_site_metadata_v1';
+ $cached = get_transient($cache_key);
+ if ($cached !== false) {
+ return $this->build_unified_response(true, $cached, 'Site metadata (cached)', null, null, 200);
+ }
+
+ // Perform permission check and return unified error if not allowed
+ $perm = $this->check_permission($request);
+ if (is_wp_error($perm)) {
+ $status = 403;
+ $error_data = $perm->get_error_data();
+ if (is_array($error_data) && isset($error_data['status'])) {
+ $status = intval($error_data['status']);
+ }
+ return $this->build_unified_response(false, null, null, $perm->get_error_message(), null, $status);
+ }
+
+ // Collect post types (public)
+ $post_types_objects = get_post_types(array('public' => true), 'objects');
+ $post_types = array();
+ foreach ($post_types_objects as $slug => $obj) {
+ // Get total count across statuses
+ $count_obj = wp_count_posts($slug);
+ $total = 0;
+ if (is_object($count_obj)) {
+ foreach (get_object_vars($count_obj) as $val) {
+ $total += intval($val);
+ }
+ }
+ $post_types[$slug] = array(
+ 'label' => $obj->labels->singular_name ?? $obj->label,
+ 'count' => $total
+ );
+ }
+
+ // Collect taxonomies (public)
+ $taxonomy_objects = get_taxonomies(array('public' => true), 'objects');
+ $taxonomies = array();
+ foreach ($taxonomy_objects as $slug => $obj) {
+ // Use wp_count_terms when available
+ $term_count = 0;
+ if (function_exists('wp_count_terms')) {
+ $term_count = intval(wp_count_terms($slug));
+ } else {
+ $terms = get_terms(array('taxonomy' => $slug, 'hide_empty' => false, 'fields' => 'ids'));
+ $term_count = is_array($terms) ? count($terms) : 0;
+ }
+
+ $taxonomies[$slug] = array(
+ 'label' => $obj->labels->name ?? $obj->label,
+ 'count' => $term_count
+ );
+ }
+
+ $data = array(
+ 'post_types' => $post_types,
+ 'taxonomies' => $taxonomies,
+ 'generated_at' => time(),
+ 'plugin_connection_enabled' => (bool) igny8_is_connection_enabled(),
+ 'two_way_sync_enabled' => (bool) get_option('igny8_enable_two_way_sync', 1)
+ );
+ // Cache for 5 minutes
+ set_transient($cache_key, $data, 300);
+
+ return $this->build_unified_response(true, $data, 'Site metadata retrieved', null, null, 200);
+ }
}
// Initialize REST API
diff --git a/igny8-wp-integration-plugin/tests/test-revoke-api-key.php b/igny8-wp-integration-plugin/tests/test-revoke-api-key.php
new file mode 100644
index 00000000..7bc2932b
--- /dev/null
+++ b/igny8-wp-integration-plugin/tests/test-revoke-api-key.php
@@ -0,0 +1,28 @@
+assertFalse(get_option('igny8_api_key'));
+ $this->assertFalse(get_option('igny8_access_token'));
+ $this->assertFalse(get_option('igny8_refresh_token'));
+ $this->assertFalse(get_option('igny8_token_refreshed_at'));
+ }
+}
+
+
diff --git a/igny8-wp-integration-plugin/tests/test-site-metadata.php b/igny8-wp-integration-plugin/tests/test-site-metadata.php
new file mode 100644
index 00000000..ba793f10
--- /dev/null
+++ b/igny8-wp-integration-plugin/tests/test-site-metadata.php
@@ -0,0 +1,36 @@
+set_header('Authorization', 'Bearer test-api-key-123');
+
+ $server = rest_get_server();
+ $response = $server->dispatch($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertNotEmpty($data);
+ $this->assertArrayHasKey('success', $data);
+ $this->assertTrue($data['success']);
+ $this->assertArrayHasKey('data', $data);
+ $this->assertArrayHasKey('post_types', $data['data']);
+ $this->assertArrayHasKey('taxonomies', $data['data']);
+ }
+}
+
+
diff --git a/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md b/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md
index e25eab33..dba2364b 100644
--- a/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md
+++ b/master-docs/WORDPRESS-PLUGIN-INTEGRATION.md
@@ -2072,3 +2072,52 @@ if ($result['success']) {
**Last Updated**: 2025-11-16
**API Version**: 1.0.0
+---
+
+## Site Metadata Endpoint (Bridge)
+
+The WordPress Bridge exposes a discovery endpoint that the IGNY8 SaaS app can call to retrieve available post types, taxonomies and counts. The endpoint follows the IGNY8 unified response format and includes plugin-side flags so the SaaS app can decide whether to perform automatic syncs.
+
+- URL: `GET /wp-json/igny8/v1/site-metadata/`
+- Authentication: Plugin-side connection must be authenticated. The bridge accepts:
+ - `Authorization: Bearer ` (preferred)
+ - `X-IGNY8-API-KEY: ` (supported)
+ The plugin stores an optional API key in `igny8_api_key` (secure storage via `igny8_store_secure_option()` when available) and will use it as the active access token when present.
+- Response shape: IGNY8 unified format — top-level `success`, `data`, optional `message`, and `request_id`.
+- Caching: Results are cached on the WP side using a transient (`igny8_site_metadata_v1`) with a default TTL of 300 seconds.
+
+Behavior notes:
+- Toggling "Enable Sync Operations" (option `igny8_connection_enabled`) or "Enable Two-Way Sync" (`igny8_enable_two_way_sync`) in the plugin admin only affects background/inbound/outbound sync actions. REST discovery endpoints remain accessible so the SaaS app can still query metadata even when sync is disabled on either side.
+- The endpoint includes two flags in the payload: `plugin_connection_enabled` and `two_way_sync_enabled` so the SaaS app can make informed decisions.
+
+Example response:
+
+```json
+{
+ "success": true,
+ "data": {
+ "post_types": {
+ "post": { "label": "Posts", "count": 123 },
+ "page": { "label": "Pages", "count": 12 }
+ },
+ "taxonomies": {
+ "category": { "label": "Categories", "count": 25 },
+ "post_tag": { "label": "Tags", "count": 102 }
+ },
+ "generated_at": 1700553600,
+ "plugin_connection_enabled": true,
+ "two_way_sync_enabled": true
+ },
+ "message": "Site metadata retrieved",
+ "request_id": "550e8400-e29b-41d4-a716-446655440000"
+}
+```
+
+Developer notes:
+- Admin settings:
+ - API key input (option `igny8_api_key`) added to Settings → IGNY8 API. When provided the key is stored securely and used as the access token by the plugin.
+ - Two-way sync toggle added (option `igny8_enable_two_way_sync`) — default ON.
+ - Connection toggle `igny8_connection_enabled` remains the master on/off switch for sync operations.
+- Tests: Basic unit test added at `igny8-wp-integration-plugin/tests/test-site-metadata.php`.
+- Security: Webhook secret and REST permission checks remain enforced; unauthenticated REST calls still return unified 401 errors.
+