# Plugin Update Delivery — design

Audience: AI coding agents first.

Status: authoritative. Supersedes the sketch in `press-zone-backend/.claude/skills/backend/licensing-system.md`, which is a generic pattern, contradicts itself on package hosting, and puts the license key in a download URL. Follow THIS document where they disagree.

## Outcome

A licensed international-press-zone site is told about a new release and can download and install it, with package integrity verifiable against a key the site already trusts. Today both endpoints 404 in production, so update delivery does not exist at all.

## Which backend

Live `https://api.press.zone` is the **Express + Prisma API at `press-zone-backend/api/`**; Cloudflare is a proxy in front of it. `api/src/server.ts:146` mounts `/v1/international/license` → `routes/multilingualLicense.ts` (activate, deactivate, validate, status). Build here.

The CF Workers tree at `press-zone-backend/.product-work/backend` is a separate greenfield platform and is NOT live. Do NOT build update delivery there.

Probe evidence (2026-08-11): `GET /v1/international/license/status` → 200; `GET /v1/international/updates/check` → 404; `GET /v1/international/updates/verify` → 404.

## The client pins the contract

The plugin is the client and already ships. Its expectations are authoritative; the server matches them, never the reverse.

### Update check

`LicenseClient::checkForUpdates()` sends `product`, `version` (the site's currently installed version), and `license_key`, and returns `$response['data']` only when `success` is true AND `data.update_available` is truthy. Anything else means "no update".

`UpdateChecker::checkForUpdates()` then writes into WordPress's `pre_set_site_transient_update_plugins` transient, so the response MUST carry every field it reads:

| Field | Used for |
|---|---|
| `update_available` | gate; falsy ⇒ plugin treats as no update |
| `version` | `new_version` in the transient |
| `url` | plugin homepage/details link |
| `package` | download URL WordPress fetches |
| `tested` | WP version tested up to |
| `requires` | minimum WP version |
| `requires_php` | minimum PHP version |

`UpdateChecker::pluginInformation()` additionally reads optional `description`, `changelog`, `banners`, `icons` for the details modal. Return them; absent keys degrade to empty.

Response envelope follows the project standard: `successResponse({ ... })` / `errorResponse(CODE, msg)`.

**Method:** the licensing hardening lane changes this call to **POST with a JSON body** so the license key stops appearing in URLs, query strings, and access logs. Serve POST. Also accepting GET is permitted ONLY as a transition for already-installed sites; if you accept GET, the key MUST NOT be logged.

**Caching note, do not fight it:** the plugin caches "no update" for 12 hours and an available update for 1 hour in the `ipz_update_info` transient. Do not design around a shorter server-side signal; it will not be observed.

### Package verification

`UpdateChecker::verifyPackageSignature()` sends `license_key`, `product`, `version`, and `hash` (SHA-256 of the downloaded file) to `POST /updates/verify`, and treats the update as valid only when `success` is true AND `data.valid` is truthy.

Two contract quirks to handle deliberately, not silently:
1. `version` is the site's **currently installed** `IPZ_VERSION`, not the version being downloaded. Resolve the release by `hash` and treat `version` as context only; NEVER use it to select which release to compare against.
2. The call is fail-closed on the plugin side. An ambiguous or errored response blocks the install. Return a definite `valid: true` or `valid: false`; never a 200 with an absent or null `valid`.

## Storage and release flow — owner decision, 2026-08-11

Packages live in **Cloudflare R2**, served to sites via **time-limited signed URLs**.

Rationale: Cloudflare already fronts `api.press.zone`, so no new vendor; the object is never publicly enumerable; and the signed URL carries no license key, which keeps the key out of WordPress's own update logs and out of any proxy in the download path.

Release flow:
1. `plugins/international-press-zone/tools/build-distribution.mjs` builds the release zip. It already exists and already has integrity tests — reuse it, do not write a second builder.
2. Upload the artifact to R2 under a versioned key.
3. Insert a release record (see schema) carrying version, R2 object key, SHA-256, detached signature, changelog, and the WP/PHP compatibility fields the transient needs.

The `package` URL returned by update check is a freshly minted signed URL with a short expiry, generated per request. Never store a signed URL; never return a permanent public object URL.

## Integrity model

A SHA-256 attested by the same API that serves the package proves only that the origin agrees with itself. Use a **detached signature over the package digest, verified in the plugin against a public key pinned in plugin source**, so a compromised origin alone cannot ship a forged update.

- Sign the release digest at publish time with a private key that lives ONLY in the release process, never in the API container, never in the repository.
- `POST /updates/verify` returns the signature alongside `valid` so the plugin can verify locally rather than trusting the boolean.
- The boolean alone remains the compatibility path for the currently shipped plugin; the pinned-key verification is the plugin-side follow-up, tracked separately. Say so plainly in the plan rather than implying today's plugin already verifies cryptographically — it does not.

## Schema

A release record is required. `prisma migrate dev` fails in this non-interactive environment: create the migration directory and SQL by hand, then `npx prisma migrate deploy`, and run `npx prisma generate` before build.

Minimum fields: version (unique), product, R2 object key, SHA-256 digest, detached signature, changelog, `requires`, `tested`, `requires_php`, published-at, and a stable/prerelease flag. Model entitlement explicitly — which licenses may receive which release — rather than assuming every active license gets every build.

## Security requirements

1. Authenticate and authorize every request. Serve a package only to a site with a valid, active, non-expired license bound to that site.
2. Match the sibling licensing routes' behavior for unknown or invalid licenses. Do NOT reveal more about license existence than `activate`/`validate`/`status` already do.
3. Apply the same rate-limiting posture as the sibling licensing routes.
4. NEVER put a license key in a URL, a redirect target, a signed URL, or a log line.
5. Zod-validate every input.
6. Signed URL expiry must be short enough to be useless if leaked, long enough for a slow site to complete a download.

## Acceptance

Prove each with a test that fails first:

- unauthenticated check → rejected, no release metadata leaked
- unlicensed, expired, and wrong-site licenses → rejected
- valid license, current version is latest → `update_available` falsy
- valid license, newer release exists → every transient field present and correct
- `package` URL is signed, expires, and contains no license key
- verify with a matching hash → `valid: true`
- verify with a mismatched or unknown hash → `valid: false`, never an ambiguous 200
- verify resolves the release by hash, not by the client-supplied `version`

## Out of scope

Automatic rollback, delta updates, multi-product release channels, and the plugin-side pinned-key verification change. Record them as follow-ups; do not build them here.
