# CLAUDE.md

This repository is a DZBuild app: a service that merchants install on their DZBuild store
(https://dzbuild.com, e-commerce platform for Algerian merchants). DZBuild runs none of this code.
The app installs through OAuth 2.0 with PKCE, calls the REST API at https://api.dzbuild.app/v1 with
one install token per store and receives webhooks signed with `X-DZ-Signature`. Developer kit 2026.09.30.

## Read the docs before changing anything

- Docs MCP for this session (search, one page, one operation, one example): `claude mcp add dzbuild-docs -- npx -y @dzbuild/docs-mcp`
- Without the MCP: any page as Markdown at its URL plus `.md`, index at https://dzbuild.dev/llms.txt
- The rules in one file: https://dzbuild.dev/skills/dzbuild-apps/SKILL.md
- OpenAPI 3.1 for apps, the scope on every operation: https://dzbuild.dev/openapi/dzbuild-apps-v1.json

Read the page for the part you are changing before you change it. Do not infer DZBuild behaviour from
general OAuth or webhook knowledge; several details differ (no refresh tokens, `Idempotency-Key` on every
write, one token per store, no webhooks on `workers.dev`).

## Contract facts

- Authorize: `GET https://dzbuild.com/oauth/apps/authorize` with `response_type=code`, `client_id`,
  `redirect_uri` (exact match with a registered one), `state`, `code_challenge`, `code_challenge_method=S256`,
  `scope` (space separated). Token: `POST https://dzbuild.com/oauth/apps/token`, form-encoded only, with
  `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`, `client_id`, `client_secret`.
- The token answer carries `stores[]` with one `access_token` per store. Store each by `store_id`. No refresh
  token, no expiry; a token dies when the merchant uninstalls or installs again on the same store.
- API: `Authorization: Bearer <token>`; success `{"data": ..., "meta": {...}}`, error
  `{"error": {"code", "message"}, "meta": {...}}`; 120 requests per minute per install. `GET /v1/whoami`
  needs no scope and returns `app: {app_id, client_id, install_id}`.
- Every `POST`, `PATCH` and `DELETE` carries an `Idempotency-Key` (1 to 64 characters of
  `A-Z a-z 0-9 _ - : .`), one key per operation, the same key on every retry.
- Webhooks: read the raw body, verify `X-DZ-Signature` (`t=<ts>,v1=<hex>`, `v1` = HMAC-SHA256 with the
  signing secret over `t + "." + raw body`) in constant time, reject `|now - t| > 300`, dedupe on the
  envelope `id`, answer `200` at once. Events: `order.created`, `order.confirmed`, `order.processing`,
  `order.shipped`, `order.delivered`, `order.cancelled`, `order.returned` (need `orders:read`) and
  `app.uninstalled`. The webhook URL must be `https` on a domain the developer owns; the console refuses
  `*.workers.dev`. Without a domain, poll `GET /v1/orders?since=` once a minute.
- Launch: the merchant's Open button sends `?dz_launch=<JWT>` (HS256 with the signing secret, `iss`
  `dzbuild`, `aud` = client id, `exp` = `iat` + 300, single-use `jti`). The launch URL opened without
  `dz_launch` must start the install flow.
- Closed to install tokens: `/v1/keys`, `/v1/webhooks`, `/v1/changes`, `/v1/connection`, `/v1/usage`,
  `/v1/quotas` and `POST /v1/landing-pages/generate`.

## Rules

- Secrets (`DZBUILD_CLIENT_SECRET`, `DZBUILD_SIGNING_SECRET`, every install token) come from the
  environment or the secret store. Never write them into code, tests, fixtures, logs or chat.
- Treat `401` on an install token as an uninstall. Stop on `403` with an `app_*` code and show the message.
  Back off on `429` using `error.retry_after`, then resend with the same `Idempotency-Key`.
- On `app.uninstalled`, drop the store's token and delete its data within 30 days.
- Redirect URIs are compared character for character with the ones registered in the developer console.
- Send a `User-Agent` header on every request to DZBuild (token exchange and API); the edge challenges
  a `POST` without one.

## Checks before a pull request

- PKCE: the verifier `dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk` must give the challenge
  `E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM`.
- A webhook fixture signed with a test secret verifies; the same fixture with one byte changed fails;
  a timestamp 301 seconds old fails.
- A `dz_launch` token with the wrong `aud`, a wrong `alg` or an `exp` in the past is refused.
- No secret value appears in the diff (`git diff | grep -E 'dzas_|dzpk_live_'` prints nothing).
