autumn-plugin-shopify 0.3.0

Autumn plugin: Shopify apps — signed webhooks routed by topic, OAuth install with expiring tokens, and a throttle-aware Admin GraphQL client.
Documentation
# autumn-plugin-shopify

Build Shopify apps on [Autumn](https://autumn-web.app). The plugin adds:

- **Signed webhooks**, routed by topic, with a duplicate guard.
- **OAuth install** with expiring offline tokens and automatic refresh.
- **An Admin GraphQL client** that retries throttled calls.

## Quickstart

```toml
# Cargo.toml
[dependencies]
autumn-plugin-shopify = { git = "https://github.com/autumn-foundation/autumn-plugin-shopify" }
autumn-web = "0.10"
serde_json = "1"
tracing = "0.1"
```

```toml
# autumn.toml
[shopify]
api_key = "<client id>"
app_url = "https://app.example.com"     # turns OAuth on
scopes = ["read_orders", "write_products"]
```

```sh
export SHOPIFY_API_SECRET="<client secret>"
```

```rust
use autumn_plugin_shopify::{ShopifyPlugin, Topic, WebhookContext};
use autumn_web::prelude::*;

async fn order_created(ctx: WebhookContext) -> AutumnResult<()> {
    let order: serde_json::Value = ctx.json().map_err(|e| e.to_autumn_error())?;
    tracing::info!(shop = %ctx.shop, order = %order["id"], "order created");
    Ok(())
}

async fn compliance(ctx: WebhookContext) -> AutumnResult<()> {
    tracing::info!(shop = %ctx.shop, topic = %ctx.topic, "compliance request");
    Ok(())
}

#[autumn_web::main]
async fn main() {
    autumn_web::app()
        .plugin(
            ShopifyPlugin::new()
                .webhook("orders/create", order_created)
                .webhook(Topic::CUSTOMERS_DATA_REQUEST, compliance)
                .webhook(Topic::CUSTOMERS_REDACT, compliance)
                .webhook(Topic::SHOP_REDACT, compliance),
        )
        .run()
        .await;
}
```

In the Shopify Dev Dashboard:

1. Set the redirect URL to `https://<host>/shopify/auth/callback`.
2. Point webhook subscriptions to `https://<host>/shopify/webhooks`.
3. Declare the compliance topics in `shopify.app.toml` (`compliance_topics`).
4. Install on a store: open `https://<host>/shopify/auth?shop=<store>.myshopify.com`.

See `examples/shop_app.rs` for a full app.

## What the plugin adds

| Piece | Detail |
|---|---|
| Config section | `[shopify]`. Secrets come only from the environment. |
| `POST /shopify/webhooks` | Checks the HMAC (401), skips done duplicates (200), answers 409 while the same webhook id still runs, deletes the token on `app/uninstalled` and `shop/redact`, runs the topic handler. |
| `GET /shopify/auth` | Starts the install: state cookie, then a redirect to Shopify. |
| `GET /shopify/auth/callback` | Checks HMAC, shop, and state; exchanges the code; checks scopes; stores the token; runs the install handler. |
| `Shopify` extractor | `client()` for a single store, `client_for(&shop)` for installed shops, `install_url(&shop)`. |
| Health | A `"shopify"` indicator in single-store mode, in `/actuator/health` only (not `/ready`). |

The three routes are `#[public]`, so `autumn routes audit` accepts them.
OAuth routes answer 404 when `app_url` is not set.

## Configuration

| Key | Environment variable | Default | Meaning |
|---|---|---|---|
| `api_key` | `SHOPIFY_API_KEY` | none | App client id. OAuth needs it. |
| `shop` | `SHOPIFY_SHOP` | none | The shop of a single-store app. |
| `api_version` | `SHOPIFY_API_VERSION` | `2026-10` | Admin API version. |
| `scopes` | `SHOPIFY_SCOPES` (comma-separated) | `[]` | Scopes OAuth asks for. OAuth needs at least one. |
| `app_url` | `SHOPIFY_APP_URL` | none | Public base URL. Setting it turns OAuth on. |
| `after_install_path` | `SHOPIFY_AFTER_INSTALL_PATH` | `/` | Local path after an install. Gets `shop=`. |
| `max_throttle_retries` | `SHOPIFY_MAX_THROTTLE_RETRIES` | `3` | Retries for a throttled Admin API call (0–10). |

Precedence: `ShopifyPlugin::configure` > environment > `autumn.toml` >
defaults. The plugin reads only `autumn.toml`. It does not apply Autumn
profile layering: use the environment variables for values that change
per profile.

| Secret | Holds |
|---|---|
| `SHOPIFY_API_SECRET` | Client secret. Required. |
| `SHOPIFY_API_SECRET_PREVIOUS` | Old client secret, during rotation. Webhook and OAuth HMAC checks accept it. |
| `SHOPIFY_ACCESS_TOKEN` | Fixed Admin API token (single-store apps). |

A secret key (`api_secret`, `access_token`, `*_secret`, `*_token`) in
`[shopify]` stops the startup.

## Two app shapes

**Single-store (custom) app.** Set `shop` and `SHOPIFY_ACCESS_TOKEN`. Call
`shopify.client()` in a route. The startup warns when only one of the two
is set.

**Public app.** Set `app_url`, `api_key`, and `scopes`. Merchants install
through `/shopify/auth`. Call `shopify.client_for(&shop).await`. When the
token expires in 60 seconds or less, `client_for` refreshes it first. On
`NotInstalled` or `Reauthorize`, send the browser to
`Shopify::install_url(&shop)`.

Run code after each install with `.on_install(handler)`. The handler gets
the shop, the granted scopes, and a `Shopify` handle. Use it to save a shop
record or to call `subscribe_webhook`.

## Calling the Admin API

```rust
use autumn_plugin_shopify::Shopify;
use autumn_web::prelude::*;

#[get("/orders/count")]
#[secured]
async fn order_count(shopify: Shopify) -> AutumnResult<String> {
    let client = shopify.client().map_err(|e| e.to_autumn_error())?;
    let data = client
        .graphql("query { ordersCount { count } }", serde_json::json!({}))
        .await
        .map_err(|e| e.to_autumn_error())?;
    Ok(data["ordersCount"]["count"].to_string())
}
```

`query::<T>()` deserializes `data`. `subscribe_webhook("orders/create", url)`
creates a webhook subscription. Compliance topics are not in the GraphQL
topic list: declare them in `shopify.app.toml`.

The client retries only throttled calls (`THROTTLED` or HTTP 429). Shopify
does not run a throttled call, so a retry is safe. A retry of another
failure can run a mutation two times, so the client does not do it. The
client does not follow redirects.

## Errors

Call `err.to_autumn_error()` to get the HTTP status in this table. Do not
use `?` to change an `Error` into an `AutumnError`: Autumn's blanket `From`
gives 500.

| Kind | Status |
|---|---|
| `Signature`, `NotInstalled`, `Reauthorize` | 401 |
| `InvalidRequest`, `Json` | 400 |
| `StateMismatch`, `ScopeMismatch` | 403 |
| `OAuthDisabled` | 404 |
| `InProgress` | 409 |
| `PayloadTooLarge` | 413 |
| `Http`, `ShopifyApi`, `GraphQl`, `UnexpectedResponse` | 502 |
| `Throttled`, `MissingRuntime` | 503 |
| `Config`, `Store` | 500 |

## Production notes

- **Respond fast.** Shopify waits 5 seconds. Enqueue a job for slow work.
  `WebhookContext::state` gives the `AppState`. When a handler takes
  longer, the Shopify retry gets 409 and comes back later.
- **Unsigned headers.** The webhook HMAC covers the body only. The
  `X-Shopify-*` headers are not signed. Before it deletes a token, the
  plugin checks that the shop in the body matches the header. Do the same
  in handlers where a wrong shop can do damage.
- **Unsigned redirect.** The `shop` value in the redirect after an install
  is not signed. Do not open a session from it.
- **Bring a store.** `InMemoryTokenStore` and `InMemoryDeliveryStore` lose
  data on a restart and do not share data across replicas. Implement
  `TokenStore` and `DeliveryStore` on your database or Redis, and install
  them with `.token_store(...)` and `.delivery_store(...)`.
- **Refresh across replicas.** One process runs one refresh per shop at a
  time. Shopify keeps a presented refresh token usable until the app uses
  the new one, so a second replica that refreshes at the same time
  usually gets a pair too. When Shopify refuses a refresh (401), the plugin
  uses a fresh pair that another replica stored. It deletes the token only
  when the store still holds the refused pair.
- **CSRF.** When `security.csrf.enabled` is on, add `/shopify/webhooks` to
  `security.csrf.exempt_paths`. The plugin logs a warning when the path is
  missing.
- **Compliance.** Public apps must handle `customers/data_request`,
  `customers/redact`, and `shop/redact`. The plugin logs a warning for each
  missing handler when OAuth is on.
- **Secret rotation.** Put the old secret in `SHOPIFY_API_SECRET_PREVIOUS`
  for at least one hour. Shopify can sign with the old secret for that long.
  Token calls use the current secret only. A refresh with a wrong secret
  gets 403, and the plugin keeps the token.

## Testing your handlers

- `sign_webhook(secret, body)` and `sign_query(secret, params)` make
  requests the plugin accepts.
- `ShopifyConfig::from_sources(toml, lookup)` builds a config without the
  process environment.
- `ShopifyPlugin::admin_base_url("http://127.0.0.1:<port>")` sends Admin
  API and OAuth calls to a local stub. Only loopback hosts are allowed.
- `WebhookContext::new(...)` and `InstallContext::new(...)` build contexts
  to call handlers directly. Get the `Shopify` handle with
  `Shopify::from_state(&state)` after you put a runtime from
  `ShopifyPlugin::build_runtime` in the state (`state.insert_extension`).
- `ShopToken` derives `Serialize` with both tokens in clear text. Encrypt
  it at rest in your store, and never log it.

## Crate layout

| Module | Purpose |
|---|---|
| `config` | `[shopify]` config, `SHOPIFY_*` variables, builder closures |
| `types` | `ShopDomain`, `Topic`, `ApiVersion` |
| `verify` | Webhook and OAuth HMAC checks |
| `webhooks` | The webhook route, extractor, and topic registry |
| `delivery` | Duplicate-delivery guard |
| `oauth` | Install and callback routes, token exchange, refresh |
| `tokens` | Token storage |
| `client` | Admin GraphQL client |
| `health` | Health check |
| `plugin` | `ShopifyPlugin`, `ShopifyRuntime`, `Shopify`, `InstallContext` |
| `error` | `Error`, `ErrorKind`, HTTP status mapping |

## Not in this release

- Embedded-app session tokens (App Bridge) and token exchange.
- App proxy signature checks.
- Online (per-user) tokens.
- Autumn profile layering for `[shopify]`.

## Docs and comments

ASD-STE100 style: short sentences, active voice, simple present tense.

## License

Apache-2.0. See `LICENSE`.