@azizikri/hookpipe 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/LICENSE +21 -0
- package/README.md +325 -0
- package/package.json +64 -0
- package/src/auth/hmac.js +100 -0
- package/src/cli/index.js +31 -0
- package/src/cli/logs.js +86 -0
- package/src/cli/replay.js +64 -0
- package/src/cli/serve.js +75 -0
- package/src/cli/test.js +135 -0
- package/src/db/index.js +60 -0
- package/src/db/migrations/001_initial.sql +107 -0
- package/src/db/queries.js +196 -0
- package/src/delivery/retry.js +63 -0
- package/src/delivery/worker.js +192 -0
- package/src/destinations/http.js +60 -0
- package/src/destinations/index.js +30 -0
- package/src/destinations/interface.js +47 -0
- package/src/pipeline-loader.js +151 -0
- package/src/plugin-loader.js +88 -0
- package/src/queue/interface.js +68 -0
- package/src/queue/sqlite-queue.js +83 -0
- package/src/server.js +124 -0
- package/src/templates/handlebars.js +21 -0
- package/src/utils/config.js +115 -0
- package/src/utils/crypto.js +44 -0
- package/src/utils/logger.js +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hookpipe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# hookpipe
|
|
2
|
+
|
|
3
|
+
Self-hosted webhook relay with transform pipelines. Receive webhooks, authenticate, filter, transform, and deliver to one or more destinations. Built for self-hosted environments where you control the infrastructure.
|
|
4
|
+
|
|
5
|
+
## Quick Start — Docker
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
docker build -t hookpipe -f docker/Dockerfile .
|
|
9
|
+
docker run -p 3000:3000 -v ./pipelines:/app/pipelines -v ./data:/app/data hookpipe
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick Start — Bare Metal
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
cp .env.example .env
|
|
17
|
+
mkdir -p pipelines data
|
|
18
|
+
node src/cli/index.js serve
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Webhooks are received at `POST /hook/:pipeline-id`.
|
|
22
|
+
|
|
23
|
+
## Pipeline YAML Schema
|
|
24
|
+
|
|
25
|
+
Pipelines are YAML files in the `pipelines/` directory. Each file defines one pipeline.
|
|
26
|
+
|
|
27
|
+
### Required Fields
|
|
28
|
+
|
|
29
|
+
| Field | Type | Description |
|
|
30
|
+
|-------|------|-------------|
|
|
31
|
+
| `id` | string | Unique pipeline identifier (must match filename) |
|
|
32
|
+
| `destinations` | array | One or more delivery targets |
|
|
33
|
+
|
|
34
|
+
Each destination requires:
|
|
35
|
+
|
|
36
|
+
| Field | Type | Description |
|
|
37
|
+
|-------|------|-------------|
|
|
38
|
+
| `id` | string | Unique destination identifier within the pipeline |
|
|
39
|
+
| `type` | string | Destination type (`http`) |
|
|
40
|
+
| `url` | string | Target URL |
|
|
41
|
+
|
|
42
|
+
### Optional Fields
|
|
43
|
+
|
|
44
|
+
| Field | Type | Description |
|
|
45
|
+
|-------|------|-------------|
|
|
46
|
+
| `name` | string | Human-readable pipeline name |
|
|
47
|
+
| `description` | string | Pipeline description |
|
|
48
|
+
| `auth` | object | Authentication configuration |
|
|
49
|
+
| `filter` | string | Filter plugin name |
|
|
50
|
+
| `filter_config` | object | Config passed to the filter plugin |
|
|
51
|
+
| `transform` | string | Transform plugin name |
|
|
52
|
+
| `retry` | object | Retry policy override |
|
|
53
|
+
|
|
54
|
+
### Auth Configuration
|
|
55
|
+
|
|
56
|
+
| Field | Type | Description |
|
|
57
|
+
|-------|------|-------------|
|
|
58
|
+
| `type` | string | `hmac-sha256` or `hmac-sha1` |
|
|
59
|
+
| `secret` | string | HMAC secret (supports `${ENV_VAR}` interpolation) |
|
|
60
|
+
| `header` | string | Header containing the signature |
|
|
61
|
+
|
|
62
|
+
### Destination Options
|
|
63
|
+
|
|
64
|
+
| Field | Type | Description |
|
|
65
|
+
|-------|------|-------------|
|
|
66
|
+
| `method` | string | HTTP method (default: `POST`) |
|
|
67
|
+
| `headers` | object | Additional headers to send |
|
|
68
|
+
| `body_template` | string | Handlebars template for the request body |
|
|
69
|
+
| `timeout_ms` | number | Request timeout in milliseconds |
|
|
70
|
+
| `on_failure` | string | Failure behavior (`retry` or `log`) |
|
|
71
|
+
|
|
72
|
+
### Complete Example
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
id: github-deploy
|
|
76
|
+
name: GitHub Deploy Notifications
|
|
77
|
+
description: Forward GitHub push events to deploy service
|
|
78
|
+
|
|
79
|
+
auth:
|
|
80
|
+
type: hmac-sha256
|
|
81
|
+
secret: ${GITHUB_WEBHOOK_SECRET}
|
|
82
|
+
header: x-hub-signature-256
|
|
83
|
+
|
|
84
|
+
filter: github-event-filter
|
|
85
|
+
filter_config:
|
|
86
|
+
events:
|
|
87
|
+
- push
|
|
88
|
+
- release
|
|
89
|
+
|
|
90
|
+
transform: extract-commit-info
|
|
91
|
+
|
|
92
|
+
destinations:
|
|
93
|
+
- id: deploy-service
|
|
94
|
+
type: http
|
|
95
|
+
url: https://deploy.internal/api/trigger
|
|
96
|
+
method: POST
|
|
97
|
+
headers:
|
|
98
|
+
authorization: Bearer ${DEPLOY_TOKEN}
|
|
99
|
+
timeout_ms: 10000
|
|
100
|
+
on_failure: retry
|
|
101
|
+
|
|
102
|
+
- id: slack-notify
|
|
103
|
+
type: http
|
|
104
|
+
url: https://hooks.slack.com/services/T00/B00/xxx
|
|
105
|
+
body_template: |
|
|
106
|
+
{"text": "Deploy triggered by {{payload.pusher.name}} on {{payload.ref}}"}
|
|
107
|
+
on_failure: log
|
|
108
|
+
|
|
109
|
+
retry:
|
|
110
|
+
maxAttempts: 5
|
|
111
|
+
backoff: exponential
|
|
112
|
+
initialDelayMs: 2000
|
|
113
|
+
maxDelayMs: 300000
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Plugin API
|
|
117
|
+
|
|
118
|
+
Plugins are CommonJS modules loaded via `createRequire` (the project itself is ESM). Place them in the `plugins/` directory.
|
|
119
|
+
|
|
120
|
+
### Transform Plugin
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
// plugins/extract-commit-info.cjs
|
|
124
|
+
module.exports.transform = function (payload, headers, config) {
|
|
125
|
+
// Return transformed payload object
|
|
126
|
+
// Return null to drop the webhook entirely
|
|
127
|
+
return {
|
|
128
|
+
ref: payload.ref,
|
|
129
|
+
commits: payload.commits.map(c => c.message),
|
|
130
|
+
pusher: payload.pusher.name,
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Signature:** `transform(payload, headers, config) → object | null`
|
|
136
|
+
|
|
137
|
+
- `payload` — parsed JSON body of the incoming webhook
|
|
138
|
+
- `headers` — request headers (lowercased keys)
|
|
139
|
+
- `config` — the pipeline's `filter_config` object (shared namespace)
|
|
140
|
+
- Returns the transformed payload, or `null` to drop the webhook
|
|
141
|
+
|
|
142
|
+
### Filter Plugin
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
// plugins/github-event-filter.cjs
|
|
146
|
+
module.exports.filter = function (payload, headers, config) {
|
|
147
|
+
const event = headers['x-github-event'];
|
|
148
|
+
if (config.events.includes(event)) {
|
|
149
|
+
return { pass: true };
|
|
150
|
+
}
|
|
151
|
+
return { pass: false, reason: `Event '${event}' not in allowed list` };
|
|
152
|
+
};
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Signature:** `filter(payload, headers, config) → { pass: boolean, reason?: string }`
|
|
156
|
+
|
|
157
|
+
- Return `{ pass: true }` to continue processing
|
|
158
|
+
- Return `{ pass: false, reason: "..." }` to reject the webhook
|
|
159
|
+
|
|
160
|
+
## CLI Reference
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
hookpipe <command> [options]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### `hookpipe serve`
|
|
167
|
+
|
|
168
|
+
Start the webhook server.
|
|
169
|
+
|
|
170
|
+
| Flag | Description |
|
|
171
|
+
|------|-------------|
|
|
172
|
+
| `-p, --port <number>` | Port to listen on |
|
|
173
|
+
| `-H, --host <string>` | Host to bind to |
|
|
174
|
+
| `-c, --config <path>` | Config file path |
|
|
175
|
+
| `--pipelines <dir>` | Pipelines directory |
|
|
176
|
+
| `--plugins <dir>` | Plugins directory |
|
|
177
|
+
|
|
178
|
+
### `hookpipe test <pipeline-id>`
|
|
179
|
+
|
|
180
|
+
Dry-run a webhook through a pipeline without delivering.
|
|
181
|
+
|
|
182
|
+
| Flag | Description |
|
|
183
|
+
|------|-------------|
|
|
184
|
+
| `-f, --file <path>` | JSON file to use as payload |
|
|
185
|
+
| `-d, --data <json>` | Inline JSON payload |
|
|
186
|
+
| `--header <header>` | Custom header (repeatable, format: `Key: Value`) |
|
|
187
|
+
| `--skip-auth` | Skip HMAC authentication check |
|
|
188
|
+
| `-c, --config <path>` | Config file path |
|
|
189
|
+
|
|
190
|
+
### `hookpipe logs`
|
|
191
|
+
|
|
192
|
+
Query delivery logs.
|
|
193
|
+
|
|
194
|
+
| Flag | Description |
|
|
195
|
+
|------|-------------|
|
|
196
|
+
| `--pipeline <id>` | Filter by pipeline ID |
|
|
197
|
+
| `--status <status>` | Filter by status (success, failed, pending) |
|
|
198
|
+
| `--since <duration>` | Show logs since duration (e.g. `1h`, `7d`) |
|
|
199
|
+
| `--limit <n>` | Max number of entries (default: 50) |
|
|
200
|
+
| `--json` | Output as JSON |
|
|
201
|
+
|
|
202
|
+
### `hookpipe replay <delivery-id>`
|
|
203
|
+
|
|
204
|
+
Re-enqueue a previous delivery for reprocessing.
|
|
205
|
+
|
|
206
|
+
| Flag | Description |
|
|
207
|
+
|------|-------------|
|
|
208
|
+
| `--dry-run` | Show what would be replayed without enqueuing |
|
|
209
|
+
| `-c, --config <path>` | Config file path |
|
|
210
|
+
|
|
211
|
+
## Configuration
|
|
212
|
+
|
|
213
|
+
hookpipe loads configuration from a YAML file, environment variables, and CLI flags.
|
|
214
|
+
|
|
215
|
+
**Precedence** (highest wins): CLI flags > environment variables > YAML file > defaults
|
|
216
|
+
|
|
217
|
+
### Config File
|
|
218
|
+
|
|
219
|
+
By default, hookpipe looks for `hookpipe.yaml` in the working directory. Override with `--config` or `HOOKPIPE_CONFIG`.
|
|
220
|
+
|
|
221
|
+
```yaml
|
|
222
|
+
host: 0.0.0.0
|
|
223
|
+
port: 3000
|
|
224
|
+
|
|
225
|
+
db:
|
|
226
|
+
path: ./data/hookpipe.db
|
|
227
|
+
|
|
228
|
+
log:
|
|
229
|
+
level: info
|
|
230
|
+
|
|
231
|
+
pipelines:
|
|
232
|
+
dir: ./pipelines
|
|
233
|
+
|
|
234
|
+
plugins:
|
|
235
|
+
dir: ./plugins
|
|
236
|
+
|
|
237
|
+
retry:
|
|
238
|
+
maxAttempts: 3
|
|
239
|
+
backoff: exponential
|
|
240
|
+
initialDelayMs: 1000
|
|
241
|
+
maxDelayMs: 300000
|
|
242
|
+
|
|
243
|
+
queue:
|
|
244
|
+
pollIntervalMs: 1000
|
|
245
|
+
concurrency: 5
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Environment Variables
|
|
249
|
+
|
|
250
|
+
| Variable | Maps to | Default |
|
|
251
|
+
|----------|---------|---------|
|
|
252
|
+
| `HOOKPIPE_PORT` | `port` | `3000` |
|
|
253
|
+
| `HOOKPIPE_HOST` | `host` | `0.0.0.0` |
|
|
254
|
+
| `HOOKPIPE_DB_PATH` | `db.path` | `./data/hookpipe.db` |
|
|
255
|
+
| `HOOKPIPE_LOG_LEVEL` | `log.level` | `info` |
|
|
256
|
+
| `HOOKPIPE_PIPELINES_DIR` | `pipelines.dir` | `./pipelines` |
|
|
257
|
+
| `HOOKPIPE_PLUGINS_DIR` | `plugins.dir` | `./plugins` |
|
|
258
|
+
| `HOOKPIPE_CONFIG` | config file path | `hookpipe.yaml` |
|
|
259
|
+
|
|
260
|
+
Pipeline YAML values support `${ENV_VAR}` interpolation for secrets.
|
|
261
|
+
|
|
262
|
+
## Trust Model
|
|
263
|
+
|
|
264
|
+
hookpipe v1.0 has **no sandbox**. Plugins are loaded as plain Node.js modules with full access to the process, filesystem, and network.
|
|
265
|
+
|
|
266
|
+
This is by design for self-hosted environments where you control what code runs on your infrastructure. The tradeoff is simplicity and zero overhead in exchange for requiring trust in your plugins.
|
|
267
|
+
|
|
268
|
+
**Guidelines:**
|
|
269
|
+
|
|
270
|
+
- Only load plugins you wrote or audited
|
|
271
|
+
- Do not accept plugin uploads from untrusted sources
|
|
272
|
+
- Run hookpipe with least-privilege OS permissions
|
|
273
|
+
- Use Docker to limit blast radius if needed
|
|
274
|
+
|
|
275
|
+
v1.1 will add optional isolation for plugins.
|
|
276
|
+
|
|
277
|
+
## Tech Stack
|
|
278
|
+
|
|
279
|
+
| Component | Role |
|
|
280
|
+
|-----------|------|
|
|
281
|
+
| Node.js 22+ | Runtime |
|
|
282
|
+
| Fastify | HTTP server |
|
|
283
|
+
| better-sqlite3 | Delivery queue and log storage |
|
|
284
|
+
| Commander.js | CLI framework |
|
|
285
|
+
| Pino | Structured logging |
|
|
286
|
+
| Handlebars | Body templates |
|
|
287
|
+
| chokidar | Pipeline hot-reload (file watching) |
|
|
288
|
+
|
|
289
|
+
## Roadmap
|
|
290
|
+
|
|
291
|
+
### v1.0 — Core (current)
|
|
292
|
+
- Fastify HTTP server, YAML pipelines, env var interpolation
|
|
293
|
+
- HMAC-SHA256/SHA1/Stripe authentication
|
|
294
|
+
- JS transform + filter plugins (CJS, no sandbox)
|
|
295
|
+
- HTTP destination adapter with Handlebars templates
|
|
296
|
+
- SQLite delivery log, retry with exponential backoff, dead letter queue
|
|
297
|
+
- Pipeline hot-reload via chokidar
|
|
298
|
+
- CLI: serve, test, logs, replay
|
|
299
|
+
- Docker image
|
|
300
|
+
|
|
301
|
+
### v1.1 — Agent Layer
|
|
302
|
+
- REST Admin API (CRUD pipelines, query logs, replay)
|
|
303
|
+
- Agent registration + scoped API keys
|
|
304
|
+
- Agent event queue (subscribe, poll, acknowledge)
|
|
305
|
+
- SSE real-time event stream
|
|
306
|
+
- Agent-writable transforms (runtime upload, sandboxed)
|
|
307
|
+
- Plugin sandbox (isolated-vm)
|
|
308
|
+
- Skills distribution via skills.sh
|
|
309
|
+
|
|
310
|
+
### v1.2 — Polish
|
|
311
|
+
- Destinations: Telegram, Discord, Slack, SMTP, File
|
|
312
|
+
- Prometheus metrics endpoint
|
|
313
|
+
- Rate limiting per pipeline
|
|
314
|
+
- `hookpipe init` scaffolding
|
|
315
|
+
- `hookpipe validate` pipeline linting
|
|
316
|
+
|
|
317
|
+
### v1.3 — Scale
|
|
318
|
+
- Redis/BullMQ queue backend
|
|
319
|
+
- Horizontal scaling (multiple workers)
|
|
320
|
+
- Pipeline chaining
|
|
321
|
+
- TypeScript plugin support
|
|
322
|
+
|
|
323
|
+
## License
|
|
324
|
+
|
|
325
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@azizikri/hookpipe",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Self-hosted webhook relay with transform pipelines",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"webhook",
|
|
7
|
+
"relay",
|
|
8
|
+
"pipeline",
|
|
9
|
+
"transform",
|
|
10
|
+
"fastify",
|
|
11
|
+
"sqlite"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/azizikri/hookpipe#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/azizikri/hookpipe/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/azizikri/hookpipe.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "azizikri",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "src/cli/index.js",
|
|
25
|
+
"bin": {
|
|
26
|
+
"hookpipe": "src/cli/index.js"
|
|
27
|
+
},
|
|
28
|
+
"directories": {
|
|
29
|
+
"test": "tests"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"start": "node src/cli/index.js serve",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"test:integration": "vitest run tests/integration",
|
|
41
|
+
"prepack": "npm test"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"better-sqlite3": "^11.7.0",
|
|
45
|
+
"chokidar": "^4.0.3",
|
|
46
|
+
"commander": "^13.1.0",
|
|
47
|
+
"dotenv": "^16.4.7",
|
|
48
|
+
"fastify": "^5.2.1",
|
|
49
|
+
"handlebars": "^4.7.8",
|
|
50
|
+
"js-yaml": "^4.1.0",
|
|
51
|
+
"pino": "^9.6.0",
|
|
52
|
+
"pino-pretty": "^13.0.0",
|
|
53
|
+
"ulid": "^2.3.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"vitest": "^3.1.3"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=22.0.0"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/auth/hmac.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { verifySignature, computeHmac, timingSafeCompare } from '../utils/crypto.js';
|
|
2
|
+
|
|
3
|
+
const TYPE_MAP = {
|
|
4
|
+
'hmac-sha256': { algorithm: 'sha256', prefix: 'sha256=' },
|
|
5
|
+
'hmac-sha1': { algorithm: 'sha1', prefix: 'sha1=' },
|
|
6
|
+
'stripe': { algorithm: 'sha256', prefix: '' },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse Stripe-Signature header format: t=<timestamp>,v1=<sig1>,v1=<sig2>
|
|
11
|
+
*/
|
|
12
|
+
function parseStripeSignature(headerValue) {
|
|
13
|
+
const parts = headerValue.split(',');
|
|
14
|
+
let timestamp = null;
|
|
15
|
+
const signatures = [];
|
|
16
|
+
for (const part of parts) {
|
|
17
|
+
const [key, value] = part.split('=', 2);
|
|
18
|
+
if (key === 't') timestamp = value;
|
|
19
|
+
else if (key === 'v1') signatures.push(value);
|
|
20
|
+
}
|
|
21
|
+
return { timestamp, signatures };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Verify Stripe webhook signature.
|
|
26
|
+
* Stripe signs `${timestamp}.${rawBody}` with HMAC-SHA256.
|
|
27
|
+
*/
|
|
28
|
+
function verifyStripeAuth(rawBody, headers, authConfig) {
|
|
29
|
+
const { header, secret } = authConfig;
|
|
30
|
+
const headerLower = header.toLowerCase();
|
|
31
|
+
const sigHeader = Object.entries(headers).find(
|
|
32
|
+
([key]) => key.toLowerCase() === headerLower
|
|
33
|
+
)?.[1];
|
|
34
|
+
|
|
35
|
+
if (!sigHeader) {
|
|
36
|
+
return { valid: false, error: 'Missing signature header' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { timestamp, signatures } = parseStripeSignature(sigHeader);
|
|
40
|
+
|
|
41
|
+
if (!timestamp || signatures.length === 0) {
|
|
42
|
+
return { valid: false, error: 'Invalid Stripe signature format' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const signedPayload = `${timestamp}.${rawBody}`;
|
|
46
|
+
const expectedSig = computeHmac('sha256', secret, signedPayload);
|
|
47
|
+
|
|
48
|
+
const isValid = signatures.some((sig) => timingSafeCompare(sig, expectedSig));
|
|
49
|
+
|
|
50
|
+
if (!isValid) {
|
|
51
|
+
return { valid: false, error: 'Invalid signature' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { valid: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Verify webhook authentication via HMAC signature.
|
|
59
|
+
*
|
|
60
|
+
* @param {string|Buffer} rawBody - The raw request body
|
|
61
|
+
* @param {object} headers - Request headers (keys may be any case)
|
|
62
|
+
* @param {object|null|undefined} authConfig - Auth configuration from pipeline YAML
|
|
63
|
+
* @returns {{ valid: boolean, error?: string }}
|
|
64
|
+
*/
|
|
65
|
+
export function verifyWebhookAuth(rawBody, headers, authConfig) {
|
|
66
|
+
if (!authConfig) {
|
|
67
|
+
return { valid: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { type, header, secret } = authConfig;
|
|
71
|
+
|
|
72
|
+
if (!TYPE_MAP[type]) {
|
|
73
|
+
return { valid: false, error: `Unknown auth type: ${type}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Stripe has its own verification flow
|
|
77
|
+
if (type === 'stripe') {
|
|
78
|
+
return verifyStripeAuth(rawBody, headers, authConfig);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { algorithm, prefix } = TYPE_MAP[type];
|
|
82
|
+
|
|
83
|
+
// Case-insensitive header lookup
|
|
84
|
+
const headerLower = header.toLowerCase();
|
|
85
|
+
const signature = Object.entries(headers).find(
|
|
86
|
+
([key]) => key.toLowerCase() === headerLower
|
|
87
|
+
)?.[1];
|
|
88
|
+
|
|
89
|
+
if (!signature) {
|
|
90
|
+
return { valid: false, error: 'Missing signature header' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const isValid = verifySignature({ algorithm, secret, payload: rawBody, signature, prefix });
|
|
94
|
+
|
|
95
|
+
if (!isValid) {
|
|
96
|
+
return { valid: false, error: 'Invalid signature' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { valid: true };
|
|
100
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import dotenv from 'dotenv';
|
|
6
|
+
import { serveCommand } from './serve.js';
|
|
7
|
+
import { replayCommand } from './replay.js';
|
|
8
|
+
import { testCommand } from './test.js';
|
|
9
|
+
import { logsCommand } from './logs.js';
|
|
10
|
+
dotenv.config();
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const pkg = require('../../package.json');
|
|
14
|
+
|
|
15
|
+
const program = new Command();
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name('hookpipe')
|
|
19
|
+
.description('Webhook ingestion and delivery pipeline')
|
|
20
|
+
.version(pkg.version);
|
|
21
|
+
|
|
22
|
+
// Register commands
|
|
23
|
+
serveCommand(program);
|
|
24
|
+
|
|
25
|
+
testCommand(program);
|
|
26
|
+
|
|
27
|
+
logsCommand(program);
|
|
28
|
+
|
|
29
|
+
replayCommand(program);
|
|
30
|
+
|
|
31
|
+
program.parse();
|
package/src/cli/logs.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { loadConfig } from '../utils/config.js';
|
|
2
|
+
import { initDatabase, closeDatabase } from '../db/index.js';
|
|
3
|
+
import { listDeliveries } from '../db/queries.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a duration string (e.g., '1h', '24h', '7d', '30m') into an ISO date string.
|
|
7
|
+
*/
|
|
8
|
+
export function parseSince(duration) {
|
|
9
|
+
const match = duration.match(/^(\d+)([mhd])$/);
|
|
10
|
+
if (!match) {
|
|
11
|
+
throw new Error(`Invalid duration format: ${duration}. Use Nm, Nh, or Nd.`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const value = parseInt(match[1], 10);
|
|
15
|
+
const unit = match[2];
|
|
16
|
+
|
|
17
|
+
const multipliers = {
|
|
18
|
+
m: 60 * 1000,
|
|
19
|
+
h: 60 * 60 * 1000,
|
|
20
|
+
d: 24 * 60 * 60 * 1000,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ms = value * multipliers[unit];
|
|
24
|
+
return new Date(Date.now() - ms).toISOString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format deliveries as a table string.
|
|
29
|
+
*/
|
|
30
|
+
function formatTable(deliveries) {
|
|
31
|
+
if (deliveries.length === 0) {
|
|
32
|
+
return 'No delivery logs found.';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const header = `${'ID'.padEnd(38)} ${'Pipeline'.padEnd(20)} ${'Status'.padEnd(12)} Received At`;
|
|
36
|
+
const separator = '-'.repeat(header.length);
|
|
37
|
+
const rows = deliveries.map((d) => {
|
|
38
|
+
const id = (d.id || '').padEnd(38);
|
|
39
|
+
const pipeline = (d.pipeline_id || '').padEnd(20);
|
|
40
|
+
const status = (d.status || '').padEnd(12);
|
|
41
|
+
const received = d.received_at || '';
|
|
42
|
+
return `${id} ${pipeline} ${status} ${received}`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return [header, separator, ...rows].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register the 'logs' command on the Commander program.
|
|
50
|
+
*/
|
|
51
|
+
export function logsCommand(program) {
|
|
52
|
+
program
|
|
53
|
+
.command('logs')
|
|
54
|
+
.description('Query delivery logs')
|
|
55
|
+
.option('-p, --pipeline <id>', 'Filter by pipeline ID')
|
|
56
|
+
.option('-s, --status <status>', 'Filter by status (pending/delivered/failed/dead_letter)')
|
|
57
|
+
.option('--since <duration>', "Show logs since duration (e.g., '1h', '24h', '7d', '30m')")
|
|
58
|
+
.option('-n, --limit <number>', 'Max results', '50')
|
|
59
|
+
.option('--offset <number>', 'Pagination offset', '0')
|
|
60
|
+
.option('-c, --config <path>', 'Config file path')
|
|
61
|
+
.option('--json', 'Output as JSON')
|
|
62
|
+
.action(async (opts) => {
|
|
63
|
+
const config = loadConfig(opts.config ? { configPath: opts.config } : {});
|
|
64
|
+
const db = initDatabase(config.db.path);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const filters = {
|
|
68
|
+
pipelineId: opts.pipeline,
|
|
69
|
+
status: opts.status,
|
|
70
|
+
limit: parseInt(opts.limit, 10),
|
|
71
|
+
offset: parseInt(opts.offset, 10),
|
|
72
|
+
since: opts.since ? parseSince(opts.since) : undefined,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const deliveries = listDeliveries(db, filters);
|
|
76
|
+
|
|
77
|
+
if (opts.json) {
|
|
78
|
+
console.log(JSON.stringify(deliveries, null, 2));
|
|
79
|
+
} else {
|
|
80
|
+
console.log(formatTable(deliveries));
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
closeDatabase(db);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { loadConfig } from '../utils/config.js';
|
|
2
|
+
import { initDatabase, closeDatabase } from '../db/index.js';
|
|
3
|
+
import { getDelivery } from '../db/queries.js';
|
|
4
|
+
import { SqliteQueue } from '../queue/sqlite-queue.js';
|
|
5
|
+
import { PipelineLoader } from '../pipeline-loader.js';
|
|
6
|
+
|
|
7
|
+
export function replayCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('replay')
|
|
10
|
+
.description('Replay a webhook delivery')
|
|
11
|
+
.argument('<delivery-id>', 'Delivery ID to replay')
|
|
12
|
+
.option('--dry-run', 'Show what would be replayed without enqueuing')
|
|
13
|
+
.option('-c, --config <path>', 'Config file path')
|
|
14
|
+
.action(async (deliveryId, opts) => {
|
|
15
|
+
const overrides = {};
|
|
16
|
+
if (opts.config) overrides.configPath = opts.config;
|
|
17
|
+
|
|
18
|
+
const config = loadConfig(overrides);
|
|
19
|
+
const db = initDatabase(config.db.path);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const delivery = getDelivery(db, deliveryId);
|
|
23
|
+
|
|
24
|
+
if (!delivery) {
|
|
25
|
+
console.error(`Delivery not found: ${deliveryId}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pipelineLoader = new PipelineLoader(config.pipelines.dir);
|
|
30
|
+
await pipelineLoader.loadAll();
|
|
31
|
+
|
|
32
|
+
const pipeline = pipelineLoader.get(delivery.pipeline_id);
|
|
33
|
+
const destinations = pipeline?.destinations || [];
|
|
34
|
+
|
|
35
|
+
if (opts.dryRun) {
|
|
36
|
+
console.log(`Delivery: ${deliveryId}`);
|
|
37
|
+
console.log(`Pipeline: ${delivery.pipeline_id}`);
|
|
38
|
+
console.log(`Status: ${delivery.status}`);
|
|
39
|
+
console.log(`Payload: ${JSON.stringify(delivery.payload).slice(0, 200)}`);
|
|
40
|
+
console.log(`Would re-enqueue to destinations: [${destinations.map((d) => d.id).join(', ')}]`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const queue = new SqliteQueue(db, config);
|
|
45
|
+
const headers = delivery.headers || {};
|
|
46
|
+
const payload = delivery.payload;
|
|
47
|
+
|
|
48
|
+
for (const dest of destinations) {
|
|
49
|
+
await queue.enqueue({
|
|
50
|
+
deliveryId,
|
|
51
|
+
destinationId: dest.id,
|
|
52
|
+
pipelineId: delivery.pipeline_id,
|
|
53
|
+
payload,
|
|
54
|
+
headers,
|
|
55
|
+
maxAttempts: pipeline?.retry?.maxAttempts ?? pipeline?.retry?.max_attempts ?? 3,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(`Replayed delivery ${deliveryId} to ${destinations.length} destinations`);
|
|
60
|
+
} finally {
|
|
61
|
+
closeDatabase(db);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|