claude-channel-github-webhook 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci.yml +23 -0
- package/README.md +126 -0
- package/jest.config.js +11 -0
- package/package.json +24 -0
- package/src/channel.ts +85 -0
- package/src/shared/message.test.ts +107 -0
- package/src/shared/message.ts +93 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "github-webhook",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "GitHub Webhook events (PR reviews, CI failures, @claude mentions) as Claude Code Channel notifications",
|
|
5
|
+
"channel": {
|
|
6
|
+
"command": "npx",
|
|
7
|
+
"args": ["tsx", "src/channel.ts"]
|
|
8
|
+
},
|
|
9
|
+
"env": [
|
|
10
|
+
{
|
|
11
|
+
"name": "GITHUB_WEBHOOK_SECRET",
|
|
12
|
+
"description": "GitHub Webhook secret for HMAC signature verification",
|
|
13
|
+
"required": false
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "WEBHOOK_PORT",
|
|
17
|
+
"description": "Port for the webhook router (default: 8788)",
|
|
18
|
+
"required": false,
|
|
19
|
+
"default": "8788"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
node-version: [20, 22]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: ${{ matrix.node-version }}
|
|
20
|
+
cache: npm
|
|
21
|
+
- run: npm ci
|
|
22
|
+
- run: npm run lint
|
|
23
|
+
- run: npm test
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# claude-channel-github-webhook
|
|
2
|
+
|
|
3
|
+
A Claude Code Channel plugin that delivers GitHub Webhook events as real-time notifications to your Claude Code session.
|
|
4
|
+
|
|
5
|
+
Supports PR reviews, CI failures, and `@claude` mentions.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
GitHub Webhook
|
|
11
|
+
|
|
|
12
|
+
v (tunnel: ngrok / cloudflared / etc.)
|
|
13
|
+
[channel.ts :8788] <- single process: MCP server + HTTP listener
|
|
14
|
+
|
|
|
15
|
+
v (notifications/claude/channel)
|
|
16
|
+
Claude Code session
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A single process handles everything: receives GitHub Webhooks over HTTP and forwards them to Claude Code via MCP stdio. No router, no registry, no extra processes.
|
|
20
|
+
|
|
21
|
+
## Notification Rules
|
|
22
|
+
|
|
23
|
+
| Event | Condition |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `pull_request_review` | All reviews |
|
|
26
|
+
| `check_run` | `conclusion === "failure"` only |
|
|
27
|
+
| `issue_comment` | Comment body contains `@claude` |
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
### 1. Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install claude-channel-github-webhook
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or clone directly:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/moeki0/claude-channel-github-webhook.git
|
|
41
|
+
cd claude-channel-github-webhook
|
|
42
|
+
npm install
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 2. Open a tunnel
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
ngrok http 8788
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Any tunneling tool works (ngrok, cloudflared, Smee.io, reverse proxy, etc.).
|
|
52
|
+
|
|
53
|
+
### 3. Configure the GitHub Webhook
|
|
54
|
+
|
|
55
|
+
In your repository's **Settings > Webhooks > Add webhook**:
|
|
56
|
+
|
|
57
|
+
- **Payload URL**: Your tunnel's public URL
|
|
58
|
+
- **Content type**: `application/json`
|
|
59
|
+
- **Secret**: Any string (optional but recommended)
|
|
60
|
+
- **Events**: `Pull request reviews` / `Check runs` / `Issue comments`
|
|
61
|
+
|
|
62
|
+
### 4. Launch with Claude Code
|
|
63
|
+
|
|
64
|
+
Add to your project's `.mcp.json`:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"github-webhook": {
|
|
70
|
+
"command": "npx",
|
|
71
|
+
"args": ["claude-channel-github-webhook"],
|
|
72
|
+
"env": {
|
|
73
|
+
"GITHUB_WEBHOOK_SECRET": "your_secret"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then start Claude Code:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
claude --dangerously-load-development-channels server:github-webhook
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Environment Variables
|
|
87
|
+
|
|
88
|
+
| Variable | Description | Default |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `GITHUB_WEBHOOK_SECRET` | Secret for HMAC signature verification | None (verification skipped) |
|
|
91
|
+
| `WEBHOOK_PORT` | HTTP listen port | `8788` |
|
|
92
|
+
|
|
93
|
+
## How It Works
|
|
94
|
+
|
|
95
|
+
1. Claude Code spawns `channel.ts` as a child process
|
|
96
|
+
2. The process connects to Claude Code via MCP stdio
|
|
97
|
+
3. It starts an HTTP server on `127.0.0.1:8788`
|
|
98
|
+
4. GitHub Webhooks arrive via tunnel, are verified (HMAC), parsed, and filtered
|
|
99
|
+
5. Matching events are sent as `notifications/claude/channel` with structured `meta` attributes
|
|
100
|
+
6. Claude Code receives `<channel source="github-webhook" event="check_run" ...>` tags and acts accordingly
|
|
101
|
+
|
|
102
|
+
## What Claude Receives
|
|
103
|
+
|
|
104
|
+
```xml
|
|
105
|
+
<channel source="github-webhook" event="check_run" conclusion="failure" branch="feat/x" workflow="CI / test">
|
|
106
|
+
CI failed: CI / test
|
|
107
|
+
Branch: feat/x
|
|
108
|
+
Details: https://github.com/org/repo/actions/runs/123
|
|
109
|
+
</channel>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm test # Run tests
|
|
116
|
+
npm run lint # Type check
|
|
117
|
+
npm run start # Start channel standalone
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Status
|
|
121
|
+
|
|
122
|
+
Claude Code Channels is in **Research Preview**.
|
|
123
|
+
|
|
124
|
+
- Requires Claude Code v2.1.80+
|
|
125
|
+
- Requires claude.ai login (not API key)
|
|
126
|
+
- Team/Enterprise orgs must enable `channelsEnabled`
|
package/jest.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-channel-github-webhook",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "GitHub Webhook Channel plugin for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "src/channel.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "tsx src/channel.ts",
|
|
10
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
11
|
+
"lint": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
15
|
+
"tsx": "^4.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"@types/jest": "^29.0.0",
|
|
20
|
+
"jest": "^29.0.0",
|
|
21
|
+
"ts-jest": "^29.0.0",
|
|
22
|
+
"typescript": "^5.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import { buildNotification, verifySignature } from "./shared/message.js";
|
|
6
|
+
|
|
7
|
+
const SECRET = process.env.GITHUB_WEBHOOK_SECRET ?? "";
|
|
8
|
+
const PORT = parseInt(process.env.WEBHOOK_PORT ?? "8788");
|
|
9
|
+
const MAX_BODY = 1024 * 1024; // 1MB
|
|
10
|
+
|
|
11
|
+
const mcp = new Server(
|
|
12
|
+
{ name: "github-webhook", version: "1.0.0" },
|
|
13
|
+
{
|
|
14
|
+
capabilities: { experimental: { "claude/channel": {} } },
|
|
15
|
+
instructions: [
|
|
16
|
+
"Events from the github-webhook channel arrive as <channel source=\"github-webhook\" ...>.",
|
|
17
|
+
"Attributes include event type, branch, PR number, etc.",
|
|
18
|
+
"On check_run failures: read the CI logs and attempt to fix the issue.",
|
|
19
|
+
"On pull_request_review: read the review comments and address the feedback.",
|
|
20
|
+
"On issue_comment with @claude: respond to the request in the comment.",
|
|
21
|
+
"These are one-way notifications — no reply mechanism is available.",
|
|
22
|
+
].join(" "),
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
await mcp.connect(new StdioServerTransport());
|
|
27
|
+
|
|
28
|
+
const httpServer = http.createServer((req, res) => {
|
|
29
|
+
if (req.method !== "POST") {
|
|
30
|
+
res.writeHead(405);
|
|
31
|
+
res.end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let body = "";
|
|
36
|
+
let size = 0;
|
|
37
|
+
|
|
38
|
+
req.on("data", (chunk: Buffer) => {
|
|
39
|
+
size += chunk.length;
|
|
40
|
+
if (size > MAX_BODY) {
|
|
41
|
+
res.writeHead(413);
|
|
42
|
+
res.end("payload too large");
|
|
43
|
+
req.destroy();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
body += chunk;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
req.on("end", async () => {
|
|
50
|
+
if (size > MAX_BODY) return;
|
|
51
|
+
|
|
52
|
+
const signature = req.headers["x-hub-signature-256"] as string | undefined;
|
|
53
|
+
if (!verifySignature(body, signature, SECRET)) {
|
|
54
|
+
res.writeHead(401);
|
|
55
|
+
res.end("invalid signature");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const event = req.headers["x-github-event"] as string;
|
|
60
|
+
let payload: Record<string, unknown>;
|
|
61
|
+
try {
|
|
62
|
+
payload = JSON.parse(body);
|
|
63
|
+
} catch {
|
|
64
|
+
res.writeHead(400);
|
|
65
|
+
res.end("invalid json");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const notification = buildNotification(event, payload);
|
|
70
|
+
if (notification) {
|
|
71
|
+
await mcp.notification({
|
|
72
|
+
method: "notifications/claude/channel",
|
|
73
|
+
params: notification,
|
|
74
|
+
});
|
|
75
|
+
process.stderr.write(`[github-webhook] notified: ${event}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
res.writeHead(200);
|
|
79
|
+
res.end("ok");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
httpServer.listen(PORT, "127.0.0.1", () => {
|
|
84
|
+
process.stderr.write(`[github-webhook] listening on 127.0.0.1:${PORT}\n`);
|
|
85
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from "@jest/globals";
|
|
2
|
+
import { buildNotification, verifySignature } from "./message.js";
|
|
3
|
+
|
|
4
|
+
describe("buildNotification", () => {
|
|
5
|
+
it("pull_request_review のメッセージと meta を組み立てる", () => {
|
|
6
|
+
const payload = {
|
|
7
|
+
pull_request: {
|
|
8
|
+
title: "Add feature",
|
|
9
|
+
number: 42,
|
|
10
|
+
html_url: "https://github.com/org/repo/pull/42",
|
|
11
|
+
head: { ref: "feat/add-feature" },
|
|
12
|
+
},
|
|
13
|
+
review: {
|
|
14
|
+
user: { login: "reviewer" },
|
|
15
|
+
state: "approved",
|
|
16
|
+
body: "LGTM",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const result = buildNotification("pull_request_review", payload);
|
|
20
|
+
expect(result).not.toBeNull();
|
|
21
|
+
expect(result!.content).toContain("Add feature");
|
|
22
|
+
expect(result!.content).toContain("reviewer");
|
|
23
|
+
expect(result!.content).toContain("approved");
|
|
24
|
+
expect(result!.meta.event).toBe("pull_request_review");
|
|
25
|
+
expect(result!.meta.pr_number).toBe("42");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("check_run 失敗のメッセージを組み立てる", () => {
|
|
29
|
+
const payload = {
|
|
30
|
+
check_run: {
|
|
31
|
+
name: "CI / test",
|
|
32
|
+
conclusion: "failure",
|
|
33
|
+
html_url: "https://github.com/org/repo/actions/runs/1",
|
|
34
|
+
check_suite: { head_branch: "feat/add-feature" },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const result = buildNotification("check_run", payload);
|
|
38
|
+
expect(result).not.toBeNull();
|
|
39
|
+
expect(result!.content).toContain("CI / test");
|
|
40
|
+
expect(result!.meta.event).toBe("check_run");
|
|
41
|
+
expect(result!.meta.conclusion).toBe("failure");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("check_run 成功は null を返す", () => {
|
|
45
|
+
const payload = {
|
|
46
|
+
check_run: {
|
|
47
|
+
name: "CI / test",
|
|
48
|
+
conclusion: "success",
|
|
49
|
+
html_url: "https://github.com/org/repo/actions/runs/1",
|
|
50
|
+
check_suite: { head_branch: "feat/add-feature" },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
expect(buildNotification("check_run", payload)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("issue_comment に @claude を含む場合のみ通過する", () => {
|
|
57
|
+
const payload = {
|
|
58
|
+
issue: { title: "Bug report" },
|
|
59
|
+
comment: { body: "Hey @claude please fix this", html_url: "https://github.com/org/repo/issues/1#comment" },
|
|
60
|
+
};
|
|
61
|
+
const result = buildNotification("issue_comment", payload);
|
|
62
|
+
expect(result).not.toBeNull();
|
|
63
|
+
expect(result!.content).toContain("Bug report");
|
|
64
|
+
expect(result!.meta.event).toBe("issue_comment");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("issue_comment に @claude を含まない場合は null を返す", () => {
|
|
68
|
+
const payload = {
|
|
69
|
+
issue: { title: "Bug report" },
|
|
70
|
+
comment: { body: "Just a regular comment", html_url: "https://github.com/org/repo/issues/1#comment" },
|
|
71
|
+
};
|
|
72
|
+
expect(buildNotification("issue_comment", payload)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("@claudebot のような部分一致では通過しない", () => {
|
|
76
|
+
const payload = {
|
|
77
|
+
issue: { title: "Bug report" },
|
|
78
|
+
comment: { body: "ask @claudebot", html_url: "https://github.com/org/repo/issues/1#comment" },
|
|
79
|
+
};
|
|
80
|
+
expect(buildNotification("issue_comment", payload)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("未知のイベントタイプは null を返す", () => {
|
|
84
|
+
expect(buildNotification("unknown_event", {})).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("verifySignature", () => {
|
|
89
|
+
it("正しい署名を検証できる", () => {
|
|
90
|
+
const body = '{"test":true}';
|
|
91
|
+
const secret = "mysecret";
|
|
92
|
+
// pre-computed: echo -n '{"test":true}' | openssl dgst -sha256 -hmac 'mysecret'
|
|
93
|
+
expect(verifySignature(body, "sha256=f269168b331c2c56aa328857bfcda87bacca5e4e1da4da687667068a21dd3c53", secret)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("不正な署名を拒否する", () => {
|
|
97
|
+
expect(verifySignature("{}", "sha256=wrong", "mysecret")).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("secret が空なら検証をスキップする", () => {
|
|
101
|
+
expect(verifySignature("{}", undefined, "")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("secret があるのに署名がなければ拒否する", () => {
|
|
105
|
+
expect(verifySignature("{}", undefined, "mysecret")).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface Notification {
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
content: string;
|
|
6
|
+
meta: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildNotification(event: string, payload: Record<string, unknown>): Notification | null {
|
|
10
|
+
switch (event) {
|
|
11
|
+
case "pull_request_review": {
|
|
12
|
+
const pr = payload.pull_request as {
|
|
13
|
+
title: string;
|
|
14
|
+
number: number;
|
|
15
|
+
html_url: string;
|
|
16
|
+
};
|
|
17
|
+
const review = payload.review as {
|
|
18
|
+
user: { login: string };
|
|
19
|
+
state: string;
|
|
20
|
+
body?: string;
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
content: [
|
|
24
|
+
`PR review on "${pr.title}" (#${pr.number})`,
|
|
25
|
+
`Reviewer: ${review.user.login}`,
|
|
26
|
+
`State: ${review.state}`,
|
|
27
|
+
`Comment: ${review.body ?? "(none)"}`,
|
|
28
|
+
`URL: ${pr.html_url}`,
|
|
29
|
+
].join("\n"),
|
|
30
|
+
meta: {
|
|
31
|
+
event,
|
|
32
|
+
pr_number: String(pr.number),
|
|
33
|
+
reviewer: review.user.login,
|
|
34
|
+
state: review.state,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case "check_run": {
|
|
40
|
+
const checkRun = payload.check_run as {
|
|
41
|
+
name: string;
|
|
42
|
+
conclusion: string;
|
|
43
|
+
html_url: string;
|
|
44
|
+
check_suite: { head_branch: string };
|
|
45
|
+
};
|
|
46
|
+
if (checkRun.conclusion !== "failure") return null;
|
|
47
|
+
return {
|
|
48
|
+
content: [
|
|
49
|
+
`CI failed: ${checkRun.name}`,
|
|
50
|
+
`Branch: ${checkRun.check_suite.head_branch}`,
|
|
51
|
+
`Details: ${checkRun.html_url}`,
|
|
52
|
+
].join("\n"),
|
|
53
|
+
meta: {
|
|
54
|
+
event,
|
|
55
|
+
conclusion: checkRun.conclusion,
|
|
56
|
+
branch: checkRun.check_suite.head_branch,
|
|
57
|
+
workflow: checkRun.name,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case "issue_comment": {
|
|
63
|
+
const issue = payload.issue as { title: string };
|
|
64
|
+
const comment = payload.comment as { body: string; html_url: string };
|
|
65
|
+
if (!/@claude\b/.test(comment.body)) return null;
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
`Mentioned in: ${issue.title}`,
|
|
69
|
+
`Comment: ${comment.body}`,
|
|
70
|
+
`URL: ${comment.html_url}`,
|
|
71
|
+
].join("\n"),
|
|
72
|
+
meta: {
|
|
73
|
+
event,
|
|
74
|
+
issue_title: issue.title,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
default:
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function verifySignature(body: string, signature: string | undefined, secret: string): boolean {
|
|
85
|
+
if (!secret) return true;
|
|
86
|
+
if (!signature) return false;
|
|
87
|
+
const digest = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
88
|
+
try {
|
|
89
|
+
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|