# WooCommerce plugin README

The README shipped inside the WooCommerce plugin: install, configuration, cart marker, login endpoint, hooks, development and changelog.

URL: https://punchout.dev/docs/plugins/woocommerce/readme/

Connects a WordPress + WooCommerce shop to [punchout.dev](https://punchout.dev/):
the snippet, the cart marker and the buyer auto-login. Every value the plugin
needs comes from the portal. The plugin is *shown* as punchout.dev, and its
technical identifiers carry the same name: folder, slug and text domain
`punchout-dev`, namespace `PunchoutDev\WooCommerce`, the `PUNCHOUT_DEV_*`
constants, the `punchout_dev_*` options and the REST route `punchout-dev/v1`.

Version **2.0.0**. Three features, independent of each other:

1. **Snippet embed** (`src/Snippet.php`) — prints the `pob.js` `<script>` tag on
   `wp_footer`. That is the widget that reads the cart and posts it back to the
   buyer's e-procurement system. Every attribute comes from the plugin
   settings; there is no hard-coded URL anywhere.
2. **Cart marker** (`src/CartMarker.php`) — wraps the cart in
   `<div data-punchout-cart>`, so punchout.dev finds the cart without any cart
   detection for this shop and survives a theme or WooCommerce redesign. Both
   cart flavours are covered.
3. **Auto-login** (`src/Login/`) —
   `GET /wp-json/punchout-dev/v1/login?token=…` verifies a signed single-use
   token minted by punchout.dev, logs the mapped WordPress user in and redirects
   to the shop path carried in the token (normally the cart page with
   `?__pob_sid=…`, where the punchout session continues).

Requires PHP 8.1, WordPress 6.5, WooCommerce 8.0 (tested up to 10.6) and
`ext-sodium`. No Composer runtime dependency: the plugin ships as a zip and
autoloads its own `src/`.

## Install

Plugins → Add New → **Upload Plugin** → `punchout-dev-<version>.zip` →
Install → Activate. (Or unzip into `wp-content/plugins/punchout-dev` and
activate from the plugins list.)

Activation creates one table, `{prefix}punchout_dev_login_jti`, and two
options, `punchout_dev_settings` and `punchout_dev_db_version`. Nothing else in
the database is touched, and deleting the plugin removes all three
(`uninstall.php`).

> **If you replace the files without deactivating first** (an unzip over the old
> directory), the activation hook never runs. The plugin notices on the next
> request and creates the table itself — the settings page says whether it is
> there.

### Coming from punchout-ninja 1.x

WordPress treats 2.0.0 as a new plugin — its folder and main file are new — so
nothing is taken over from the old one:

1. Deactivate and delete the old plugin (folder `punchout-ninja`). Never run
   both: each would print its own script tag.
2. Upload and activate this one, as above.
3. Enter the settings again from the portal; they are not carried over.
4. On the portal's **Auto-login** tab, change the login path to
   `/wp-json/punchout-dev/v1/login` (prefixed by the install's sub-directory,
   if there is one).

## Configuration

**WooCommerce → punchout.dev** (capability `manage_options`). The page opens
with a small brand head (the magenta mark, the wordmark, one line with the
portal link); the form below it is WordPress's own. Everything lives in one
option, `punchout_dev_settings`:

| setting                        | key               | meaning                                                                                                     |
|--------------------------------|-------------------|-------------------------------------------------------------------------------------------------------------|
| **Embed the snippet**          | `snippet_enabled` | off ⇒ no script tag at all. Default on. The cart marker stays, which is inert on its own.                    |
| **Snippet URL**                | `snippet_url`     | the `pob.js` URL from the embed tag in the portal, `https://js.punchout.dev/pob.js`. Empty ⇒ nothing is embedded. |
| **API base URL (optional)**    | `api_base`        | development or self-hosting only. Empty ⇒ no `data-api` attribute.                                            |
| **Enable auto-login**          | `enabled`         | off ⇒ the login route redirects without signing anyone in; the punchout continues as a guest. Default on.     |
| **Shop ID**                    | `tenant_id`       | the shop ID from the portal's Embed tab; must equal the token's `aud`. Rendered as the snippet's `data-shop-id`. |
| **Public key lines**           | `public_keys`     | one `kid=<base64url public key>` per line, exactly as the portal's Auto-login tab shows it (Ed25519, 32 raw bytes, unpadded). `#` comments and blank lines ignored. |

`data-shop-id` is **not** a field of its own: the snippet reuses the shop ID,
because it is the same shop on both legs. No shop ID ⇒ no script tag.

The rendered tag, with every value escaped:

```html
<script async src="…/pob.js" data-shop-id="pob_live_…" data-api="…"></script>
```

Take the public key lines from the portal's **Auto-login** tab (`kid=<base64url
public key>`). During a key rotation keep the old line in place until every minted token has
expired; an unknown `kid` is rejected outright (reason `kid_unknown`).

From the command line (no WP-CLI needed):

```sh
php -r 'define("WP_USE_THEMES", false); require "/path/to/wp-load.php";
  update_option("punchout_dev_settings", [
    "enabled" => true,
    "tenant_id" => "pob_live_…",
    "public_keys" => "k1=…",
    "snippet_enabled" => true,
    "snippet_url" => "https://js.punchout.dev/pob.js",
    "api_base" => "",
  ]);'
```

### Portal settings for this shop

| portal field                              | value                                                                          |
|-------------------------------------------|--------------------------------------------------------------------------------|
| Auto-login → **Login path**               | `/wp-json/punchout-dev/v1/login`, prefixed by the install's sub-directory       |
| Auto-login → **Customer reference**       | email address (recommended), customer number (= the WordPress **login name**) or customer ID (= the numeric WordPress user id) |
| General → **Landing path**                | the cart page, e.g. `/cart/`                                                    |

The settings page prints the exact login path for this install, sub-directory
included. `next` is used **verbatim**: the plugin only makes it absolute against
the site's own scheme, host and port, it never prepends the sub-path — so for an
install under `/wordpress-6.9.7/` punchout.dev must mint
`next = /wordpress-6.9.7/cart/?__pob_sid=…`.

## The cart marker

`[data-punchout-cart]` takes precedence over any detected cart, so a shop
running this plugin needs no cart detection at all.

**Block cart** — filter `render_block`, priority 20, block `woocommerce/cart`:

```html
<div data-punchout-cart>
  <div data-block-name="woocommerce/cart" class="wp-block-woocommerce-cart …">…</div>
</div>
```

Priority 20 rather than the default 10 is deliberate: WooCommerce Blocks itself
filters `render_block` at 10 to stamp `data-block-name` onto the block's
outermost tag, and wrapping first would hand *our* div those attributes.

The server HTML of that block is an empty shell — the line items are rendered by
React from the Store API after load, **inside** the wrapper. Wrapping the block
rather than an inner element is what makes the marker useful: the inner nodes do
not exist yet when the filter runs, and the snippet waits for them before it
captures.

**Classic shortcode cart** — `woocommerce_before_cart_table` (priority 0) opens
the div and `woocommerce_after_cart_table` (priority 100) closes it, wrapping
exactly the `shop_table`: line items and their column header, and nothing of the
totals, the coupon form or the checkout button.

Never the mini cart, never the checkout, never the order-received page. The
wrapper is a plain `div` with no class, style or id, which is layout-neutral.

### Setup self-test

The shop owner creates a one-time setup link in the portal (Setup tab, valid 60
minutes by default):

1. Put 2–3 different articles in the cart.
2. Open the cart page with the setup link appended (`…/cart/?pob_setup=…`).
3. Click **Detect cart** in the widget (on a German storefront the widget
   speaks German: **Warenkorb erkennen**).

With this plugin installed the widget reports the source **Shop plugin**. That
is the self-test: it proves the marker is on the page, that the snippet is
embedded with the right shop ID, and that punchout.dev can reach the shop.
Confirming records the source on the tenant and consumes the link; nothing
about the cart's layout is stored, because the marker is read at runtime.

If the widget reports *Preset* or *Detected automatically* instead, the
marker did not reach the page — the usual causes are a page that does not use
the `woocommerce/cart` block at all, or a caching plugin serving an older copy.

## The login endpoint

### Why it exists

Without it a punchout buyer shops as a guest: no customer-specific prices, no
contract assortment. The bridge knows which WordPress user the buyer's
procurement account maps to, so it hands the browser a short-lived, signed,
single-use ticket instead of a password.

### Flow

```
bridge  --mint-->  pbl1.<payload>.<sig>
browser --GET-->   /wp-json/punchout-dev/v1/login?token=…
plugin             verify → jti → resolve user → refuse admins → wp_set_auth_cookie()
        --302-->   /cart/?__pob_sid=…      (logged in)
        --302-->   /cart/?__pob_sid=…      (any failure: guest punchout)
```

**Every failure is a redirect, never an error page and never wp-login.php.** If
the token cannot be used, the buyer still lands on the cart page and the
punchout continues as a guest session. Only when there is no usable path at all
does the endpoint fall back to `/`.

The route has `permission_callback => __return_true` and no nonce: **the token
is the credential.** A nonce would be pointless — the request arrives from the
buyer's procurement system, which has never seen this WordPress install, and the
token is already signed, audience-scoped, single-use and two minutes old at
most.

### Token format

```
pbl1.<b64url(payload)>.<b64url(signature)>
```

`b64url` is RFC 4648 §5 without padding. `payload` is UTF-8 JSON:

| key        | value                                                                |
|------------|----------------------------------------------------------------------|
| `aud`      | the shop ID — must equal the configured **Shop ID**                   |
| `customer` | `{"kind": "id"\|"number"\|"email", "value": "…"}`                     |
| `exp`      | unix seconds, expiry                                                  |
| `iat`      | unix seconds, issued at                                               |
| `iss`      | `punchout-bridge`                                                     |
| `jti`      | opaque single-use id                                                  |
| `kid`      | key id, must be listed in **Public key lines**                        |
| `next`     | relative path, starts with `/`, never `//`                            |
| `op`       | `punchout_login`                                                      |
| `protocol` | `oci`, `cxml` or `ids`                                                |

The signature is **Ed25519 over the ASCII bytes of `"pbl1." . <b64url(payload)>`**
— the prefix plus the *encoded* payload, not the raw JSON. The plugin verifies
the bytes exactly as received and never re-serialises the JSON, so the bridge is
free to spell the JSON however it likes.

Checks, in order (`src/Login/TokenVerifier.php`): format → `kid` known →
signature → `iss`/`op` → `aud` → `iat` (60 s skew allowed) and `exp` (no skew) →
`next` is a relative path → `jti`/`protocol`/`customer` shape. The `jti`
single-use check follows in the endpoint (`src/Login/NonceStore.php`), then user
resolution, then the administrator refusal.

### User resolution

| `customer.kind` | resolved against            | note                                                                    |
|-----------------|-----------------------------|-------------------------------------------------------------------------|
| `email`         | `user_email`                | recommended                                                             |
| `number`        | `user_login`                | **WooCommerce has no customer number.** A WordPress user is identified by its login name, which is what a shop puts in the ERP's customer-number field. |
| `id`            | the numeric WordPress user id | the bridge's `id` kind is opaque; anything that is not a plain positive integer simply does not resolve, which is the guest fallback. |

**An administrator is never logged in this way.** Any user with
`manage_options` is refused (reason `administrator_refused`) and gets a guest
punchout. The token only proves which *shop customer* a procurement account maps
to; handing a browser that arrived from a third-party system a session with
`manage_options` would turn a convenience feature into a takeover of the whole
site. A shop whose buyer really is an admin needs a second, ordinary customer
account.

On success the plugin calls `wp_set_current_user()`, `wp_set_auth_cookie()` and
then `do_action('wp_login', …)` — the last of which is how WooCommerce merges
the guest cart into the customer's persistent cart.

### Interop with the bridge

The contract is `fixtures/login-token/README.md` in the bridge repo;
`services/api/src/pob_api/core/login_token.py` is the reference implementation.
The shipped reference vector (`fixtures/login-token/token.txt`) is asserted by
`tests/TokenVerifierTest.php::testAcceptsTheBridgeReferenceVector` and can be
re-checked from the command line:

```sh
php bin/selftest.php --verify "$(cat token.txt)" "test=$(cat public_key.txt)" \
    pob_live_test0000000000000000 1757930060
```

Where this plugin differs from the reference verifier, and why:

| point | reference | here |
|---|---|---|
| `exp` | valid until `exp + 60 s` | valid until `exp` — no grace (`TokenVerifier::EXP_SKEW_SECONDS`) |
| `iat` | `iat - 60 s` | same 60 s |
| `next` with a control character or whitespace | rejected | rejected — but the controls are stripped *first*, so a smuggled newline can never hide a `//` from the structural checks |
| `protocol` | any non-empty string | any non-empty string ≤ 32 chars |
| `jti` | any non-empty string | non-empty printable ASCII, 8–256 chars |
| reason codes | `format`, `kid`, `signature`, … | more granular (`kid_unknown`, `signature_invalid`, `iat_future`, `administrator_refused`, …) — log-only, never sent to the client |

`EXP_SKEW_SECONDS = 0` is the one place where this plugin is stricter than the
contract. If a shop's clock runs fast enough to reject live tokens — the symptom
is reason `expired` on every login, while the punchout still works as a guest —
set it to 60 to match the reference exactly.

The plugin is byte-for-byte compatible with the Shopware one: same wire format,
same check order, same reason vocabulary.

### `?next=` on the endpoint

The endpoint accepts an optional `next` **query parameter**. It is used for one
thing only — as the guest-fallback target when there is no usable `next` inside
the token (a health check, a hand-written link, a token too mangled to parse).
It goes through exactly the same path validation as a signed `next`, so it can
never be anything but a same-origin relative path, and it never overrides a
verified token's `next`.

### Security notes

- **Single use.** `sha256(jti)` is inserted into
  `{prefix}punchout_dev_login_jti`, whose primary key is that hash — **the
  `INSERT` is the check**. The first request to present a token wins; every
  later one hits the unique constraint and is refused (reason `replay`). Two
  concurrent requests race in the database, not in PHP, so there is no window in
  which both succeed. Only the hash is stored, never the `jti`. A token is spent
  by the first request that presents it, successful or not — an administrator's
  token is burned by the refusal.
  The store fails closed: if the marker cannot be written for any reason (the
  table is missing, the database is down) the login is not granted and the
  punchout continues as a guest. Markers are swept opportunistically: every
  `consume()` first deletes rows whose `expires_at` has passed, which keeps the
  table at roughly one TTL's worth of logins. A failed sweep is never fatal.
  *A transient would have been the wrong shape for this:* an object cache is
  allowed to forget, and forgetting here means a granted replay —
  and `get_transient()` + `set_transient()` is not atomic either.
- **Short TTL.** `exp` is enforced with no grace at all. Clock skew is tolerated
  on `iat` only (60 s), so a token cannot be extended by a fast clock.
- **No leakage.** The token is never rendered, never echoed and never logged.
  A short reason code goes to `error_log()` and only when the site runs with
  `WP_DEBUG`. Responses carry `Cache-Control: no-store` (no shared cache keeps
  the redirect) and `Referrer-Policy: no-referrer` (the next page's `Referer`
  cannot carry the token to a third party).
- **No open redirect.** `next` must be a relative path: it has to start with a
  single `/`, must not start with `//`, must not contain a backslash, and CR/LF
  plus every other C0 control is stripped *before* those checks so a smuggled
  newline cannot hide a `//`. The same validation is applied to the unverified
  `next` used for the guest fallback and to the `?next=` query parameter, so
  even a forged token can only ever redirect within this shop — and
  `wp_safe_redirect()` is the belt to that braces.
- **Administrators are refused**, see above.

## Development

### Self-test (no WordPress bootstrap)

`bin/selftest.php` needs only PHP 8.1+, ext-sodium and ext-json, so it can be
dropped into any shop container.

```sh
# generate a keypair, mint a token, verify it, run the negative cases
php bin/selftest.php

# verify a token minted elsewhere (e.g. by the Python bridge)
php bin/selftest.php --verify "<token>" "<kid>=<b64url public key>" "<tenant id>" [now]
```

`now` is an optional unix timestamp; pass it to check a fixed test vector whose
validity window has already passed. Exit codes: `0` accepted / all assertions
held, `1` rejected, `2` usage error.

### Unit tests

The classes under test are pure PHP; `tests/bootstrap.php` loads them directly
and stubs the handful of WordPress escaping helpers they call, so the suite
needs no shop:

```sh
docker exec <php-container> sh -lc \
  'cd /var/www/html/<wp>/wp-content/plugins/punchout-dev && phpunit -c phpunit.xml.dist'
```

`NonceStoreTest` drives the very same SQL against `tests/FakeWpdb.php`, an
in-memory SQLite double of the four `$wpdb` members `NonceStore` uses (skipped
without `pdo_sqlite`). What it asserts is the behaviour that matters — the
INSERT is the single-use check and every failure is closed — not the MySQL DDL,
which `Activator` owns.

## Layout

```
punchout-dev.php                plugin header, constants, autoloader, hooks
uninstall.php                   drops the table and both options
phpunit.xml.dist
bin/selftest.php                standalone verifier self-test
src/Settings.php                WooCommerce → punchout.dev (Settings API)
src/Snippet.php                 pob.js embed on wp_footer
src/CartMarker.php              data-punchout-cart, block cart + classic cart
src/Activator.php               dbDelta of the jti table, defaults, upgrade guard
src/Login/TokenVerifier.php     token parsing + Ed25519 verification
src/Login/LoginClaims.php       verified claims (readonly DTO)
src/Login/TokenRejected.php     rejection with a short reason code
src/Login/NonceStore.php        single-use jti store (INSERT = the check)
src/Login/Endpoint.php          GET /wp-json/punchout-dev/v1/login
tests/bootstrap.php             WordPress stubs
tests/FakeWpdb.php              in-memory SQLite double of $wpdb
tests/TokenVerifierTest.php     incl. the bridge reference vector
tests/NonceStoreTest.php
tests/SettingsTest.php
tests/RenderTest.php            the script tag and the cart marker
```

## Hooks used

| hook                                     | what for                                        |
|------------------------------------------|-------------------------------------------------|
| `plugins_loaded`                         | wire the four features up, table upgrade guard   |
| `init`                                   | `load_plugin_textdomain()`                       |
| `admin_menu` (99)                        | the submenu under WooCommerce                    |
| `admin_init`                             | `register_setting` / sections / fields           |
| `wp_footer` (99)                         | the `pob.js` script tag                          |
| `render_block` (20)                      | the `data-punchout-cart` wrapper on the cart block |
| `woocommerce_before_cart_table` (0)      | opens the wrapper on the classic cart            |
| `woocommerce_after_cart_table` (100)     | closes it                                        |
| `rest_api_init`                          | registers `punchout-dev/v1/login`                |
| `plugin_action_links_…`                  | the Settings link on the plugins list            |
| `before_woocommerce_init`                | declares HPOS compatibility                      |
| activation hook                          | `dbDelta` of the jti table, default option       |

## Changelog

- **2.0.0** — the technical identifiers carry the product name: folder, slug
  and text domain `punchout-dev`, namespace `PunchoutDev\WooCommerce`, the
  `PUNCHOUT_DEV_*` constants, the `punchout_dev_*` options, the table
  `{prefix}punchout_dev_login_jti` and the REST route
  `/wp-json/punchout-dev/v1/login`. WordPress installs it as a new plugin; see
  the upgrade steps under Install. No behaviour change.
- **1.0.1** — shown as punchout.dev (plugin header, menu entry, settings page);
  the settings page speaks the portal's words — Shop ID, Snippet URL, API base
  URL, Public key lines, Auto-login — and carries a small brand head with the
  portal link. No behaviour change; every technical identifier is unchanged.
- **1.0.0** — first release: snippet embed, cart marker for the block and
  classic carts, single-use login endpoint.
