diff --git a/.env.example b/.env.example index 53759c6..fd07598 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ PORT=3000 # WhatsApp block (sandbox env writes WHATSAPP_API_URL; production channels env writes META_GRAPH_API_URL, the kit accepts either) WHATSAPP_API_URL=https://sandbox.hookmyapp.com/v22.0 -WHATSAPP_ACCESS_TOKEN=your-sandbox-activation-code +WHATSAPP_ACCESS_TOKEN=your-sandbox-channel-token WHATSAPP_PHONE_NUMBER_ID=your-sandbox-phone # Instagram block (sandbox env writes INSTAGRAM_API_URL; a real Instagram @@ -24,7 +24,7 @@ WHATSAPP_PHONE_NUMBER_ID=your-sandbox-phone # INSTAGRAM_ACCOUNT_ID. The kit reads either base-URL shape, so the same code # runs against the sandbox and production unchanged.) INSTAGRAM_API_URL=https://sandbox.hookmyapp.com/v25.0 -INSTAGRAM_ACCESS_TOKEN=your-sandbox-activation-code +INSTAGRAM_ACCESS_TOKEN=your-sandbox-channel-token INSTAGRAM_ACCOUNT_ID=your-sandbox-ig-account-id # =================================================================== diff --git a/.github/workflows/copy-guard.yml b/.github/workflows/copy-guard.yml new file mode 100644 index 0000000..40adaa1 --- /dev/null +++ b/.github/workflows/copy-guard.yml @@ -0,0 +1,42 @@ +# Retired-copy ban (MAR-3). This kit is cloned by every new integrator, so a +# stale term here becomes their vocabulary. Bans reseller-category framing and +# the retired "activation code" name for the sandbox channel token. +name: Copy guard + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + banned-copy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Ban retired copy in the kit + run: | + set -uo pipefail + status=0 + HITS=$(grep -rn -i -E '\bBSPs?\b|passthrough|activation code' README.md AGENTS.md .env.example src/) || status=$? + # grep exits 1 for "no match" and >1 for a real error. Without this the + # `|| true` idiom fails OPEN and passes on a scan that never ran. + if [ "$status" -gt 1 ]; then + echo "FAIL: grep exited $status — the scan did not run, refusing to pass" + exit 1 + fi + if [ -n "$HITS" ]; then + echo "FAIL: retired copy found in the starter kit:" + echo "$HITS" + echo "" + echo " Fix: reseller-category framing (say what the reader gets, never" + echo " the category or the plumbing) and \"activation code\" (the" + echo " env value is the sandbox channel token; the code the user" + echo " sends is the BIND code). See MAR-3." + exit 1 + fi + echo "PASS: no retired copy in the starter kit" diff --git a/AGENTS.md b/AGENTS.md index 51479bd..3bf342b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ This kit is an **Express webhook receiver wired to `@gethookmyapp/cli`**. The CL - `WEBHOOK_HMAC_SECRET` — the HMAC-SHA256 key for `X-HookMyApp-Signature-256`. Written by both `sandbox env` and `channels env`. As of v3 there is NO `VERIFY_TOKEN` fallback. - `PORT` — port the Express server listens on (defaults to `3000` if absent). - `META_GRAPH_API_URL` — Meta Graph API base URL. Sandbox: `https://sandbox.hookmyapp.com/v22.0`. Production: `https://graph.facebook.com/v24.0` (or whatever Graph version your channel is pinned to). Renamed from `WHATSAPP_API_URL` in v2.0.0 — the name now reflects that the Graph API is Meta-level, not WhatsApp-specific. - - `WHATSAPP_ACCESS_TOKEN` — sandbox activation code (CLI-issued) or production Meta access token. + - `WHATSAPP_ACCESS_TOKEN` — Bearer credential for the chosen transport: a HookMyApp channel token (`hmat_`) from `sandbox env` / `channels env`, or your own Meta access token on the direct-Meta transport (see "Choose exactly one transport" below). - `WHATSAPP_PHONE_NUMBER_ID` — sandbox session phone or production Meta phone number ID. - **Sandbox vs production:** sandbox is a shared HookMyApp WABA with no Meta paperwork; recipient is pinned server-side to the session phone and templates are blocked. Production is the user's own WABA via Meta embedded signup; templates work and any opted-in recipient is reachable. The env keys above stay the same — only their values change. @@ -179,9 +179,9 @@ Type into the bottom input and press Enter to send a message. This posts to `POS ## Signature verification -Every inbound `POST /webhook/whatsapp` or `POST /webhook/instagram` from HookMyApp, in both sandbox and production, carries an `X-HookMyApp-Signature-256` header set to `sha256=` where the HMAC key is your `WEBHOOK_HMAC_SECRET`. As of v3 there is no `VERIFY_TOKEN` fallback. HookMyApp's forwarder signs every outbound request this way; the customer-facing contract is a single shape, not two. +Every inbound `POST /webhook/whatsapp` or `POST /webhook/instagram` from HookMyApp, in both sandbox and production, carries an `X-HookMyApp-Signature-256` header set to `sha256=` where the HMAC key is your `WEBHOOK_HMAC_SECRET`. As of v3 there is no `VERIFY_TOKEN` fallback. HookMyApp signs every outbound request this way; the customer-facing contract is a single shape, not two. -This kit's `src/index.js` uses the parsed-then-restringified body shape because the kit ships with `express.json()` middleware. The forwarder signs `JSON.stringify(parsedBody)` on its side, and V8's `JSON.stringify` is deterministic, so parsed+restringified on your side is byte-equivalent to raw. +This kit's `src/index.js` uses the parsed-then-restringified body shape because the kit ships with `express.json()` middleware. HookMyApp signs `JSON.stringify(parsedBody)` on its side, and V8's `JSON.stringify` is deterministic, so parsed+restringified on your side is byte-equivalent to raw. ```js import { createHmac } from 'node:crypto'; @@ -195,9 +195,9 @@ function verifySignature(body, signature, hmacSecret) { } ``` -If you extend the kit and swap `express.json()` for `express.raw({ type: 'application/json' })`, update `.update(JSON.stringify(body))` to `.update(rawBody)` — the signature still matches because the forwarder sent the same bytes. What you must NOT do is mix the two (e.g., keep `express.json()` but hash the stringified representation of a manually re-encoded object with different whitespace) — that will break verification. +If you extend the kit and swap `express.json()` for `express.raw({ type: 'application/json' })`, update `.update(JSON.stringify(body))` to `.update(rawBody)` — the signature still matches because HookMyApp sent the same bytes. What you must NOT do is mix the two (e.g., keep `express.json()` but hash the stringified representation of a manually re-encoded object with different whitespace) — that will break verification. -> **Note:** Earlier versions of this guide and the HookMyApp skill mentioned a separate `X-Hub-Signature-256` path keyed on Meta's `APP_SECRET` for production. That path does **not** exist on the customer-facing interface — the forwarder verifies Meta's signature internally and re-signs with your `WEBHOOK_HMAC_SECRET` before forwarding. Do not wire an `APP_SECRET` verification branch on your server. +> **Note:** Earlier versions of this guide and the HookMyApp skill mentioned a separate `X-Hub-Signature-256` path keyed on Meta's `APP_SECRET` for production. That path does **not** exist on the customer-facing interface — HookMyApp verifies Meta's signature internally and re-signs with your `WEBHOOK_HMAC_SECRET` before forwarding. Do not wire an `APP_SECRET` verification branch on your server. ## Troubleshooting diff --git a/README.md b/README.md index da51165..2ceb3fa 100644 --- a/README.md +++ b/README.md @@ -98,24 +98,24 @@ The `.env.example` file lists the keys the server expects, but you should not ne | `WEBHOOK_HMAC_SECRET` | Signing key for verifying incoming `X-HookMyApp-Signature-256` headers (HMAC-SHA256). Written by both `sandbox env` and `channels env`. As of v3 there is no `VERIFY_TOKEN` fallback. | | `PORT` | Port the webhook server listens on. Default `3000`. | | `WHATSAPP_API_URL` | WhatsApp Graph API base URL. Sandbox: `https://sandbox.hookmyapp.com/v22.0`. Production `channels env` writes this as `META_GRAPH_API_URL`; the kit reads either. | -| `WHATSAPP_ACCESS_TOKEN` | Sandbox activation code (CLI-provided) or Meta access token in production. | +| `WHATSAPP_ACCESS_TOKEN` | The Bearer credential for whichever transport you chose. `sandbox env` and `channels env` both write a HookMyApp channel token (`hmat_`); if you point the base at `graph.facebook.com` yourself, put your own Meta access token here. | | `WHATSAPP_PHONE_NUMBER_ID` | Phone number ID from your sandbox session or Meta app. | | `INSTAGRAM_API_URL` | Instagram Graph API base URL. Sandbox `sandbox env` writes this. A real Instagram channel's `channels env` writes it as `INSTAGRAM_GRAPH_API_URL`; the kit reads either. | -| `INSTAGRAM_ACCESS_TOKEN` | Sandbox activation code (CLI-provided) or Meta access token for Instagram. | +| `INSTAGRAM_ACCESS_TOKEN` | Same, for Instagram: a HookMyApp channel token (`hmat_`) from the CLI, or your own Meta access token on the direct-Meta transport. | | `INSTAGRAM_ACCOUNT_ID` | Instagram account ID the kit sends from. | | `INSTAGRAM_USERNAME` | Optional. The connected account's @username. Used by `/comments` to filter out the account's own reply echoes (comment webhooks can report the account under a different id than `INSTAGRAM_ACCOUNT_ID`). | ## How it works -``` +```text WhatsApp user Meta HookMyApp Your server -sends message ──────> Cloud API ──> Forwarder ──────> POST /webhook/whatsapp +sends message ──────> Cloud API ──> HookMyApp ──────> POST /webhook/whatsapp webhook signs with verifies HMAC-SHA256 signature ``` 1. A WhatsApp user sends a message to your sandbox business number. -2. Meta's Cloud API delivers the webhook to HookMyApp's forwarder. +2. Meta's Cloud API delivers the webhook to HookMyApp. 3. HookMyApp signs the payload with your webhook signing secret (HMAC-SHA256) and forwards it through a Cloudflare tunnel to your local server. 4. Your server verifies the signature and processes the message. @@ -125,7 +125,7 @@ The payload arrives in the **original Meta format**. HookMyApp does not transfor ### Verification challenge -When you register your own public webhook URL with `hookmyapp channels webhook set --url ...` or `hookmyapp sandbox webhook set --url ...`, HookMyApp sends a one-time `GET /webhook/whatsapp` (or `/webhook/instagram`) to that URL. Your server must respond with `VERIFY_TOKEN` as the entire response body. This kit handles that automatically in `src/index.js`. The sandbox `listen` tunnel does not issue this GET; it only forwards live POSTs from the forwarder to your local routes. +When you register your own public webhook URL with `hookmyapp channels webhook set --url ...` or `hookmyapp sandbox webhook set --url ...`, HookMyApp sends a one-time `GET /webhook/whatsapp` (or `/webhook/instagram`) to that URL. Your server must respond with `VERIFY_TOKEN` as the entire response body. This kit handles that automatically in `src/index.js`. The sandbox `listen` tunnel does not issue this GET; it only forwards live POSTs from HookMyApp to your local routes. ### Signature verification