=== AudiScale Connector ===
Contributors: platinumwp
Tags: seo, meta description, redirects, schema, open graph
Requires at least: 6.0
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 5.8.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Server-side companion for AudiScale: SEO <head> tags, redirects, and an enumerated catalog of operations over an HMAC-signed channel.

== Description ==

AudiScale Connector is the companion component for the hosted AudiScale service (https://audiscale.com). It unlocks the SEO actions that WordPress core and the standard REST API do not allow — most notably printing a `<meta name="description">` tag in the `<head>` without forcing you to install another SEO plugin.

= Service disclosure (SaaS) =

This plugin communicates with the third-party AudiScale service. **No data is
sent until you have explicitly paired** your site from your AudiScale dashboard.
Once paired, AudiScale can remotely apply a **fixed catalog of operations**
(listed below), each of which is:

* signed with HMAC-SHA256 using a per-site secret (rotatable and revocable);
* subject to a WordPress capability check (`manage_options`);
* logged (who, what, when, before/after value);
* reversible where possible (draft/revision, dry-run).

The plugin **never executes arbitrary code**: there is no code-evaluation
endpoint and no remote code download. "Almost everything" means an enumerated,
hand-coded, audited catalog.

* Terms of service: https://audiscale.com/en/terms
* Privacy policy: https://audiscale.com/en/privacy

= Operation catalog =

* SEO / `<head>`: meta description, SEO title, canonical, robots, Open Graph, Twitter cards, native sitemap exclusion
* Redirects: create / update / delete (301, 302, 410)
* Structured data: per-content JSON-LD
* Content & media: field updates (via revision), draft creation, alternative text, status change (publish/draft only), move to trash
* Taxonomies: read the taxonomies and terms a post type declares, assign existing terms to a content (never creates a term)
* Site (read-only): robots.txt, public settings, content inventory
* Site (footer): marked HTML block printed on `wp_footer` (badge install / removal)
* Plugins: inventory with update availability, forced update check, update of an installed plugin
* Connector: status, pairing, audit log

The plugin detects Yoast, Rank Math and SEOPress and **stands down**
automatically if one of them already manages the `<head>`, to avoid duplicate
tags.

== Installation ==

1. Install and activate the plugin (from wordpress.org or by uploading the ZIP).
2. From your AudiScale dashboard, start pairing: AudiScale calls `POST /wp-json/audiscale/v1/pair` with a secret generated on the AudiScale side.
3. That's it — subsequent operations are signed with that secret.

To revoke access at any time: "Settings → AudiScale → Disconnect", or disconnect
the site from AudiScale.

== Frequently Asked Questions ==

= Does the plugin send data without my consent? =

No. No communication happens until the site is paired, and pairing requires an
administrator (`manage_options`).

= What happens if I already have an SEO plugin? =

AudiScale Connector detects Yoast / Rank Math / SEOPress and prints nothing in
the `<head>` to avoid duplicates.

= How do I revoke access? =

Unpairing erases the pairing secret: the command channel becomes inert
immediately.

== Changelog ==

= 5.8.0 =
* New `taxonomy.list` operation (`edit_posts` capability, read-only): lists the
  taxonomies that apply to a post type — categories, tags, and any custom one the
  site declares — and, on request, one bounded page of their terms. AudiScale
  reads it so it can only ever propose a term that exists.
* New `content.terms.assign` operation (`manage_options` capability): files a page
  or a post under existing terms. It **adds** by default: assigning one category
  no longer removes the others, and clearing the existing terms requires spelling
  out `mode: "replace"`. It **never creates a term**: an unknown one is refused and
  reported, so a near-duplicate ("Actualités" on a site holding "Actualité") is
  never silently created. The complete previous set of terms is written to the
  audit log, and the recorded result is re-read after the write, so it shows the
  default category WordPress reassigns to a post left without one.
* New `content.trash` operation (`delete_posts` capability): moves a page or a post
  to the WordPress trash, from where you can restore it. Until now a draft created
  from AudiScale could not be removed from AudiScale. Nothing is ever deleted
  permanently: if the trash is disabled on your site the operation is refused
  instead of destroying the content, and media are out of scope — WordPress does
  not trash an attachment, it deletes it and its files for good.
* The `promote` operation now also approves a page body prepared in draft mode
  (`field: "content"`). It previously handled SEO fields only, so a body drafted by
  AudiScale could be approved from WordPress and nowhere else.
* `content.set_page_template` now honours `publishMode` like every other content
  operation: by default the new template waits on the "Pending" screen instead of
  being applied to the live site immediately.
* `content.status.set` and `content.trash` now refuse to unpublish or trash the
  page used as the front page or as the posts page, which would leave the site
  without an entry point.
* Every content operation now checks that its target is an editable content — not a
  revision, a menu item or a theme template — and that the current user may edit it.
  Custom content types declaring their own capabilities without asking WordPress to map
  them keep working: for those, the check falls back to the generic content capability
  rather than a primitive capability no role holds.
* **Behaviour change** — a URL is now resolved to a content deterministically, and
  never guessed. The plugin reads, in order: the id declared in the URL (`?p=`,
  `?page_id=`, `?attachment_id=` — the three WordPress itself honours), then the
  site's own rewrite rules, then the site root, and refuses otherwise with
  `audiscale_url_needs_post_id` (404). The slug fallback that used to search the
  path against every content type is **removed**: a slug read off a crawled URL
  names the right content only by luck, and every way it named a wrong one ended in
  a success on content nobody aimed at — on a shop holding both a page and a product
  named "contact", the write landed on whichever the database returned last. A URL
  whose path WordPress does not resolve now needs an explicit content id; AudiScale
  resolves the target on its side and sends it.
* A URL pointing at another domain is refused on the whole resolution, not on one
  branch of it: an id declared in the URL does not depend on the domain, so
  "https://autre.com/x/?p=12" used to trash the post 12 of YOUR site, and the root
  of any other domain used to serve your home page. Sites whose public domain is not
  the one WordPress stores (an origin behind a CDN, a migrated domain, one domain
  per language) keep working: the host the request came in on is trusted alongside
  `home_url()`, `site_url()` and the `audiscale_trusted_hosts` filter, and
  internationalized domains, punycode and a trailing dot compare equal.
* An id declared in a URL now has to name editorial content in an editable state:
  a revision, an auto-draft, a trashed content or a media is refused instead of
  written to. `post` and `post_id` are no longer read as content ids — WordPress
  honours neither, while plugins use both as ordinary parameters, so a crawled
  "/recherche/?post_id=2" resolved to the post 2 instead of the search page.
* Fix: on a site installed in a subdirectory ("https://site.fr/blog"), the address
  "https://site.fr/" was treated as the site root and served the WordPress home
  page, although this WordPress does not answer that address at all.
* AudiScale is told, through a dedicated capability token, that this build honours
  `publishMode` on `content.set_page_template` — the operation id alone is the same as
  in 5.7.1, where the template was always applied immediately.
* Fix: assigning a term whose slug is a number ("2024" on a taxonomy of years) was
  refused as an unknown term, because a digits-only value was only ever read as a
  term id.
* Fix: a draft write no longer files a WordPress revision of a body it did not touch.
* Refusals now carry a stable machine-readable `code` (and the details behind it,
  such as the templates the theme really declares) next to the message, so AudiScale
  can tell one refusal from another and suggest the right fix. The `error` field is
  unchanged.

= 5.7.1 =
* Fix: a URL carrying a content id (`?page_id=`, `?p=`, `?post=`, `?post_id=`) was
  read as the site root, because its path is `/`. Depending on the site it either
  failed with `audiscale_no_front_page` or — with a static front page — applied the
  operation to the home page instead of the content actually targeted. The declared
  id is now read first, and an id matching no content fails instead of falling back
  to the home page. Affects every operation resolving a URL, SEO ones included.

= 5.7.0 =
* New `media.upload` operation (`upload_files` capability): sideloads a remote
  image or video into the media library and returns its attachment id, so a page
  AudiScale drafts can reference a real attachment instead of an external URL.
  The downloaded bytes are type-checked (no SVG), the source URL is validated
  against WordPress' own SSRF guard, the transfer is capped at 25 MB (declared
  size checked before the download, response size capped during it), and nothing
  existing is overwritten. This operation is why the plugin requires WordPress
  6.0 or later: `download_url()` fetches through `wp_safe_remote_get()`, which
  re-validates every redirect target against the same SSRF guard rather than
  following it blindly.
