Skip to content

PendingActionStore: signed approve/reject URL ability for any pending action #1929

@chubes4

Description

@chubes4

Problem

DM's `ResolvePendingActionAbility` exposes `POST /datamachine/v1/actions/resolve` for resolving pending actions, gated by capability checks. That works for in-app review flows where the resolver is an authenticated WordPress user.

It does not work for review flows where the approval lives outside the WordPress session — the canonical example being email approval, where a user clicks "Approve" in an inbox link without any active WordPress login. Today, anyone wanting that pattern would have to bolt on a custom token system on top of DM's REST endpoint.

This is generic enough that the signed-URL primitive belongs in DM core, reusable by any consumer (Studio's socials approval, Wire moderation, future destructive ops, etc.) without each one inventing its own token scheme.

Proposal

A new ability — call it `datamachine/sign-pending-action-resolution` — that produces a pair of signed URLs for a given pending action:

  • Approve URL — when visited, resolves the action with `accepted` decision.
  • Reject URL — when visited, resolves the action with `rejected` decision.

URLs carry an HMAC token (server-side secret in `wp_options`, rotated periodically) over a payload of `{ action_id, decision, expires_at, nonce }`. They're stateless — no server-side token table, no extra DB rows. Token validity is short — default 7 days, configurable per-call up to a hard ceiling (`MAX_TOKEN_LIFETIME`, maybe 30 days).

Public surface

Ability input:

```php
array(
'action_id' => 'pa_abc123', // required
'lifetime' => 604800, // optional, seconds, default 7 days
'resolver' => 'email_approval', // optional, recorded on resolution
)
```

Ability output:

```php
array(
'approve_url' => 'https://.../resolve?t=...',
'reject_url' => 'https://.../resolve?t=...',
'expires_at' => '2026-05-17T12:00:00Z',
)
```

Resolution endpoint

A new public REST route `GET /datamachine/v1/actions/resolve-by-token?t={token}` that:

  1. Validates the HMAC.
  2. Validates the token hasn't expired.
  3. Loads the pending action; bails 410 Gone if already resolved or expired.
  4. Calls the existing resolution path (same as `/actions/resolve`).
  5. Renders a confirmation page (HTML for browser, JSON for clients with `Accept: application/json`).

Confirmation page is intentionally minimal — DM doesn't need to ship a fancy UI. Consumers wanting custom confirmation can hook a filter to inject branded HTML.

Replay protection

Each token is single-use because the resolution itself is idempotent — once the action is resolved, the second click on either link returns the existing decision rather than overwriting it.

Out of scope

  • Email sending. Studio (and other consumers) wire their own email via the observer hook from PendingActionStore: observer registry + WordPress action dispatch adapter #1928.
  • Confirmation UI customization beyond a simple filter hook.
  • Per-user identity binding. The resolver is recorded as whatever string the caller passed (e.g. `email_approval`); if a consumer needs to bind to a specific WP user, they can include that in the action's metadata at `store()` time.

Why DM, not agents-api

Signed URLs presume HTTP, WordPress nonces / HMAC keys, a REST endpoint, and a URL scheme. agents-api stays transport-agnostic — it should not care if approval comes from REST, CLI, MCP, or a Discord button. The resolver contract upstream already accepts decisions from any source. Signed URLs are a DM-specific transport adapter on top of that contract.

Acceptance

  • New ability `datamachine/sign-pending-action-resolution` returning approve/reject URLs.
  • New REST route `GET /datamachine/v1/actions/resolve-by-token` that resolves by valid token.
  • HMAC secret stored in `wp_options`, auto-generated on first use, rotation helper for security incidents.
  • Token expiry validated server-side; expired tokens return 410.
  • Already-resolved actions return their existing decision, not overwrite it.
  • Confirmation page (minimal HTML for browsers, JSON for API clients).
  • Documented in DM's `docs/` next to the existing pending-action surface.

Depends on / unblocks

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions