commit 482fe22d407e5619014ad66c2c1adc0f2ec9d6fa
parent 2ad112d33a022a42e946d17d48f63678d7aef366
Author: Christian Grothoff <christian@grothoff.org>
Date: Tue, 26 May 2026 22:17:26 +0200
add wordpress-paivana manual
Diffstat:
9 files changed, 533 insertions(+), 2 deletions(-)
diff --git a/core/api-merchant.rst b/core/api-merchant.rst
@@ -1739,6 +1739,12 @@ and is thus not yet buyer-specific.
// Defaults to one if the field is not provided.
count?: Integer;
+ // When should the output token be valid. Can be specified if the
+ // desired validity period should be in the future (like selling
+ // a subscription for the next month). Optional. If not given,
+ // the validity is supposed to be "now" (time of order creation).
+ valid_at?: Timestamp;
+
// Index of the public key for this output token
// in the `ContractTokenFamily` ``keys`` array.
key_index: Integer;
diff --git a/frags/wordpress-paivana-manual.rst b/frags/wordpress-paivana-manual.rst
@@ -22,7 +22,532 @@
Wordpress integration
=====================
+.. note::
+
+ This chapter is still early work in progress.
+
This chapter documents the installation and operation of the
-Wordpress Paivana module.
+``taler-turnstile`` Wordpress plugin. The plugin gates individual Wordpress
+posts (and any other selected public post type) behind a GNU Taler paywall:
+visitors who have not yet paid are shown a teaser plus a payment widget, and
+once their wallet has paid the associated merchant template the full post
+content is unlocked.
+
+Like its :ref:`Drupal counterpart <drupal-paivana>`, the plugin is built
+around the same Paivana-style flow as :ref:`paivana-httpd <Paivana-httpd>`: a
+static, cacheable paywall page references a *payment template* in the merchant
+backend, no order is created when a visitor merely loads a paywalled article,
+and no PHP session is started for unauthenticated traffic. This keeps bot
+traffic from polluting the merchant backend and lets Wordpress' page cache (or
+any reverse-proxy cache in front of it) serve the paywall page to anonymous
+visitors without per-request work.
+
+
+Architecture overview
+---------------------
+
+A Wordpress site using Turnstile combines three independent
+components:
+
+1. **The Wordpress site.** An ordinary Wordpress 6.3+ installation
+ serving one or more public post types (``post``, ``page``, custom
+ post types, ...). The ``taler-turnstile`` plugin adds a
+ *Price Category* meta field (stored as the post meta key
+ ``_taler_price_category``) to the post types you select, and
+ hooks ``the_content`` and ``template_redirect`` to intercept
+ rendering of posts that carry a non-empty value.
+2. **A GNU Taler merchant backend** (``taler-merchant-httpd``).
+ The backend stores one *payment template* per price category
+ defined in Wordpress, manages orders, talks to one or more Taler
+ exchanges, and ultimately reports back whether a given order
+ has been paid. See the
+ :ref:`Taler Merchant Backend Operator Manual
+ <taler-merchant-backend-operator-manual>` for full details.
+3. **The visitor's GNU Taler wallet.** The wallet scans the QR
+ code shown on the paywall page (or follows the
+ ``taler://pay-template/…`` link), pays the merchant template
+ and triggers the unlock.
+
+Turnstile itself stores no per-visitor state in the Wordpress database; once
+the merchant backend confirms a payment, Turnstile mints a short-lived HMAC
+cookie (``taler_turnstile_paivana``, keyed by Wordpress' built-in
+``wp_salt('auth')``) that the browser presents on subsequent requests to the
+same fulfillment URL. Clearing cookies forgets paid access, but GNU Taler
+wallet's repurchase detection will restore the cookie if the user attempts to
+pay for the same article again.
+
+ .. note::
+
+ This feature likely does not interact well with expiration
+ periods for article purchases. This is a bug that will
+ require further work to fully address. See #11443.
+
+
+Installation
+------------
+
+Requirements
+^^^^^^^^^^^^
+
+- Wordpress 6.3 or later
+- PHP 8.0 or later
+- **Pretty permalinks must be enabled.** With the default plain
+ permalinks (``?p=123``) every post is served from path ``/``,
+ which means the path-scoped access cookie cannot be isolated
+ per article; the plugin therefore refuses to paywall posts in
+ that configuration and shows an admin notice on the settings
+ pages until pretty permalinks are turned on under
+ *Settings → Permalinks*.
+- A reachable GNU Taler merchant backend supporting the v29 (or
+ newer) ``paivana`` template type and the public
+ ``/sessions/$SESSION_ID`` endpoint
+- An access token for the merchant instance with the
+ ``templates-write`` and ``orders-read`` permissions
+
+
+Obtaining the plugin
+^^^^^^^^^^^^^^^^^^^^
+
+The plugin sources are available from the GNU Taler Git repository as well as
+Wordpress' own plugin directory. Download the latest release and extract it
+into your Wordpress installation's ``wp-content/plugins/`` directory so that
+the resulting layout is:
+
+.. code-block:: shell-session
+
+ $ ls wp-content/plugins/taler-turnstile/
+ admin/ assets/ includes/
+ COPYING README.md taler-turnstile.php
+
+There is no build step and no Composer package: the plugin is plain PHP and a
+handful of JavaScript files. The QR code library ``assets/js/qrcode.min.js``
+is vendored from `davidshimjs/qrcodejs
+<https://github.com/davidshimjs/qrcodejs>`__ and must be present for the QR
+code to render; see ``assets/js/QRCODE-README.md`` in the plugin directory for
+the download instructions if you are building from source.
+
+
+Activating the plugin
+^^^^^^^^^^^^^^^^^^^^^
+
+Once the directory is in place, activate the plugin from
+*Plugins → Installed Plugins* and press *Activate* on the
+*GNU Taler Turnstile* row:
+
+.. image:: screenshots/wordpress-turnstile-plugin-activate.png
+
+Activation registers the plugin's options with their defaults
+(by default, the ``post`` post type is enabled and the backend
+URL/token are left blank) and adds three new admin menu entries:
+
+- *Settings → Taler Turnstile* (main settings),
+- *Settings → Taler Subscriptions* (subscription purchase prices),
+- *Taler Prices* (top-level menu, price-category management).
+
+Deactivating the plugin removes the meta box and stops the
+paywall from rendering, but it does currently **not** delete templates
+already published to the merchant backend; remove those manually
+if desired (see :ref:`template`).
+
+
+Configuring the basics
+----------------------
+
+The main settings page lives at
+``wp-admin/options-general.php?page=taler-turnstile-settings``
+and is also reachable through *Settings → Taler Turnstile*. It
+collects the four pieces of information Turnstile needs to talk
+to the merchant backend and to know which types of content to gate:
+
+.. image:: screenshots/wordpress-turnstile-settings-form.png
+
+The fields are:
+
+- **Enabled Post Types** — a list of checkboxes derived from
+ every *public* post type registered on the site (the built-in
+ ``post`` and ``page``, plus any custom post types). Each
+ ticked post type gains the *Taler Turnstile Price Category*
+ sidebar meta box on its editor screen, and gets it removed
+ again when unticked. Disabling every post type triggers a
+ one-shot cleanup that drops the ``_taler_price_category`` meta
+ from every post on the site, so re-enabling a post type later
+ starts from a clean slate.
+- **Payment Backend URL** — the HTTP(S) base URL of the merchant
+ backend. Instance-specific URLs ending in ``/instances/$ID/``
+ are accepted; the URL **must end with a trailing slash**. When
+ you save the form with a non-empty value, Turnstile contacts
+ the backend's ``/config`` endpoint to verify reachability and
+ refuses to save the value if the backend does not respond as a
+ ``taler-merchant`` instance.
+- **Access Token** — the Bearer token used for every call to the
+ backend. It **must begin with** ``secret-token:`` (following
+ RFC 8959, see :ref:`Access control
+ <taler-merchant-backend-operator-manual>` in the merchant
+ manual). When both URL and token are present, the form
+ performs a live authentication check against the backend and
+ surfaces the HTTP status as an admin notice if the access
+ token is wrong (401/403), the instance does not exist (404),
+ or the backend is otherwise unreachable.
+- **Disable Turnstile on Error** (fail-open toggle, off by
+ default) — when the merchant backend cannot be reached, this
+ controls whether visitors see the unpaywalled article
+ (checked) or an error message in place of the payment widget
+ (unchecked). The settings form warns you with an admin
+ notice if you leave the toggle off while the backend URL or
+ access token is missing, so a misconfiguration cannot
+ silently break the live site.
+
+The first time you save a working backend URL and access token,
+Turnstile pushes a ``paivana``-style template to the merchant
+backend for every price category that already exists. Each
+template carries the ID ``turnstile-{category_id}``. You do not
+need to create those templates by hand, and you do not need to
+visit the merchant backend's SPA to manage them — Turnstile keeps
+them in sync as you edit the categories (see
+:ref:`wordpress-paivana-price-categories` below). Saving a new
+backend URL or access token also republishes every existing
+price-category template, on the assumption that the new instance
+does not have them yet.
+
+Before continuing, make sure that the merchant backend instance
+itself is ready to take payments:
+
+- The instance has at least one configured
+ :ref:`bank account <instance-bank-account>`.
+- Any legitimization required by the chosen payment service
+ provider has been completed.
+
+
+.. _wordpress-paivana-subscription-prices:
+
+Configuring subscription prices
+-------------------------------
+
+.. note::
+
+ The term *subscription* is slightly misleading, as it is not
+ auto-renewing. We are in the process of renaming this to *passes* which is
+ clearer. However, the current plugin uses subscription and thus so does this
+ manual.
+
+Turnstile supports the GNU Taler subscription model, in which a visitor buys a
+*subscription token* once and the token is then consumed to unlock individual
+articles for some validity period. The catalogue of available *token
+families* is configured on the merchant backend (see the merchant manual
+section on :ref:`templates <template>` and the wider Taler concept of
+subscription tokens); Turnstile reads the catalogue live from
+``private/tokenfamilies`` and asks you to set the *purchase price* per token
+family per currency.
+
+Navigate to *Settings → Taler Subscriptions* (URL
+``wp-admin/admin.php?page=taler-subscription-prices``). The page refuses to
+render until the backend is configured and reachable — it needs the live
+token-family list and a complete currency inventory from ``/config``. When
+the backend is ready, the page lists one *postbox* block per token family
+advertised by the backend, with one numeric input per supported currency:
+
+.. image:: screenshots/wordpress-turnstile-subscription-list.png
+
+Leaving a field empty disables the *Buy subscription in
+{currency}* payment choice for that combination — the
+subscription will then not be offered for sale in that currency
+on any article.
+
+If the merchant backend has no token families configured, the page shows a
+warning and offers nothing to edit. In that case you can still operate
+Turnstile in pure pay-per-article mode; simply skip this configuration step.
+
+Whenever this page is saved, Turnstile republishes **every** price-category
+template against the backend so the new subscription prices appear in the
+wallet's *Buy subscription* choices immediately.
+
+.. note::
+
+ GNU Taler token families offer more flexible payment options,
+ not just subscriptions. However, only subscriptions are
+ supported by Turnstile at this time.
+
+
+.. _wordpress-paivana-price-categories:
+
+Defining price categories
+-------------------------
+
+A *price category* is a named set of payment options that can be attached to
+posts. Categories are managed under the top-level *Taler Prices* menu in the
+admin sidebar (URL ``wp-admin/admin.php?page=taler-price-categories``). The
+list page shows the existing categories together with their descriptions and
+offers *Edit*, *Delete* and *Add New* actions:
+
+.. image:: screenshots/wordpress-turnstile-price-category-list.png
+
+
+Creating or editing a price category
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Click *Add New* (or *Edit* on an existing row) to reach the
+price-category form. The form has two sections:
+
+- **Name** — human-readable label for the category (e.g.
+ *Standard article*, *Long-form feature*). Shown to editors
+ when they pick a category on a post-edit screen and used as
+ the merchant template's summary text.
+- **Description** — free text shown to editors and used as the
+ template description on the merchant backend if non-empty.
+
+Below the meta fields, the form has one *Prices* postbox per known token
+family, plus a special block labelled *No reduction* that holds the price for
+non-subscribers. Each block contains one numeric input per supported
+currency:
+
+.. image:: screenshots/wordpress-turnstile-price-category-edit.png
+
+Pricing semantics:
+
+- A value in the *No reduction* block sets the **per-article
+ price for visitors without a subscription** in that currency.
+ Leave it empty to make the article available **only** to
+ subscribers in that currency.
+- A value in a subscription block sets the **discounted price
+ for holders of that subscription token** in that currency. A
+ value of exactly ``0`` marks the article as *fully covered* by
+ that subscription — subscribers see it for free. An empty
+ field means "this subscription does not unlock articles of
+ this category in this currency", so a holder of such a
+ subscription has to pay the full per-article price.
+
+At least one price (across all blocks and currencies) must be set; otherwise
+the category yields no valid payment choices and the matching template cannot
+be published.
+
+Saving the form publishes the category as a ``paivana``-style template to the
+merchant backend (the v1 ``choices`` array is built from the price grid; see
+:ts:type:`TemplateContractPaivana` in the :ref:`Merchant Backend HTTP API
+<merchant-api>`). If the local save succeeds but the backend push fails
+(network error, 401, ...), the admin notice on the next page surfaces the
+error.
+
+.. note::
+
+ In this case, the local price in Wordpress and the merchant
+ template may have drifted out of sync. Visitors would still
+ pay the price in the merchant backend, so this error should
+ always be investigated.
+
+Editing a category or changing prices automatically refreshes the matching
+merchant template via ``PATCH``. Deleting a category removes its template via
+``DELETE``; if the delete on the backend fails, you are warned to clean it up
+manually.
+
+
+Gating individual posts
+-----------------------
+
+Once a post type is enabled in the basic settings (see above)
+and at least one price category exists, every post of that
+post type gains a new *Taler Turnstile Price Category* sidebar
+meta box on its editor screen (shown in the bottom right corner):
+
+.. image:: screenshots/wordpress-turnstile-post-meta-box.png
+
+To paywall a post:
+
+1. Edit or create a post of an enabled post type.
+2. Pick a category in the *Price Category* drop-down on the
+ sidebar meta box.
+3. *Update* / *Publish* the post.
+
+Leaving the drop-down on *-- None --* leaves the post freely accessible — the
+paywall logic only fires for posts that reference a category.
+
+If you want to remove the paywall from a post, edit it and change the meta box
+back to *-- None --*. If you want to lift the paywall from an entire post
+type, untick it in the *Enabled Post Types* list on the settings page; the
+meta box and the post meta are then removed from every post of that post type.
+Re-enabling the post type later starts from a clean slate, forcing you to set
+the price categories again for all posts of that type.
+
+
+The visitor experience
+----------------------
+
+Anonymous visitor without a cookie
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+For a post that references a price category, an anonymous
+visitor without a valid ``taler_turnstile_paivana`` cookie sees:
+
+- The post rendered as its *excerpt* (the explicit ``excerpt``
+ field, or the first 55 words of the body if none is set).
+- A *Payment Required* block immediately below the excerpt with
+ a QR code (encoding the ``taler://pay-template/…`` URI for the
+ matching merchant template), a *Scan with your GNU Taler
+ wallet* hint, and — when the browser advertises support for
+ the ``taler`` URI scheme — an *Open GNU Taler payment page*
+ button.
+- A pricing summary showing the per-article price (or "Not sold
+ individually" if only subscriptions unlock the category) and
+ the list of subscriptions that accept this category.
+- A *Waiting for payment…* status line that updates once the
+ wallet picks up the order.
+
+.. image:: screenshots/wordpress-turnstile-paywall-page.png
+
+The page is identical for every visitor without the cookie, so Wordpress' page
+cache (and any reverse-proxy cache in front of it) can serve it without
+per-request work. The response sets ``Vary: Cookie`` so a returning paying
+visitor's path-scoped access cookie still busts the cached paywall, and the
+``taler://pay-template/…`` URI is also advertised as a ``Paivana:`` HTTP
+header for non-JavaScript Taler clients.
+
+Behind the scenes, the JavaScript on the page does **not** create an order in
+the merchant backend. It generates a fresh nonce, derives a session ID from
+it (the ``paivana_id``), renders the QR code, and long-polls the public
+``sessions/{paivana_id}`` endpoint on the merchant backend waiting for the
+wallet to pick the template up and pay. No order, no PHP session and no
+database write happens until the visitor's wallet actually engages with the
+template.
+
+
+Paying
+^^^^^^
+
+The visitor scans the QR code with a `GNU Taler wallet
+<https://docs.taler.net/wallet/>`__ (or clicks the *Open GNU Taler payment
+page* button when running a browser with a wallet extension). The wallet
+shows the available payment choices — for a typical category these are:
+
+- *Pay in {currency}* — the per-article price for non-subscribers.
+- *Pay in {currency} with subscription* — only offered to
+ visitors whose wallet holds a matching, unspent subscription
+ token (in which case it usually costs ``{currency}:0``).
+- *Buy subscription in {currency}* — pays the per-article price
+ plus the subscription's purchase price (from
+ :ref:`wordpress-paivana-subscription-prices` above) and yields
+ a fresh subscription token in the visitor's wallet.
+
+The visitor picks an option, the wallet pays, and Wordpress'
+JavaScript proceeds to the confirmation step.
+
+
+Confirmation and unlock
+^^^^^^^^^^^^^^^^^^^^^^^
+
+Once the merchant backend reports the order as paid, the JavaScript POSTs
+``{order_id, nonce, cur_time, website}`` to the plugin's REST endpoint at
+``/wp-json/taler-turnstile/v1/paivana``. The Wordpress-side controller
+re-derives the canonical ``paivana_id``, resolves the fulfillment URL back to
+a local post via ``url_to_postid()``, fetches the order from the merchant
+backend and **verifies that the paid contract's ``fulfillment_url`` matches
+the requesting page and that the paid amount is one of the amounts the price
+category accepts**. Only then does it mint the ``taler_turnstile_paivana``
+cookie and instruct the browser to reload the article.
+
+The cookie is keyed on Wordpress' built-in ``wp_salt('auth')``, bound to the
+visitor's ``REMOTE_ADDR`` and ``Path``-scoped to the specific fulfillment URL,
+marked ``HttpOnly``, ``SameSite=Lax``, and ``Secure`` when the page was served
+over HTTPS. It is valid for 24 hours (the ``ORDER_VALIDITY_SECONDS`` constant
+in ``Taler_Merchant_API``; this will follow the merchant-supplied
+``pay_deadline``/``max_pickup_time`` in the future). The cookie covers
+exactly one fulfillment URL — clearing cookies forgets paid access, and access
+to a different paywalled article requires a fresh payment (or a valid
+subscription).
+
+The REST confirmation endpoint is unauthenticated by design (the payer has no
+Wordpress account) and is therefore rate-limited per-IP to at most 30 requests
+per minute, comfortably above legitimate use but capping the abuse of an
+endpoint that triggers a merchant-backend round-trip per call.
+
+.. note::
+
+ ``ORDER_VALIDITY_SECONDS`` should probably be made
+ configurable in the future, as should the restriction of the
+ client to a particular IP address, as that may or may not be
+ desired. See #11444.
+
+
+Subscriptions in action
+^^^^^^^^^^^^^^^^^^^^^^^
+
+When a paid contract bought a subscription token, Turnstile also stores the
+token family slug and the token's expiration time in the visitor's PHP session
+(the session is started only *after* the successful payment, so the cacheable
+pre-payment path is unaffected). Subsequent requests to other posts whose
+price category is fully covered by the same subscription short-circuit the
+paywall entirely: the visitor never sees the *Payment Required* block again
+until the token expires.
+
+Visitors without a PHP-session cookie still see the paywall page (so
+anonymous, cache-friendly traffic stays cacheable); the subscriber check is
+gated on the session cookie already being present so that loading a paywalled
+post never creates a session on its own.
+
+
+Operational notes
+-----------------
+
+Caching
+^^^^^^^
+
+The plugin emits cache-control headers tuned for the two paths it produces:
+
+- The static paywall response carries ``Vary: Cookie``, so a
+ shared cache (page cache plugin, reverse proxy, CDN) can store
+ one copy for all anonymous visitors while still busting it for
+ returning paying visitors whose path-scoped
+ ``taler_turnstile_paivana`` cookie now accompanies their
+ request.
+- The post-payment response (the article, served only to the
+ visitor who paid) is marked ``Cache-Control: private,
+ no-store`` and triggers the ``DONOTCACHEPAGE`` define plus
+ ``nocache_headers()`` so it never lands in a shared cache.
+- The REST confirmation endpoint under ``/wp-json/`` is not
+ page-cached by Wordpress, and its response is explicitly
+ marked ``no-store``.
+
+A reverse-proxy cache in front of Wordpress must therefore honour the ``Vary:
+Cookie`` header to avoid handing one paying visitor's full-content response to
+another visitor.
+
+
+Logging
+^^^^^^^
+
+The plugin writes to PHP's ``error_log`` (i.e. the destination your
+``php.ini`` or web server configures for ``error_log``). Notable events that
+are logged include unreachable backends, malformed responses from the merchant
+backend, paid orders whose contract does not match the fulfillment URL or
+whose paid amount falls outside the price category, and template
+create/update/delete failures. When debugging an integration problem, tailing
+the PHP error log while reproducing the issue is usually the fastest way to
+identify the culprit.
+
+
+Permissions
+^^^^^^^^^^^
+
+All admin pages — the basic settings, the subscription prices and the
+price-category CRUD — are gated on Wordpress' ``manage_options`` capability,
+which corresponds to the *Administrator* role. Editors who only need to
+assign a price category to a post do not need *manage_options*: the sidebar
+meta box is rendered for any user who can edit the post in question
+(``edit_post`` capability check on the meta field).
+
+
+Hooks and filters
+^^^^^^^^^^^^^^^^^
+
+The plugin exposes a small number of WordPress filters so a theme or
+site-specific plugin can adapt Turnstile to local conventions without forking
+it:
+
+- ``taler_turnstile_enabled_post_types`` — alter the list of
+ post types subject to the paywall transformation.
+- ``taler_turnstile_payment_choices`` — modify the payment
+ ``choices`` array before it is sent to the merchant backend.
+- ``taler_turnstile_currencies`` — add to or modify the
+ list of currencies discovered from the backend's ``/config``.
-FIXME!
+These hooks are intentionally narrow; deeper customisation (for example,
+overriding the paywall HTML) requires patching
+``Taler_Content_Filter::render_paywall()`` directly.
diff --git a/screenshots/wordpress-turnstile-paywall-page.png b/screenshots/wordpress-turnstile-paywall-page.png
Binary files differ.
diff --git a/screenshots/wordpress-turnstile-plugin-activate.png b/screenshots/wordpress-turnstile-plugin-activate.png
Binary files differ.
diff --git a/screenshots/wordpress-turnstile-post-meta-box.png b/screenshots/wordpress-turnstile-post-meta-box.png
Binary files differ.
diff --git a/screenshots/wordpress-turnstile-price-category-edit.png b/screenshots/wordpress-turnstile-price-category-edit.png
Binary files differ.
diff --git a/screenshots/wordpress-turnstile-price-category-list.png b/screenshots/wordpress-turnstile-price-category-list.png
Binary files differ.
diff --git a/screenshots/wordpress-turnstile-settings-form.png b/screenshots/wordpress-turnstile-settings-form.png
Binary files differ.
diff --git a/screenshots/wordpress-turnstile-subscription-list.png b/screenshots/wordpress-turnstile-subscription-list.png
Binary files differ.