* New `blocks.validate` operation (`edit_posts` capability, read-only): parses
  block markup the way the editor does and reports content sitting outside any
  block, unknown block types, or markup that does not survive a parse/serialize
  round-trip. AudiScale calls it before proposing a draft, so a page never lands
  in the editor showing "This block contains unexpected or invalid content".
* New `site.layout_options` operation (`edit_posts` capability, read-only):
  lists the page templates the active theme actually declares, and whether the
  theme renders wide/full alignments. AudiScale reads it before offering any
  layout change, so it can only ever propose a layout the theme can render.
* New `content.set_page_template` operation (`manage_options` capability):
  changes the template of an existing page or post, targeted by `post_id` or
  `url`. The value must be a template the active theme declares (or `default`);
  anything else is refused rather than written, and the before/after is recorded
  in the audit log.
* `content.create_draft` and `content.update_fields` accept an optional
  `page_template`, validated the same way. On creation the template is checked
  before the post is inserted, so a refused template never leaves an orphan
  draft behind.

= 5.6.0 =
* New `plugins.check_updates` operation (`update_plugins` capability): purges the
  `update_plugins` transient and re-runs WordPress' update check immediately, with
  no staleness guard, then reports how many updates are now visible. WordPress only
  refreshes that cache about twice a day and its upgrader refuses any plugin the
  cache does not list, so updating a freshly released version from AudiScale failed
  for hours for no real reason. AudiScale now calls this before retrying, and the
  update goes through. `plugins.list` is unchanged.

