diff --git a/CHANGELOG.md b/CHANGELOG.md index f08c626..55c6bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to `@gethookmyapp/cli` are documented here. +## Unreleased + +### Added + +- `sandbox env` now emits `VERIFY_TOKEN` (AIT-179) — the session's webhook verify token, distinct from `WEBHOOK_HMAC_SECRET`. `sandbox webhook set` runs the verify-GET handshake against it, so an unmodified starter-kit receiver configured via `sandbox env` now passes verification. + +### Changed + +- Sandbox session boundary parser requires `verifyToken` on every session; older backends without it surface as `MALFORMED_SANDBOX_SESSION`. + ## 0.12.7 — 2026-05-30 ### Breaking diff --git a/README.md b/README.md index f71f7fd..498fd02 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ WhatsApp sandbox: ```bash WEBHOOK_HMAC_SECRET=... +VERIFY_TOKEN=... PORT=3000 WHATSAPP_API_URL=... WHATSAPP_ACCESS_TOKEN=... @@ -286,6 +287,7 @@ Instagram sandbox: ```bash WEBHOOK_HMAC_SECRET=... +VERIFY_TOKEN=... PORT=3000 INSTAGRAM_API_URL=... INSTAGRAM_ACCESS_TOKEN=... @@ -293,8 +295,9 @@ INSTAGRAM_ACCOUNT_ID=... ``` `WEBHOOK_HMAC_SECRET` is the secret that signs delivered webhook payloads -(the `X-HookMyApp-Signature-256` header). Sandbox env blocks do not include -`VERIFY_TOKEN`: the sandbox tunnel never issues the verify-GET handshake. +(the `X-HookMyApp-Signature-256` header). `VERIFY_TOKEN` is a separate value: +`sandbox webhook set` runs the verify-GET handshake against your URL and +expects this token echoed back in the response body. ## Telemetry diff --git a/src/__tests__/sandbox-listen/picker.test.ts b/src/__tests__/sandbox-listen/picker.test.ts index 01f9dd9..e268db4 100644 --- a/src/__tests__/sandbox-listen/picker.test.ts +++ b/src/__tests__/sandbox-listen/picker.test.ts @@ -25,6 +25,7 @@ function makeSession(overrides: Partial = {}): WhatsAppS status: 'active', accessToken: 'ACT_test', hmacSecret: 'HMAC_test', + verifyToken: 'VT_test', origin: 'test', whatsappPhone: '+15550001', whatsappPhoneNumberId: 'PNID_test', @@ -114,6 +115,7 @@ describe('pickSession', () => { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig', hmacSecret: 'HMAC_ig', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/api/__tests__/sandbox-session.test.ts b/src/api/__tests__/sandbox-session.test.ts index 8169cbe..5c5bf1c 100644 --- a/src/api/__tests__/sandbox-session.test.ts +++ b/src/api/__tests__/sandbox-session.test.ts @@ -13,6 +13,7 @@ const baseShared = { id: 'ssn_TEST0001', accessToken: 'ACT_xxx', hmacSecret: 'HMAC_yyy', + verifyToken: 'VT_test', status: 'active', origin: 'manual', }; @@ -72,6 +73,7 @@ describe('parseSandboxSession', () => { type: 'instagram', accessToken: 'ACT_xxx', hmacSecret: 'HMAC_yyy', + verifyToken: 'VT_test', status: 'active', origin: 'manual', senderInstagramId: '12345', diff --git a/src/api/sandbox-session.ts b/src/api/sandbox-session.ts index 0a139ed..4d52c36 100644 --- a/src/api/sandbox-session.ts +++ b/src/api/sandbox-session.ts @@ -23,6 +23,10 @@ interface SandboxSessionBase { id: string; accessToken: string; hmacSecret: string; + /** GET-handshake echo value for webhook verification. Distinct from + * hmacSecret (Verify Token ≠ HMAC). Exported as VERIFY_TOKEN by + * `sandbox env` (AIT-179). */ + verifyToken: string; status: 'pending_activation' | 'active' | 'replaced' | 'expired'; origin: string; // Optional fields tolerated when present (not required for parser success): @@ -89,6 +93,7 @@ export function parseSandboxSession(dto: unknown): SandboxSession { if (!isNonEmptyString(d.id)) malformed(id, 'id missing'); if (!isNonEmptyString(d.accessToken)) malformed(id, 'accessToken missing'); if (!isNonEmptyString(d.hmacSecret)) malformed(id, 'hmacSecret missing'); + if (!isNonEmptyString(d.verifyToken)) malformed(id, 'verifyToken missing'); if (!isNonEmptyString(d.status)) malformed(id, 'status missing'); // Validate status against the closed union declared on SandboxSessionBase. // A typo like 'pending_activision' would otherwise pass through and lie to diff --git a/src/auth/__tests__/login.test.ts b/src/auth/__tests__/login.test.ts index 6fe1249..1b5cd5a 100644 --- a/src/auth/__tests__/login.test.ts +++ b/src/auth/__tests__/login.test.ts @@ -195,6 +195,7 @@ describe('post-login wizard', () => { status: 'active', accessToken: 'abc12345', hmacSecret: 'secret', + verifyToken: 'VT_test', origin: 'sandbox', }, ]); diff --git a/src/commands/__tests__/sandbox-listen-banner.test.ts b/src/commands/__tests__/sandbox-listen-banner.test.ts index 50af5f6..1900107 100644 --- a/src/commands/__tests__/sandbox-listen-banner.test.ts +++ b/src/commands/__tests__/sandbox-listen-banner.test.ts @@ -16,6 +16,7 @@ const SESSION: WhatsAppSandboxSession = { status: 'active', accessToken: 'ACT_banner', hmacSecret: 'HMAC_banner', + verifyToken: 'VT_test', origin: 'test', whatsappPhone: '+15551234567', whatsappPhoneNumberId: 'PNID_banner', diff --git a/src/commands/__tests__/wizard.test.ts b/src/commands/__tests__/wizard.test.ts index 643968a..a790f83 100644 --- a/src/commands/__tests__/wizard.test.ts +++ b/src/commands/__tests__/wizard.test.ts @@ -105,6 +105,7 @@ describe('wizard sandbox sub-flow — Phase 126 bind-code rework', () => { whatsappApiVersion: 'v20.0', accessToken: 'abc12345', hmacSecret: 'HMAC_only', + verifyToken: 'VT_test', status: 'active', origin: 'sandbox', }, @@ -130,6 +131,7 @@ describe('wizard sandbox sub-flow — Phase 126 bind-code rework', () => { whatsappApiVersion: 'v20.0', accessToken: 'tok_a', hmacSecret: 'hmac_a', + verifyToken: 'VT_test', status: 'active', origin: 'sandbox', }, @@ -143,6 +145,7 @@ describe('wizard sandbox sub-flow — Phase 126 bind-code rework', () => { whatsappApiVersion: 'v20.0', accessToken: 'tok_b', hmacSecret: 'hmac_b', + verifyToken: 'VT_test', status: 'active', origin: 'sandbox', }, @@ -157,6 +160,7 @@ describe('wizard sandbox sub-flow — Phase 126 bind-code rework', () => { whatsappApiVersion: 'v20.0', accessToken: 'tok_a', hmacSecret: 'hmac_a', + verifyToken: 'VT_test', status: 'active', origin: 'sandbox', }); @@ -190,6 +194,7 @@ describe('wizard sandbox sub-flow — Phase 126 bind-code rework', () => { whatsappApiVersion: 'v20.0', accessToken: 'tok_a', hmacSecret: 'hmac_a', + verifyToken: 'VT_test', status: 'active', origin: 'sandbox', }, @@ -203,6 +208,7 @@ describe('wizard sandbox sub-flow — Phase 126 bind-code rework', () => { whatsappApiVersion: 'v20.0', accessToken: 'tok_b', hmacSecret: 'hmac_b', + verifyToken: 'VT_test', status: 'active', origin: 'sandbox', }, diff --git a/src/commands/sandbox-listen/__tests__/listen-positional.test.ts b/src/commands/sandbox-listen/__tests__/listen-positional.test.ts index 5e80f16..f30d9c0 100644 --- a/src/commands/sandbox-listen/__tests__/listen-positional.test.ts +++ b/src/commands/sandbox-listen/__tests__/listen-positional.test.ts @@ -30,6 +30,7 @@ const ig: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig', hmacSecret: 'HMAC_ig', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/__tests__/env.test.ts b/src/commands/sandbox/__tests__/env.test.ts index c3e6035..bbb06e3 100644 --- a/src/commands/sandbox/__tests__/env.test.ts +++ b/src/commands/sandbox/__tests__/env.test.ts @@ -31,6 +31,7 @@ const wa: WhatsAppSandboxSession = { whatsappApiVersion: 'v24.0', accessToken: 'ACT_wa_xxx', hmacSecret: 'HMAC_wa_yyy', + verifyToken: 'VT_wa_zzz', status: 'active', origin: 'manual', }; @@ -43,6 +44,7 @@ const ig: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig_xxx', hmacSecret: 'HMAC_ig_yyy', + verifyToken: 'VT_ig_zzz', status: 'active', origin: 'demo_handoff', }; @@ -60,6 +62,7 @@ describe('buildEnvBlock — WhatsApp regression', () => { expect(out).toBe( [ 'WEBHOOK_HMAC_SECRET=HMAC_wa_yyy', + 'VERIFY_TOKEN=VT_wa_zzz', 'PORT=3000', 'WHATSAPP_API_URL=https://proxy.test/v24.0', 'WHATSAPP_ACCESS_TOKEN=ACT_wa_xxx', @@ -83,6 +86,7 @@ describe('buildEnvBlock — Instagram (D2)', () => { expect(out).toBe( [ 'WEBHOOK_HMAC_SECRET=HMAC_ig_yyy', + 'VERIFY_TOKEN=VT_ig_zzz', 'PORT=3000', 'INSTAGRAM_API_URL=https://proxy.test/v25.0', 'INSTAGRAM_ACCESS_TOKEN=ACT_ig_xxx', @@ -137,6 +141,7 @@ describe('runSandboxEnv --json — flat {KEY: VALUE} object', () => { const parsed = JSON.parse((writeSpy.mock.calls[0][0] as string).trim()); expect(parsed).toEqual({ WEBHOOK_HMAC_SECRET: 'HMAC_ig_yyy', + VERIFY_TOKEN: 'VT_ig_zzz', PORT: '3000', INSTAGRAM_API_URL: 'https://proxy.test/v25.0', INSTAGRAM_ACCESS_TOKEN: 'ACT_ig_xxx', diff --git a/src/commands/sandbox/__tests__/helpers.test.ts b/src/commands/sandbox/__tests__/helpers.test.ts index 924f3a4..bd298e2 100644 --- a/src/commands/sandbox/__tests__/helpers.test.ts +++ b/src/commands/sandbox/__tests__/helpers.test.ts @@ -18,6 +18,7 @@ const wa: WhatsAppSandboxSession = { whatsappApiVersion: 'v24.0', accessToken: 'ACT_wa_xxx', hmacSecret: 'HMAC_wa', + verifyToken: 'VT_test', status: 'active', origin: 'manual', }; @@ -30,6 +31,7 @@ const igWithUsername: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig_xxx', hmacSecret: 'HMAC_ig', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/__tests__/logs-default-format.test.ts b/src/commands/sandbox/__tests__/logs-default-format.test.ts index e84ed62..16284ed 100644 --- a/src/commands/sandbox/__tests__/logs-default-format.test.ts +++ b/src/commands/sandbox/__tests__/logs-default-format.test.ts @@ -19,6 +19,7 @@ const ig = { senderInstagramUsername: 'ordvir', accessToken: 'tok', hmacSecret: 'hmac', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/__tests__/logs.test.ts b/src/commands/sandbox/__tests__/logs.test.ts index f33cb68..5cd592a 100644 --- a/src/commands/sandbox/__tests__/logs.test.ts +++ b/src/commands/sandbox/__tests__/logs.test.ts @@ -28,6 +28,7 @@ const wa: WhatsAppSandboxSession = { whatsappApiVersion: 'v24.0', accessToken: 'ACT_wa', hmacSecret: 'HMAC_wa', + verifyToken: 'VT_test', status: 'active', origin: 'manual', }; @@ -40,6 +41,7 @@ const ig: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig', hmacSecret: 'HMAC_ig', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/__tests__/picker.test.ts b/src/commands/sandbox/__tests__/picker.test.ts index 921ffea..ee78389 100644 --- a/src/commands/sandbox/__tests__/picker.test.ts +++ b/src/commands/sandbox/__tests__/picker.test.ts @@ -15,6 +15,7 @@ const wa: WhatsAppSandboxSession = { whatsappApiVersion: 'v24.0', accessToken: 'ACT_wa', hmacSecret: 'HMAC_wa', + verifyToken: 'VT_test', status: 'active', origin: 'manual', }; @@ -27,6 +28,7 @@ const ig: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig', hmacSecret: 'HMAC_ig', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/__tests__/send-positional.test.ts b/src/commands/sandbox/__tests__/send-positional.test.ts index 72afa1a..ac0ff30 100644 --- a/src/commands/sandbox/__tests__/send-positional.test.ts +++ b/src/commands/sandbox/__tests__/send-positional.test.ts @@ -30,6 +30,7 @@ const ig: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'tok', hmacSecret: 'hmac', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/__tests__/send.test.ts b/src/commands/sandbox/__tests__/send.test.ts index 7b02360..093e962 100644 --- a/src/commands/sandbox/__tests__/send.test.ts +++ b/src/commands/sandbox/__tests__/send.test.ts @@ -24,6 +24,7 @@ const wa: WhatsAppSandboxSession = { whatsappApiVersion: 'v24.0', accessToken: 'ACT_wa', hmacSecret: 'HMAC_wa', + verifyToken: 'VT_test', status: 'active', origin: 'manual', }; @@ -36,6 +37,7 @@ const ig: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig', hmacSecret: 'HMAC_ig', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/__tests__/status.test.ts b/src/commands/sandbox/__tests__/status.test.ts index cb3e71a..4bb362d 100644 --- a/src/commands/sandbox/__tests__/status.test.ts +++ b/src/commands/sandbox/__tests__/status.test.ts @@ -22,6 +22,7 @@ const rawWaWire = { whatsappApiVersion: 'v24.0', accessToken: 'wa-secret-token', hmacSecret: 'wa-hmac-secret', + verifyToken: 'VT_test', status: 'active', origin: 'manual', webhookUrl: null, @@ -41,6 +42,7 @@ const rawIgWire = { senderInstagramUsername: 'ordvir', accessToken: 'ig-secret-token', hmacSecret: 'ig-hmac-secret', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', webhookUrl: 'https://my.example/hook', diff --git a/src/commands/sandbox/__tests__/stop.test.ts b/src/commands/sandbox/__tests__/stop.test.ts index 48d2f84..c9a57e0 100644 --- a/src/commands/sandbox/__tests__/stop.test.ts +++ b/src/commands/sandbox/__tests__/stop.test.ts @@ -25,6 +25,7 @@ const wa: WhatsAppSandboxSession = { whatsappApiVersion: 'v24.0', accessToken: 'ACT_wa', hmacSecret: 'HMAC_wa', + verifyToken: 'VT_test', status: 'active', origin: 'manual', }; diff --git a/src/commands/sandbox/__tests__/webhook.test.ts b/src/commands/sandbox/__tests__/webhook.test.ts index b9fd9e1..7db1708 100644 --- a/src/commands/sandbox/__tests__/webhook.test.ts +++ b/src/commands/sandbox/__tests__/webhook.test.ts @@ -28,6 +28,7 @@ const wa: WhatsAppSandboxSession = { whatsappApiVersion: 'v24.0', accessToken: 'ACT_wa', hmacSecret: 'HMAC_wa', + verifyToken: 'VT_test', status: 'active', origin: 'manual', }; @@ -40,6 +41,7 @@ const ig: InstagramSandboxSession = { senderInstagramUsername: 'ordvir', accessToken: 'ACT_ig', hmacSecret: 'HMAC_ig', + verifyToken: 'VT_test', status: 'active', origin: 'demo_handoff', }; diff --git a/src/commands/sandbox/env.ts b/src/commands/sandbox/env.ts index d3c7d58..084b21d 100644 --- a/src/commands/sandbox/env.ts +++ b/src/commands/sandbox/env.ts @@ -5,10 +5,9 @@ // Per D2: WA block uses the WHATSAPP_* prefix (including the WA quirk where // WHATSAPP_PHONE_NUMBER_ID carries the tester's phone, per spec D4). IG block // uses the INSTAGRAM_* prefix. Both blocks carry the session's webhook HMAC -// signing secret as WEBHOOK_HMAC_SECRET. No VERIFY_TOKEN is written: the -// sandbox tunnel never issues the verify-GET handshake, and the temporary -// compat alias for pre-split starter-kits was dropped alongside -// webhook-starter-kit v3 (AIT-126). +// signing secret as WEBHOOK_HMAC_SECRET and, since AIT-179, the session's +// VERIFY_TOKEN — `sandbox webhook set` runs the verify-GET handshake against +// it (distinct from the HMAC secret; Verify Token ≠ HMAC). import * as fs from 'node:fs'; import type { Command } from 'commander'; @@ -42,6 +41,7 @@ export function buildEnvPairs(session: SandboxSession): [string, string][] { case 'whatsapp': return [ ['WEBHOOK_HMAC_SECRET', session.hmacSecret], + ['VERIFY_TOKEN', session.verifyToken], ['PORT', '3000'], ['WHATSAPP_API_URL', `${proxyBase}/${session.whatsappApiVersion}`], ['WHATSAPP_ACCESS_TOKEN', session.accessToken], @@ -50,6 +50,7 @@ export function buildEnvPairs(session: SandboxSession): [string, string][] { case 'instagram': return [ ['WEBHOOK_HMAC_SECRET', session.hmacSecret], + ['VERIFY_TOKEN', session.verifyToken], ['PORT', '3000'], ['INSTAGRAM_API_URL', `${proxyBase}/${INSTAGRAM_GRAPH_VERSION}`], ['INSTAGRAM_ACCESS_TOKEN', session.accessToken],