commit 38c6e180c261cb17311c023c69bf45673ffac871
parent 8dff335c269adf4b4d7fc0c9ea5e75f098f0801e
Author: Christian Grothoff <christian@grothoff.org>
Date: Mon, 25 May 2026 22:38:34 +0200
add drupal-turnstile manual
Diffstat:
14 files changed, 547 insertions(+), 35 deletions(-)
diff --git a/core/api-merchant.rst b/core/api-merchant.rst
@@ -1317,13 +1317,13 @@ and basically present (or optional) all the time.
// legal requirements.
minimum_age?: Integer;
- // Default money pot to use for this product, applies to the
+ // Default money pot to use for this order, applies to the
// amount remaining that was not claimed by money pots of
// products or taxes. Not useful to wallets, only for
// merchant-internal accounting. If not given, the remaining
// account is simply not accounted for in any money pot.
// Since **v25**.
- default_money_pot?: Integer;
+ order_default_money_pot?: Integer;
// Latest time until which the good or service specified in the
// contract may be picked up by the customer. This is usually
diff --git a/drupal-paivana-manual.rst b/drupal-paivana-manual.rst
@@ -1,27 +0,0 @@
-..
- This file is part of GNU TALER.
-
- Copyright (C) 2026 Taler Systems SA
-
- TALER is free software; you can redistribute it and/or modify it under the
- terms of the GNU Affero General Public License as published by the Free Software
- Foundation; either version 2.1, or (at your option) any later version.
-
- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License along with
- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
-
- @author Christian Grothoff
-
-.. _drupal-paivana:
-
-Drupal integration
-==================
-
-This chapter documents the installation and operation of the
-Drupal Paivana module.
-
-FIXME!
diff --git a/frags/drupal-paivana-manual.rst b/frags/drupal-paivana-manual.rst
@@ -0,0 +1,542 @@
+..
+ This file is part of GNU TALER.
+
+ Copyright (C) 2026 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 2.1, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+ @author Christian Grothoff
+
+.. _drupal-paivana:
+
+
+Drupal integration
+==================
+
+This chapter documents the installation and operation of the
+``taler_turnstile`` Drupal module. The module gates individual
+nodes (articles, pages, …) of a Drupal 9 or 10 site behind a
+GNU Taler paywall: visitors who have not yet paid are shown a
+teaser plus a payment button, and once their wallet has paid the
+associated merchant template the full content is unlocked.
+
+The module is designed 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
+Drupal's page caches serve the paywall page to anonymous visitors
+without per-request work.
+
+
+Architecture overview
+---------------------
+
+A Drupal site using Turnstile combines three independent
+components:
+
+1. **The Drupal site.** An ordinary Drupal 9 or 10 installation
+ serving content of one or more content types
+ (``article``, ``page``, …). The ``taler_turnstile`` module
+ adds an entity-reference field called *Price category* to the
+ content types you select, and uses ``hook_entity_view_alter()``
+ to intercept rendering of nodes 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 Drupal, 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 Drupal
+database; once the merchant backend confirms a payment, Turnstile
+mints a short-lived HMAC cookie (``taler_turnstile_paivana``)
+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
+^^^^^^^^^^^^
+
+- Drupal 9 or 10
+- PHP 8.1 or later (the module uses native enums)
+- The Drupal core modules ``node``, ``field``, ``user`` and
+ ``path_alias`` (the latter is used by the confirmation
+ endpoint to map fulfillment URLs back to nodes)
+- 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 module
+^^^^^^^^^^^^^^^^^^^^
+
+The module sources are available from the GNU Taler Git repository as well as
+Drupal's own module repository. Download the latest release and extract it
+into your Drupal installation's ``modules/custom/`` directory so that the
+resulting layout is:
+
+.. code-block:: shell-session
+
+ $ ls modules/custom/taler_turnstile/
+ config/ js/ taler_turnstile.info.yml
+ src/ templates/ taler_turnstile.module
+ ...
+
+There is no build step and no Composer package: the module is
+plain PHP and a couple of JavaScript files (the QR code library
+is vendored from `davidshimjs/qrcodejs
+<https://github.com/davidshimjs/qrcodejs>`__).
+
+
+Enabling the module
+^^^^^^^^^^^^^^^^^^^
+
+The module can be enabled via Drush or via the Drupal admin
+interface:
+
+.. tab-set::
+
+ .. tab-item:: Drush
+
+ .. code-block:: shell-session
+
+ $ drush en taler_turnstile
+ $ drush cr
+
+ .. tab-item:: Admin UI
+
+ Navigate to ``/admin/modules``, locate *GNU Taler Turnstile*
+ in the *System* package, tick its checkbox and press
+ *Install*.
+
+ .. image:: screenshots/drupal-turnstile-module-enable.png
+
+ After installation, clear the cache so that Drupal picks
+ up the new routes, services and theme template
+ (``drush cr`` or *Configuration → Performance →
+ Clear all caches*).
+
+Enabling the module triggers ``hook_install()``, which attaches the
+``field_taler_turnstile_prcat`` entity-reference field to every content type
+listed in ``enabled_content_types`` (default: ``article``).
+
+Uninstalling with ``drush pmu taler_turnstile`` removes the
+price-category field from every configured content type and
+deletes the module's configuration. It does **not** delete
+templates from the merchant backend; remove those manually if
+desired (see :ref:`template`).
+
+
+Configuring the basics
+----------------------
+
+The main settings page lives at
+``/admin/config/system/taler-turnstile`` and is also reachable
+through *Configuration → System → GNU Taler Turnstile basics*.
+It collects the four pieces of information Turnstile needs to
+talk to the merchant backend and to know which content to gate:
+
+.. image:: screenshots/drupal-turnstile-settings-form.png
+
+The fields are:
+
+- **Enabled content types** — a list of checkboxes derived from
+ the site's configured node bundles. Each ticked content type
+ gains the *Price category* field (and gets it removed again
+ when unticked). The change takes effect immediately on save:
+ the field is created or destroyed as a side-effect of saving
+ this form, not via ``drush cim``.
+- **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 before
+ accepting the value.
+- **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 an error is shown if the access token is incorrect.
+- **Disable Turnstile when payment backend is unavailable**
+ (fail-open toggle, on by default) — when the merchant backend
+ cannot be reached, this controls whether visitors see the
+ unpaywalled article (toggle on) or an error message in place
+ of the payment button (toggle off). The settings form refuses
+ to save with the toggle off and an empty backend URL or token
+ unless you confirm the warning, 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:`drupal-paivana-price-categories` below).
+
+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.
+
+
+.. _drupal-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 module 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
+``/admin/config/system/taler_turnstile/subscription-prices``
+(also reachable as *Configuration → System → GNU Taler Turnstile
+subscription prices*). The form lists one collapsible *Details*
+block per token family advertised by the backend, with one
+numeric input per supported currency:
+
+.. image:: screenshots/drupal-turnstile-subscription-prices.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.
+
+If the merchant backend has no token families configured, the
+form 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 form 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 at this
+ time.
+
+
+.. _drupal-paivana-price-categories:
+
+Defining price categories
+-------------------------
+
+A *price category* is a named bucket of payment options that can
+be attached to nodes. Categories are managed at
+``/admin/structure/taler-turnstile-price-categories`` (also
+reachable as *Structure → GNU Taler Turnstile price
+categories*). The collection page lists the existing categories
+together with their machine names and descriptions and offers
+*Edit*, *Delete* and *Add price category* actions:
+
+.. image:: screenshots/drupal-turnstile-price-category-list.png
+
+
+Creating or editing a price category
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Click *Add price category* (or *Edit* on an existing row) to
+reach the price-category form. The form has three 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 node form.
+- **Machine name** — the slug used to build the merchant
+ template ID (``turnstile-{machine_name}``). Derived
+ automatically from the initial **Name**; fixed once the
+ category exists.
+- **Description** — free text shown to editors in the node form
+ to help them pick the right category.
+- **Prices** — a fieldset with one collapsible *Details* block
+ 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/drupal-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", forcing subscribers
+ (with this type of subscription) to pay the full per-article
+ price.
+
+At least one price (across all blocks and currencies) must be
+set; otherwise the form refuses to save as there would be no
+way to purchase articles in that price category otherwise.
+
+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 form surfaces an error message.
+
+.. note::
+
+ In this case, the local price in Drupal 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, renaming it, or changing prices
+automatically refreshes the matching merchant template.
+Deleting a category removes its template; if the delete on the
+backend fails, you are warned to clean it up manually.
+
+
+Gating individual nodes
+-----------------------
+
+Once a content type is *enabled* in the basic settings (see
+above) and at least one price category exists, every node of
+that content type gains a new *Price category* field in its
+editing form. By default the field is placed in the *meta*
+group on the right-hand side of the node form.
+
+.. image:: screenshots/drupal-turnstile-node-edit.png
+
+To paywall a node:
+
+1. Edit or create a node of an enabled content type.
+2. Pick a price category in the *Price category* field.
+3. Save the node.
+
+Leaving the field empty leaves the node freely accessible — the
+paywall logic only fires for nodes that reference a category.
+
+If you want to remove the paywall from a node, edit it and clear
+the field. If you want to lift the paywall from an entire
+content type, untick it in the *Enabled content types* list on
+the settings form; the field and its values are then removed.
+(The field's contents are dropped, so re-enabling the content
+type later starts from a clean slate, forcing you to set the
+price categories again for all nodes in that category.)
+
+
+The visitor experience
+----------------------
+
+Anonymous visitor without a cookie
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+For a node that references a price category, an anonymous
+visitor without a valid ``taler_turnstile_paivana`` cookie sees:
+
+- The node rendered in its *teaser* view mode. A soft fade-out
+ is applied at the bottom of the teaser so it is visually clear
+ that there is more content behind the paywall.
+- A *Payment required* block immediately below the teaser, 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 Web
+ 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/drupal-turnstile-paywall-page.png
+
+The page is identical for every visitor without the cookie, so
+Drupal's page cache serves it without per-request work. 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, 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 Web 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:`drupal-paivana-subscription-prices` above) and yields a
+ fresh subscription token in the visitor's wallet.
+
+The visitor picks an option, the wallet pays, and Drupal's
+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
+``/taler-turnstile/paivana``. The Drupal-side controller
+re-derives the session ID, 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 303-redirect the browser back to the article.
+
+The cookie is keyed on Drupal's per-site ``private_key``, bound
+to the visitor's client IP and 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 the API service; this
+will follow the merchant-supplied
+``pay_deadline``/``max_pickup_time`` once backend v1.6 lands).
+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).
+
+.. 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. Subsequent requests to
+other articles 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 session cookie still see the paywall page
+(so anonymous, cache-friendly traffic stays cacheable).
+
+
+Operational notes
+-----------------
+
+Logging
+^^^^^^^
+
+The module logs to the dedicated ``taler-turnstile`` channel.
+To watch the live event stream while debugging:
+
+.. code-block:: shell-session
+
+ $ drush watchdog:tail --type=taler-turnstile
+
+Levels are used meaningfully: ``debug`` for flow tracing,
+``info`` for expected skips (for example, a subscription slug
+the backend no longer advertises), ``warning`` for user-fixable
+configuration issues, and ``error`` for protocol violations or
+unexpected HTTP statuses from the backend.
+
+
+Cache management
+^^^^^^^^^^^^^^^^
+
+The module declares ``cookies:taler_turnstile_paivana`` as a
+cache context on every paywalled response, so Drupal's internal
+page cache and ``dynamic_page_cache`` separate visitors with a
+cookie from those without. A reverse-proxy cache in front of
+Drupal must therefore honour the ``Vary: Cookie`` header to
+avoid handing one paying visitor's full-content response to
+another visitor.
+
+
+Configuration import/export
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Both ``taler_turnstile.settings`` and each
+``taler_turnstile_price_category.*`` config entity participate in
+the normal Drupal ``drush cex`` / ``drush cim`` workflow.
+Importing a configuration change that adds or removes content
+types from ``enabled_content_types`` triggers field
+add/removal; importing a price-category change republishes the
+matching merchant template.
+
+
+Permissions
+^^^^^^^^^^^
+
+Two permissions are exposed:
+
+- **Administer GNU Taler Turnstile** — grants access to the
+ *basics* and *subscription prices* settings forms.
+- **Administer price categories** — grants access to the
+ price-category CRUD pages.
+
+Both default to *restrict access* and should normally only be
+granted to trusted administrators.
diff --git a/paivana-httpd-manual.rst b/frags/paivana-httpd-manual.rst
diff --git a/wordpress-paivana-manual.rst b/frags/wordpress-paivana-manual.rst
diff --git a/screenshots/drupal-turnstile-module-enable.png b/screenshots/drupal-turnstile-module-enable.png
Binary files differ.
diff --git a/screenshots/drupal-turnstile-node-edit.png b/screenshots/drupal-turnstile-node-edit.png
Binary files differ.
diff --git a/screenshots/drupal-turnstile-paywall-page.png b/screenshots/drupal-turnstile-paywall-page.png
Binary files differ.
diff --git a/screenshots/drupal-turnstile-price-category-edit.png b/screenshots/drupal-turnstile-price-category-edit.png
Binary files differ.
diff --git a/screenshots/drupal-turnstile-price-category-list.png b/screenshots/drupal-turnstile-price-category-list.png
Binary files differ.
diff --git a/screenshots/drupal-turnstile-settings-form.png b/screenshots/drupal-turnstile-settings-form.png
Binary files differ.
diff --git a/screenshots/drupal-turnstile-subscription-prices.png b/screenshots/drupal-turnstile-subscription-prices.png
Binary files differ.
diff --git a/screenshots/taler-turnstile-price-categories.png b/screenshots/taler-turnstile-price-categories.png
Binary files differ.
diff --git a/taler-paivana-manual.rst b/taler-paivana-manual.rst
@@ -23,9 +23,6 @@ Paivana Operator Manual
Introduction
============
-About Paivana
--------------
-
Paivana is an approach to implement paywalls using GNU Taler.
For the seller, the Paivana design offers a scalable, cachable, static paywall
@@ -79,8 +76,8 @@ and configured to process orders. When setting up the respective
Paivana service, you must have the base URL, username and password
of the selected merchant backend at hand.
-.. include:: paivana-httpd-manual.rst
+.. include:: frags/paivana-httpd-manual.rst
-.. include:: drupal-paivana-manual.rst
+.. include:: frags/drupal-paivana-manual.rst
-.. include:: wordpress-paivana-manual.rst
+.. include:: frags/wordpress-paivana-manual.rst