= 5.5.0 =
* After a plugin update or activation, the connector announces its own version to
  AudiScale over the existing HMAC-signed channel, so the version AudiScale shows
  and gates features on is right immediately instead of at the next manual check.
  One non-blocking request, only when the site is paired, and nothing is sent
  beyond the version number and the site URL.

= 5.4.0 =
* New `site.footer_snippet.set` / `.clear` / `.get` operations: store a marked
  HTML block (a link and an image, no script) and print it on `wp_footer`. Used
  by AudiScale for the one-click "Verified" badge install. The block lives in an
  option, not in the theme's `footer.php`, so a theme update cannot wipe it, and
  re-posing the same marker replaces the block instead of stacking a second one.

= 5.3.1 =
* `plugins.update` no longer leaves a plugin deactivated after a successful
  update: a failed reactivation is now reported (`reactivationFailed`) instead
  of being swallowed, and a main file renamed by the update is re-resolved
  before reactivating.

= 5.3.0 =
* New `seo.sitemap.exclude` operation: flags a content so the native
  wp-sitemap.xml skips it (reversible with `excluded: false`; draft/approve flow
  supported). No effect while a third-party SEO plugin provides the sitemap.
* New `content.status.set` operation: switches a content between "publish" and
  "draft" only (strict whitelist — no trash, no private, no scheduling),
  `publish_pages` capability, audited before/after.
* Declared compatibility with WordPress 7.1.

= 5.2.0 =
* SEO title and meta description now write into the meta key of the SEO plugin
  that actually renders the page (Yoast, Rank Math, SEOPress) instead of the
  plugin's own key. While one of those is active this plugin does not print its
  own tags — so a value you approved was stored but never appeared on the site.
  Approved changes now take effect. Sites with no third-party SEO plugin are
  unaffected.
* The approval screen reads the current value from that same key, so the
  "current" column no longer shows empty against a field that is set.
* The audit log records the key really written.

= 5.1.1 =
* First public WordPress.org release: English readme and service disclosure,
  packaging hygiene, and internationalization (text domain loading).
* `plugins.update` now keeps the target plugin active after upgrading it (the
  WordPress upgrader deactivates during the file swap and does not restore it on a
  programmatic call).

= 5.0.0 =
* Actionable `security.*` operation family: hardenings applied at the PHP
  runtime, without touching wp-config and always reversible in a single call
  (file editor, REST user enumeration, XML-RPC + pingbacks, HTTP headers
  HSTS/nosniff/X-Frame-Options/Referrer-Policy, minor core auto-updates,
  version masking, `?author=N` scan blocking).
* Each op accepts `dryRun` (simulation without writing) and logs the before/after
  value for an undo via the inverse op. `security.state.get` exposes the current
  state. Disconnecting (`unpair`) resets all hardenings.
* `hsts` is set only if HTTPS is actually enforced (anti-lockout guard).

= 4.0.0 =
* New `security.audit` operation (read-only, `manage_options` capability):
  free hardening snapshot (versions, configuration flags, admin accounts, core
  integrity via wordpress.org checksums). No secret is returned.

= 3.0.0 =
* New plugin-management operations: `plugins.list` (read, `activate_plugins`
  capability) and `plugins.update` (live, irreversible update, `update_plugins`
  capability).

= 2.0.0 =
* `content.update_fields` op: URL targeting + `fields` object
  (title/excerpt/content), respecting `publishMode`.
* The body is written faithfully (no destructive filtering on our side,
  Gutenberg block delimiters preserved); `filtered` flag if the user lacks the
  `unfiltered_html` capability.
* `draft` mode for content: the proposal is queued and approved from the
  "AudiScale pending" screen.

= 1.1.0 =
* `draft` mode: proposed values are stored "pending" without changing the live
  render.
* Approval surface: "Pending AudiScale change" metabox on the edit screen + a
  central screen (Settings → AudiScale pending) to approve/reject (nonce +
  capability).
* `promote` operation to approve a field from AudiScale (consent equivalent to
  `direct`).

= 1.0.0 =
* Initial version: SEO `<head>` output, redirects, structured data, HMAC-signed
  operation catalog, audit log.

== Upgrade Notice ==

= 5.1.1 =
First public WordPress.org release.
