taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

commit 482fe22d407e5619014ad66c2c1adc0f2ec9d6fa
parent 2ad112d33a022a42e946d17d48f63678d7aef366
Author: Christian Grothoff <christian@grothoff.org>
Date:   Tue, 26 May 2026 22:17:26 +0200

add wordpress-paivana manual

Diffstat:
Mcore/api-merchant.rst | 6++++++
Mfrags/wordpress-paivana-manual.rst | 529++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Ascreenshots/wordpress-turnstile-paywall-page.png | 0
Ascreenshots/wordpress-turnstile-plugin-activate.png | 0
Ascreenshots/wordpress-turnstile-post-meta-box.png | 0
Ascreenshots/wordpress-turnstile-price-category-edit.png | 0
Ascreenshots/wordpress-turnstile-price-category-list.png | 0
Ascreenshots/wordpress-turnstile-settings-form.png | 0
Ascreenshots/wordpress-turnstile-subscription-list.png | 0
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.