Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ WhatsApp sandbox:

```bash
WEBHOOK_HMAC_SECRET=...
VERIFY_TOKEN=...
PORT=3000
WHATSAPP_API_URL=...
WHATSAPP_ACCESS_TOKEN=...
Expand All @@ -286,15 +287,17 @@ Instagram sandbox:

```bash
WEBHOOK_HMAC_SECRET=...
VERIFY_TOKEN=...
PORT=3000
INSTAGRAM_API_URL=...
INSTAGRAM_ACCESS_TOKEN=...
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

Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/sandbox-listen/picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function makeSession(overrides: Partial<WhatsAppSandboxSession> = {}): WhatsAppS
status: 'active',
accessToken: 'ACT_test',
hmacSecret: 'HMAC_test',
verifyToken: 'VT_test',
origin: 'test',
whatsappPhone: '+15550001',
whatsappPhoneNumberId: 'PNID_test',
Expand Down Expand Up @@ -114,6 +115,7 @@ describe('pickSession', () => {
senderInstagramUsername: 'ordvir',
accessToken: 'ACT_ig',
hmacSecret: 'HMAC_ig',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
2 changes: 2 additions & 0 deletions src/api/__tests__/sandbox-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const baseShared = {
id: 'ssn_TEST0001',
accessToken: 'ACT_xxx',
hmacSecret: 'HMAC_yyy',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
};
Expand Down Expand Up @@ -72,6 +73,7 @@ describe('parseSandboxSession', () => {
type: 'instagram',
accessToken: 'ACT_xxx',
hmacSecret: 'HMAC_yyy',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
senderInstagramId: '12345',
Expand Down
5 changes: 5 additions & 0 deletions src/api/sandbox-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/auth/__tests__/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ describe('post-login wizard', () => {
status: 'active',
accessToken: 'abc12345',
hmacSecret: 'secret',
verifyToken: 'VT_test',
origin: 'sandbox',
},
]);
Expand Down
1 change: 1 addition & 0 deletions src/commands/__tests__/sandbox-listen-banner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions src/commands/__tests__/wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand All @@ -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',
},
Expand All @@ -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',
},
Expand All @@ -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',
});
Expand Down Expand Up @@ -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',
},
Expand All @@ -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',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const ig: InstagramSandboxSession = {
senderInstagramUsername: 'ordvir',
accessToken: 'ACT_ig',
hmacSecret: 'HMAC_ig',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
5 changes: 5 additions & 0 deletions src/commands/sandbox/__tests__/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand All @@ -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',
};
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/commands/sandbox/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const wa: WhatsAppSandboxSession = {
whatsappApiVersion: 'v24.0',
accessToken: 'ACT_wa_xxx',
hmacSecret: 'HMAC_wa',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
};
Expand All @@ -30,6 +31,7 @@ const igWithUsername: InstagramSandboxSession = {
senderInstagramUsername: 'ordvir',
accessToken: 'ACT_ig_xxx',
hmacSecret: 'HMAC_ig',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
1 change: 1 addition & 0 deletions src/commands/sandbox/__tests__/logs-default-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const ig = {
senderInstagramUsername: 'ordvir',
accessToken: 'tok',
hmacSecret: 'hmac',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
2 changes: 2 additions & 0 deletions src/commands/sandbox/__tests__/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const wa: WhatsAppSandboxSession = {
whatsappApiVersion: 'v24.0',
accessToken: 'ACT_wa',
hmacSecret: 'HMAC_wa',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
};
Expand All @@ -40,6 +41,7 @@ const ig: InstagramSandboxSession = {
senderInstagramUsername: 'ordvir',
accessToken: 'ACT_ig',
hmacSecret: 'HMAC_ig',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
2 changes: 2 additions & 0 deletions src/commands/sandbox/__tests__/picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const wa: WhatsAppSandboxSession = {
whatsappApiVersion: 'v24.0',
accessToken: 'ACT_wa',
hmacSecret: 'HMAC_wa',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
};
Expand All @@ -27,6 +28,7 @@ const ig: InstagramSandboxSession = {
senderInstagramUsername: 'ordvir',
accessToken: 'ACT_ig',
hmacSecret: 'HMAC_ig',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
1 change: 1 addition & 0 deletions src/commands/sandbox/__tests__/send-positional.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const ig: InstagramSandboxSession = {
senderInstagramUsername: 'ordvir',
accessToken: 'tok',
hmacSecret: 'hmac',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
2 changes: 2 additions & 0 deletions src/commands/sandbox/__tests__/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const wa: WhatsAppSandboxSession = {
whatsappApiVersion: 'v24.0',
accessToken: 'ACT_wa',
hmacSecret: 'HMAC_wa',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
};
Expand All @@ -36,6 +37,7 @@ const ig: InstagramSandboxSession = {
senderInstagramUsername: 'ordvir',
accessToken: 'ACT_ig',
hmacSecret: 'HMAC_ig',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
2 changes: 2 additions & 0 deletions src/commands/sandbox/__tests__/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/commands/sandbox/__tests__/stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const wa: WhatsAppSandboxSession = {
whatsappApiVersion: 'v24.0',
accessToken: 'ACT_wa',
hmacSecret: 'HMAC_wa',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
};
Expand Down
2 changes: 2 additions & 0 deletions src/commands/sandbox/__tests__/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const wa: WhatsAppSandboxSession = {
whatsappApiVersion: 'v24.0',
accessToken: 'ACT_wa',
hmacSecret: 'HMAC_wa',
verifyToken: 'VT_test',
status: 'active',
origin: 'manual',
};
Expand All @@ -40,6 +41,7 @@ const ig: InstagramSandboxSession = {
senderInstagramUsername: 'ordvir',
accessToken: 'ACT_ig',
hmacSecret: 'HMAC_ig',
verifyToken: 'VT_test',
status: 'active',
origin: 'demo_handoff',
};
Expand Down
9 changes: 5 additions & 4 deletions src/commands/sandbox/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand All @@ -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],
Expand Down
Loading