wolverine-ai 1.6.1 → 1.7.1
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/README.md +74 -27
- package/package.json +1 -1
- package/src/brain/brain.js +7 -3
- package/src/index.js +3 -1
- package/src/skills/sql.js +179 -12
package/README.md
CHANGED
|
@@ -185,17 +185,19 @@ Most production bugs don't crash the process — Fastify/Express catch them and
|
|
|
185
185
|
```
|
|
186
186
|
Route returns 500 (process still alive)
|
|
187
187
|
→ Error hook reports to parent via IPC (auto-injected, zero user code changes)
|
|
188
|
-
→ ErrorMonitor tracks
|
|
189
|
-
→
|
|
188
|
+
→ ErrorMonitor tracks errors per normalized route (/api/users/:id)
|
|
189
|
+
→ Single error triggers heal pipeline immediately (configurable threshold)
|
|
190
190
|
→ Fix applied → server restarted → route prober verifies fix
|
|
191
191
|
```
|
|
192
192
|
|
|
193
193
|
| Setting | Default | Env Variable |
|
|
194
194
|
|---------|---------|-------------|
|
|
195
|
-
| Failure threshold |
|
|
195
|
+
| Failure threshold | 1 | `WOLVERINE_ERROR_THRESHOLD` |
|
|
196
196
|
| Time window | 30s | `WOLVERINE_ERROR_WINDOW_MS` |
|
|
197
197
|
| Cooldown per route | 60s | `WOLVERINE_ERROR_COOLDOWN_MS` |
|
|
198
198
|
|
|
199
|
+
Routes are auto-normalized: `/api/users/123` and `/api/users/456` aggregate as `/api/users/:id`.
|
|
200
|
+
|
|
199
201
|
The error hook auto-patches Fastify and Express via `--require` preload. No middleware, no code changes to your server.
|
|
200
202
|
|
|
201
203
|
---
|
|
@@ -315,7 +317,7 @@ Reasoning models (`o-series`, `gpt-5-nano`) automatically get 4x token limits to
|
|
|
315
317
|
| **Admin Auth** | Dashboard requires key + IP allowlist. Localhost always allowed. Remote IPs via `WOLVERINE_ADMIN_IPS` env var or `POST /api/admin/add-ip` at runtime. Timing-safe comparison, lockout after 10 failures |
|
|
316
318
|
| **Rate Limiter** | Sliding window, min gap, hourly budget, exponential backoff on error loops |
|
|
317
319
|
| **MCP Security** | Per-server tool allowlists, arg sanitization, result injection scanning |
|
|
318
|
-
| **SQL Skill** | `sqlGuard()`
|
|
320
|
+
| **SQL Skill** | `sqlGuard()` blocks 15 injection pattern families; `idempotencyGuard()` prevents double-fire in cluster mode |
|
|
319
321
|
|
|
320
322
|
---
|
|
321
323
|
|
|
@@ -354,17 +356,35 @@ The `📊 Analytics` dashboard panel shows memory/CPU charts, route health statu
|
|
|
354
356
|
|
|
355
357
|
---
|
|
356
358
|
|
|
357
|
-
##
|
|
359
|
+
## Cluster Mode
|
|
358
360
|
|
|
359
|
-
|
|
361
|
+
The server handles its own clustering. Wolverine is the single process manager — it spawns your server, which forks workers internally.
|
|
360
362
|
|
|
361
363
|
```bash
|
|
362
|
-
|
|
363
|
-
wolverine server/index.js
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
# Enable cluster mode
|
|
365
|
+
WOLVERINE_CLUSTER=true wolverine server/index.js
|
|
366
|
+
|
|
367
|
+
# System info (cores, RAM, recommended workers)
|
|
368
|
+
wolverine --info
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**How it works:**
|
|
372
|
+
```
|
|
373
|
+
Wolverine (single process manager)
|
|
374
|
+
└── spawns server/index.js
|
|
375
|
+
├── WOLVERINE_CLUSTER=false → single server (default)
|
|
376
|
+
└── WOLVERINE_CLUSTER=true → master forks N workers
|
|
377
|
+
├── Worker 1 (port 3000, reusePort)
|
|
378
|
+
├── Worker 2 (port 3000, reusePort)
|
|
379
|
+
└── Worker N (port 3000, reusePort)
|
|
366
380
|
```
|
|
367
381
|
|
|
382
|
+
- **`WOLVERINE_RECOMMENDED_WORKERS`** auto-set based on CPU cores/RAM
|
|
383
|
+
- Workers share port 3000 via `reusePort` — OS handles load balancing
|
|
384
|
+
- Dead workers auto-respawn by the master process
|
|
385
|
+
- Wolverine kills the **entire process tree** on restart (no orphaned workers)
|
|
386
|
+
- **Idempotency protection** prevents double-fire across workers (see below)
|
|
387
|
+
|
|
368
388
|
**System detection:**
|
|
369
389
|
- CPU cores, model, speed
|
|
370
390
|
- Total/free RAM, disk space
|
|
@@ -372,18 +392,6 @@ wolverine --info # show system capabilities
|
|
|
372
392
|
- Container environment (Docker, Kubernetes)
|
|
373
393
|
- Cloud provider (AWS, GCP, Azure, Railway, Fly, Render, Heroku)
|
|
374
394
|
|
|
375
|
-
**Scaling rules:**
|
|
376
|
-
|
|
377
|
-
| Cores | Workers |
|
|
378
|
-
|-------|---------|
|
|
379
|
-
| 1 | 1 (no clustering) |
|
|
380
|
-
| 2 | 2 |
|
|
381
|
-
| 3-4 | cores - 1 |
|
|
382
|
-
| 5-8 | cores - 1, cap 6 |
|
|
383
|
-
| 9+ | cores / 2, cap 16 |
|
|
384
|
-
|
|
385
|
-
Workers auto-respawn on crash with exponential backoff (1s → 30s). Max 5 restarts per worker.
|
|
386
|
-
|
|
387
395
|
---
|
|
388
396
|
|
|
389
397
|
## Configuration
|
|
@@ -412,8 +420,8 @@ Startup:
|
|
|
412
420
|
- Auto-registers on first run, retries every 60s until platform responds
|
|
413
421
|
- Saves key to `.wolverine/platform-key` (survives restarts)
|
|
414
422
|
- Sends one ~2KB JSON POST every 60 seconds (5s timeout, non-blocking)
|
|
415
|
-
- Payload
|
|
416
|
-
- Platform
|
|
423
|
+
- Payload: `instanceId`, `server`, `process`, `routes`, `repairs`, `usage` (tokens/cost/calls + `byCategory` + `byModel` + `byTool`), `brain`, `backups`
|
|
424
|
+
- Platform aggregates across all servers: total tokens/cost by category, model, tool
|
|
417
425
|
- Secrets redacted before sending
|
|
418
426
|
- Offline-resilient: queues up to 1440 heartbeats locally, drains on reconnect
|
|
419
427
|
|
|
@@ -422,7 +430,7 @@ Startup:
|
|
|
422
430
|
**Override:** `WOLVERINE_PLATFORM_URL=https://your-own-platform.com`
|
|
423
431
|
**Opt out:** `WOLVERINE_TELEMETRY=false`
|
|
424
432
|
|
|
425
|
-
|
|
433
|
+
Telemetry payload includes: `instanceId`, `server`, `process`, `routes`, `repairs`, `usage` (by category/model/tool), `brain`, `backups`.
|
|
426
434
|
|
|
427
435
|
---
|
|
428
436
|
|
|
@@ -478,14 +486,53 @@ Full `server/` directory snapshots with lifecycle management:
|
|
|
478
486
|
Auto-discovered from `src/skills/`. Each skill exports metadata for the registry:
|
|
479
487
|
|
|
480
488
|
### SQL Skill (`src/skills/sql.js`)
|
|
481
|
-
- **sqlGuard()** — Express middleware blocking SQL injection (UNION, stacked queries, tautologies, timing attacks,
|
|
482
|
-
- **SafeDB** —
|
|
489
|
+
- **sqlGuard()** — Fastify/Express middleware blocking SQL injection (UNION, stacked queries, tautologies, timing attacks, 15 pattern families)
|
|
490
|
+
- **SafeDB** — Cluster-safe database with split read/write connections, FIFO write queue, WAL mode
|
|
491
|
+
- **idempotencyGuard()** — Prevents double-fire of write requests in cluster mode (see below)
|
|
492
|
+
- **db.idempotent(key, fn)** — Database-level dedup for critical writes (payments, orders)
|
|
483
493
|
- Auto-injected into agent prompts when building database features
|
|
484
494
|
|
|
485
495
|
Add new skills by creating a file in `src/skills/` with `SKILL_NAME`, `SKILL_DESCRIPTION`, `SKILL_KEYWORDS`, `SKILL_USAGE` exports.
|
|
486
496
|
|
|
487
497
|
---
|
|
488
498
|
|
|
499
|
+
## Idempotency (Double-Fire Protection)
|
|
500
|
+
|
|
501
|
+
In cluster mode, a retry or duplicate request can land on a different worker and execute twice. Two layers prevent this:
|
|
502
|
+
|
|
503
|
+
### Layer 1: HTTP Middleware
|
|
504
|
+
|
|
505
|
+
```javascript
|
|
506
|
+
const { idempotencyGuard, idempotencyAfterHook } = require("wolverine-ai");
|
|
507
|
+
|
|
508
|
+
fastify.addHook("preHandler", idempotencyGuard({ db, logger }));
|
|
509
|
+
fastify.addHook("onSend", idempotencyAfterHook(db));
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
- Client sends `X-Idempotency-Key: order-abc-123` header
|
|
513
|
+
- Without header: auto-generates key from `sha256(method + url + body)`
|
|
514
|
+
- First request: executes handler, caches response in shared SQLite table
|
|
515
|
+
- Duplicate: returns cached response with `X-Idempotency-Cached: true` header
|
|
516
|
+
- Safe methods (GET/HEAD/OPTIONS) always pass through
|
|
517
|
+
- Keys expire after 24h (configurable)
|
|
518
|
+
|
|
519
|
+
### Layer 2: Database-Level
|
|
520
|
+
|
|
521
|
+
```javascript
|
|
522
|
+
const result = await db.idempotent("charge-abc-123", (tx) => {
|
|
523
|
+
tx.run("INSERT INTO charges (id, amount) VALUES (?, ?)", ["abc-123", 99.99]);
|
|
524
|
+
tx.run("UPDATE balance SET amount = amount - ? WHERE user_id = ?", [99.99, 1]);
|
|
525
|
+
return { charged: true };
|
|
526
|
+
});
|
|
527
|
+
// result.executed = true (first time) or false (duplicate)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
- Wraps `fn` in a transaction with idempotency key check
|
|
531
|
+
- All workers share the `_idempotency` table via WAL mode — globally consistent
|
|
532
|
+
- Auto-created on `db.connect()`, pruned via `db.pruneIdempotency()`
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
489
536
|
## MCP Integration
|
|
490
537
|
|
|
491
538
|
Connect external tools via [Model Context Protocol](https://modelcontextprotocol.io):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/brain/brain.js
CHANGED
|
@@ -96,7 +96,7 @@ const SEED_DOCS = [
|
|
|
96
96
|
metadata: { topic: "skill-sql-patterns" },
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
|
-
text: "Database best practices: SafeDB uses split connections — separate read connection (concurrent, never waits) and write connection (single writer, FIFO queue). Write queue drains synchronously in one microtask, zero delays. WAL mode means readers never block writers. Each write is microseconds. db.transaction(fn) queues as single atomic unit. No busy_timeout, no blocking, no IPC. Reads: db.get(), db.all() are instant. Writes: db.run(), db.exec() go through queue.",
|
|
99
|
+
text: "Database best practices: SafeDB uses split connections — separate read connection (concurrent, never waits) and write connection (single writer, FIFO queue). Write queue drains synchronously in one microtask, zero delays. WAL mode means readers never block writers. Each write is microseconds. db.transaction(fn) queues as single atomic unit. No busy_timeout, no blocking, no IPC. Reads: db.get(), db.all() are instant. Writes: db.run(), db.exec() go through queue. Idempotent writes: db.idempotent(key, fn, ttlSeconds) executes fn only once per key — prevents double-charge/double-insert when retries or cluster workers duplicate a request. Idempotency keys stored in _idempotency table (auto-created on connect), shared across all workers via WAL mode.",
|
|
100
100
|
metadata: { topic: "skill-sql-best-practices" },
|
|
101
101
|
},
|
|
102
102
|
{
|
|
@@ -120,7 +120,7 @@ const SEED_DOCS = [
|
|
|
120
120
|
metadata: { topic: "process-manager" },
|
|
121
121
|
},
|
|
122
122
|
{
|
|
123
|
-
text: "
|
|
123
|
+
text: "Cluster mode: server handles its own clustering (not wolverine-level). WOLVERINE_CLUSTER=true enables it. Server forks N workers (WOLVERINE_RECOMMENDED_WORKERS set by system detection). Workers share port 3000 via reusePort. Wolverine kills entire process tree on restart (_killProcessTree: taskkill /T on Windows, kill -pgid + pgrep -P on Linux). Idempotency protection prevents double-fire: idempotencyGuard() middleware deduplicates write requests across workers using shared SQLite _idempotency table. Client sends X-Idempotency-Key header, or auto-generated from method+path+body hash. All workers see the same table via WAL mode. SafeDB.idempotent(key, fn) for database-level dedup.",
|
|
124
124
|
metadata: { topic: "clustering" },
|
|
125
125
|
},
|
|
126
126
|
{
|
|
@@ -220,9 +220,13 @@ const SEED_DOCS = [
|
|
|
220
220
|
metadata: { topic: "agent-tools-v2" },
|
|
221
221
|
},
|
|
222
222
|
{
|
|
223
|
-
text: "Server problem categories the agent can fix: CODE BUGS (SyntaxError, TypeError, ReferenceError → edit_file), DEPENDENCIES (Cannot find module → npm install, corrupted node_modules → rm + reinstall), DATABASE (invalid entries → run_db_fix UPDATE, missing table → CREATE TABLE, schema mismatch → ALTER TABLE, constraint violation → fix data or schema), CONFIG (invalid JSON → edit_file, missing env vars → write .env, wrong port → edit config), FILESYSTEM (misplaced files → move_file, missing directories → bash_exec mkdir, wrong permissions → chmod), NETWORK (port conflict → check_port + kill, service down → restart, connection refused → check config), STATE (corrupted cache → delete + restart, stale locks → remove lock file, git conflicts → resolve markers). The agent investigates before fixing — reads files, checks directories, inspects databases, never guesses.",
|
|
223
|
+
text: "Server problem categories the agent can fix: CODE BUGS (SyntaxError, TypeError, ReferenceError → edit_file), DEPENDENCIES (Cannot find module → npm install, corrupted node_modules → rm + reinstall), DATABASE (invalid entries → run_db_fix UPDATE, missing table → CREATE TABLE, schema mismatch → ALTER TABLE, constraint violation → fix data or schema), CONFIG (invalid JSON → edit_file, missing env vars → write .env, wrong port → edit config), FILESYSTEM (misplaced files → move_file, missing directories → bash_exec mkdir, wrong permissions → chmod), NETWORK (port conflict → check_port + kill, service down → restart, connection refused → check config), STATE (corrupted cache → delete + restart, stale locks → remove lock file, git conflicts → resolve markers), IDEMPOTENCY (double-fire → add idempotencyGuard middleware, missing idempotency key → add X-Idempotency-Key header support, duplicate DB entries → add UNIQUE constraint or use db.idempotent()). The agent investigates before fixing — reads files, checks directories, inspects databases, never guesses.",
|
|
224
224
|
metadata: { topic: "server-problems" },
|
|
225
225
|
},
|
|
226
|
+
{
|
|
227
|
+
text: "Idempotency protection: two layers prevent double-fire in cluster mode. Layer 1: idempotencyGuard() Fastify middleware — intercepts POST/PUT/PATCH/DELETE, checks X-Idempotency-Key header (or auto-generates key from method+path+body hash), queries _idempotency table. If key exists and not expired → return cached response with X-Idempotency-Cached:true header, skip handler. If new → pass through, idempotencyAfterHook() stores response. Layer 2: SafeDB.idempotent(key, fn) — database-level dedup. Wraps fn in transaction, checks key, executes only if new. Returns {executed:true/false, result, cached}. Keys expire after TTL (default 24h). All workers share the SQLite _idempotency table via WAL mode — globally consistent. Auto-pruned on connect and via db.pruneIdempotency().",
|
|
228
|
+
metadata: { topic: "idempotency" },
|
|
229
|
+
},
|
|
226
230
|
{
|
|
227
231
|
text: "Heal pipeline no longer requires a file path. When no file is identified from the error (database errors, config problems, port conflicts), the pipeline skips fast path and goes straight to the agent, which uses investigation tools (glob_files, grep_code, list_dir, inspect_db, check_env, check_port) to find the root cause. Agent verification for no-file errors: if agent made changes or ran commands, trust the agent's assessment. For file-based errors, verification uses syntax check + boot probe as before.",
|
|
228
232
|
metadata: { topic: "fileless-heal" },
|
package/src/index.js
CHANGED
|
@@ -33,7 +33,7 @@ const { scanProject } = require("./brain/function-map");
|
|
|
33
33
|
const { detect: detectSystem } = require("./core/system-info");
|
|
34
34
|
const { ClusterManager } = require("./core/cluster-manager");
|
|
35
35
|
const { loadConfig, getConfig } = require("./core/config");
|
|
36
|
-
const { sqlGuard, SafeDB, scanForInjection } = require("./skills/sql");
|
|
36
|
+
const { sqlGuard, SafeDB, scanForInjection, idempotencyGuard, idempotencyAfterHook } = require("./skills/sql");
|
|
37
37
|
|
|
38
38
|
module.exports = {
|
|
39
39
|
// Core
|
|
@@ -93,4 +93,6 @@ module.exports = {
|
|
|
93
93
|
sqlGuard,
|
|
94
94
|
SafeDB,
|
|
95
95
|
scanForInjection,
|
|
96
|
+
idempotencyGuard,
|
|
97
|
+
idempotencyAfterHook,
|
|
96
98
|
};
|
package/src/skills/sql.js
CHANGED
|
@@ -201,6 +201,18 @@ class SafeDB {
|
|
|
201
201
|
this._writer.pragma("foreign_keys = ON");
|
|
202
202
|
this._writer.pragma("synchronous = NORMAL");
|
|
203
203
|
|
|
204
|
+
// Idempotency table — prevents double-execution of writes in cluster mode
|
|
205
|
+
this._writer.exec(`
|
|
206
|
+
CREATE TABLE IF NOT EXISTS _idempotency (
|
|
207
|
+
key TEXT PRIMARY KEY,
|
|
208
|
+
result TEXT,
|
|
209
|
+
created_at INTEGER DEFAULT (strftime('%s','now')),
|
|
210
|
+
expires_at INTEGER
|
|
211
|
+
)
|
|
212
|
+
`);
|
|
213
|
+
// Clean expired keys on connect
|
|
214
|
+
this._writer.exec(`DELETE FROM _idempotency WHERE expires_at < strftime('%s','now')`);
|
|
215
|
+
|
|
204
216
|
} catch (err) {
|
|
205
217
|
if (err.code === "MODULE_NOT_FOUND") {
|
|
206
218
|
throw new Error("Install better-sqlite3: npm install better-sqlite3");
|
|
@@ -216,6 +228,49 @@ class SafeDB {
|
|
|
216
228
|
process.on("exit", () => this.close());
|
|
217
229
|
}
|
|
218
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Idempotent write — execute fn only if this key hasn't been seen before.
|
|
233
|
+
* In cluster mode, prevents the same request from double-firing across workers.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} key — unique idempotency key (e.g. from X-Idempotency-Key header)
|
|
236
|
+
* @param {Function} fn — function that performs the write, receives writerProxy
|
|
237
|
+
* @param {number} ttlSeconds — how long to remember this key (default: 86400 = 24h)
|
|
238
|
+
* @returns {{ executed: boolean, result: any }} — executed=false if key was already seen
|
|
239
|
+
*/
|
|
240
|
+
idempotent(key, fn, ttlSeconds = 86400) {
|
|
241
|
+
this._assertOpen();
|
|
242
|
+
return this._enqueueWrite(() => {
|
|
243
|
+
// Check if key already executed
|
|
244
|
+
const existing = this._writer.prepare("SELECT result FROM _idempotency WHERE key = ? AND expires_at > strftime('%s','now')").get(key);
|
|
245
|
+
if (existing) {
|
|
246
|
+
return { executed: false, result: JSON.parse(existing.result || "null"), cached: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Execute the write
|
|
250
|
+
const txn = this._writer.transaction(() => {
|
|
251
|
+
const result = fn(this._writerProxy());
|
|
252
|
+
// Store the key so duplicates are rejected
|
|
253
|
+
this._writer.prepare(
|
|
254
|
+
"INSERT OR REPLACE INTO _idempotency (key, result, expires_at) VALUES (?, ?, strftime('%s','now') + ?)"
|
|
255
|
+
).run(key, JSON.stringify(result ?? null), ttlSeconds);
|
|
256
|
+
return result;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const result = txn();
|
|
260
|
+
return { executed: true, result, cached: false };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Clean up expired idempotency keys. Call periodically (e.g., every hour).
|
|
266
|
+
*/
|
|
267
|
+
pruneIdempotency() {
|
|
268
|
+
this._assertOpen();
|
|
269
|
+
return this._enqueueWrite(() => {
|
|
270
|
+
return this._writer.prepare("DELETE FROM _idempotency WHERE expires_at < strftime('%s','now')").run();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
219
274
|
/**
|
|
220
275
|
* Write query (INSERT, UPDATE, DELETE, CREATE).
|
|
221
276
|
* Queued and executed in order. Returns a promise that resolves with the result.
|
|
@@ -327,27 +382,137 @@ class SafeDB {
|
|
|
327
382
|
}
|
|
328
383
|
}
|
|
329
384
|
|
|
385
|
+
// ── Idempotency Middleware ──────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Request idempotency middleware — prevents double-fire in cluster mode.
|
|
389
|
+
*
|
|
390
|
+
* How it works:
|
|
391
|
+
* 1. Client sends write request (POST/PUT/PATCH/DELETE) with X-Idempotency-Key header
|
|
392
|
+
* 2. Middleware checks if this key was already processed
|
|
393
|
+
* 3. If yes: return cached response (no re-execution)
|
|
394
|
+
* 4. If no: execute handler, cache response, return result
|
|
395
|
+
*
|
|
396
|
+
* Without the header, mutating requests get an auto-generated key based on
|
|
397
|
+
* method + path + body hash. This means identical retries are deduplicated
|
|
398
|
+
* even without client cooperation.
|
|
399
|
+
*
|
|
400
|
+
* In cluster mode (reusePort), a retry can land on a different worker.
|
|
401
|
+
* Since all workers share the same SQLite database (WAL mode), the
|
|
402
|
+
* idempotency table is visible to all workers instantly.
|
|
403
|
+
*
|
|
404
|
+
* Safe methods (GET, HEAD, OPTIONS) are always passed through — they're
|
|
405
|
+
* inherently idempotent.
|
|
406
|
+
*
|
|
407
|
+
* @param {object} options
|
|
408
|
+
* @param {SafeDB} options.db — SafeDB instance (must be connected)
|
|
409
|
+
* @param {number} options.ttlSeconds — how long to cache responses (default: 86400)
|
|
410
|
+
* @param {object} options.logger — wolverine EventLogger (optional)
|
|
411
|
+
*/
|
|
412
|
+
function idempotencyGuard(options = {}) {
|
|
413
|
+
const db = options.db;
|
|
414
|
+
const ttlSeconds = options.ttlSeconds || 86400;
|
|
415
|
+
const logger = options.logger || null;
|
|
416
|
+
const crypto = require("crypto");
|
|
417
|
+
|
|
418
|
+
return async (req, res, next) => {
|
|
419
|
+
// Safe methods are inherently idempotent — pass through
|
|
420
|
+
const method = (req.method || "GET").toUpperCase();
|
|
421
|
+
if (["GET", "HEAD", "OPTIONS"].includes(method)) return next();
|
|
422
|
+
|
|
423
|
+
// Get or generate idempotency key
|
|
424
|
+
let key = req.headers["x-idempotency-key"] || req.headers["idempotency-key"];
|
|
425
|
+
if (!key) {
|
|
426
|
+
// Auto-generate from method + path + body hash
|
|
427
|
+
const bodyStr = typeof req.body === "string" ? req.body : JSON.stringify(req.body || "");
|
|
428
|
+
key = crypto.createHash("sha256").update(`${method}:${req.url}:${bodyStr}`).digest("hex");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!db || !db._writer) return next(); // No DB — can't check, pass through
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
// Check idempotency table directly (read from writer for consistency)
|
|
435
|
+
const existing = db._writer.prepare(
|
|
436
|
+
"SELECT result FROM _idempotency WHERE key = ? AND expires_at > strftime('%s','now')"
|
|
437
|
+
).get(key);
|
|
438
|
+
|
|
439
|
+
if (existing) {
|
|
440
|
+
// Already processed — return cached response
|
|
441
|
+
const cached = JSON.parse(existing.result || "null");
|
|
442
|
+
if (logger) logger.debug("idempotency.hit", `Duplicate request blocked: ${method} ${req.url}`, { key: key.slice(0, 16) });
|
|
443
|
+
|
|
444
|
+
const status = cached?.statusCode || 200;
|
|
445
|
+
const body = cached?.body || cached;
|
|
446
|
+
if (typeof res.code === "function") {
|
|
447
|
+
// Fastify
|
|
448
|
+
res.code(status).header("X-Idempotency-Cached", "true").send(body);
|
|
449
|
+
} else {
|
|
450
|
+
// Express
|
|
451
|
+
res.status(status).set("X-Idempotency-Cached", "true").json(body);
|
|
452
|
+
}
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Not seen — attach key to request for the route handler to use
|
|
457
|
+
req._idempotencyKey = key;
|
|
458
|
+
req._idempotencyTtl = ttlSeconds;
|
|
459
|
+
} catch {
|
|
460
|
+
// DB error — don't block the request, just pass through
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
next();
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* After-response hook — stores the response for future idempotency checks.
|
|
469
|
+
* For Fastify, add as onSend hook. For Express, monkey-patch res.json.
|
|
470
|
+
*
|
|
471
|
+
* @param {SafeDB} db — connected SafeDB instance
|
|
472
|
+
*/
|
|
473
|
+
function idempotencyAfterHook(db) {
|
|
474
|
+
return (req, reply, payload, done) => {
|
|
475
|
+
if (req._idempotencyKey && db && db._writer) {
|
|
476
|
+
try {
|
|
477
|
+
const statusCode = reply.statusCode || 200;
|
|
478
|
+
const result = JSON.stringify({ statusCode, body: typeof payload === "string" ? JSON.parse(payload) : payload });
|
|
479
|
+
db._writer.prepare(
|
|
480
|
+
"INSERT OR IGNORE INTO _idempotency (key, result, expires_at) VALUES (?, ?, strftime('%s','now') + ?)"
|
|
481
|
+
).run(req._idempotencyKey, result, req._idempotencyTtl || 86400);
|
|
482
|
+
} catch { /* non-fatal */ }
|
|
483
|
+
}
|
|
484
|
+
done();
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
330
488
|
// ── Skill Metadata (for SkillRegistry discovery) ──
|
|
331
489
|
|
|
332
490
|
const SKILL_NAME = "sql";
|
|
333
|
-
const SKILL_DESCRIPTION = "SQL database interface with injection prevention. Provides sqlGuard() middleware to block SQL injection
|
|
334
|
-
const SKILL_KEYWORDS = ["sql", "database", "db", "query", "injection", "sqlite", "postgres", "mysql", "select", "insert", "update", "delete", "table", "schema", "migration", "parameterized"];
|
|
335
|
-
const SKILL_USAGE = `// Protect
|
|
336
|
-
const { sqlGuard } = require("../src/skills/sql");
|
|
337
|
-
app.use(sqlGuard({ logger: wolverineLogger }));
|
|
338
|
-
|
|
339
|
-
// Cluster-safe database (each worker gets its own connection)
|
|
340
|
-
const { SafeDB } = require("../src/skills/sql");
|
|
491
|
+
const SKILL_DESCRIPTION = "SQL database interface with injection prevention + idempotency. Provides sqlGuard() middleware to block SQL injection, idempotencyGuard() middleware to prevent double-fire in cluster mode, and SafeDB class for parameterized-only database queries with built-in idempotency key support.";
|
|
492
|
+
const SKILL_KEYWORDS = ["sql", "database", "db", "query", "injection", "sqlite", "postgres", "mysql", "select", "insert", "update", "delete", "table", "schema", "migration", "parameterized", "idempotent", "idempotency", "duplicate", "double", "cluster", "transaction"];
|
|
493
|
+
const SKILL_USAGE = `// Protect routes from SQL injection + double-fire
|
|
494
|
+
const { sqlGuard, idempotencyGuard, idempotencyAfterHook, SafeDB } = require("../src/skills/sql");
|
|
341
495
|
const db = new SafeDB({ type: "sqlite", path: "./server/data.db" });
|
|
342
|
-
await db.connect();
|
|
496
|
+
await db.connect();
|
|
497
|
+
|
|
498
|
+
// Middleware: injection prevention + idempotency (cluster-safe)
|
|
499
|
+
fastify.addHook("preHandler", sqlGuard({ logger }));
|
|
500
|
+
fastify.addHook("preHandler", idempotencyGuard({ db, logger }));
|
|
501
|
+
fastify.addHook("onSend", idempotencyAfterHook(db));
|
|
343
502
|
|
|
344
|
-
// Reads (concurrent across workers)
|
|
503
|
+
// Reads (concurrent across workers — never waits)
|
|
345
504
|
const users = db.all("SELECT * FROM users WHERE role = ?", ["admin"]);
|
|
346
505
|
|
|
347
|
-
// Writes (serialized — no corruption)
|
|
506
|
+
// Writes (serialized FIFO queue — no corruption)
|
|
348
507
|
db.run("INSERT INTO users (name, role) VALUES (?, ?)", ["Alice", "admin"]);
|
|
349
508
|
|
|
350
|
-
//
|
|
509
|
+
// Idempotent write — prevents double-charge/double-insert in cluster mode
|
|
510
|
+
const result = await db.idempotent("order-abc-123", (tx) => {
|
|
511
|
+
tx.run("INSERT INTO orders (id, total) VALUES (?, ?)", ["abc-123", 99.99]);
|
|
512
|
+
return { orderId: "abc-123" };
|
|
513
|
+
}); // result.executed=true first time, false on retry
|
|
514
|
+
|
|
515
|
+
// Atomic transaction (all-or-nothing)
|
|
351
516
|
db.transaction((tx) => {
|
|
352
517
|
tx.run("INSERT INTO orders (user_id, total) VALUES (?, ?)", [1, 99.99]);
|
|
353
518
|
tx.run("UPDATE users SET order_count = order_count + 1 WHERE id = ?", [1]);
|
|
@@ -364,6 +529,8 @@ module.exports = {
|
|
|
364
529
|
|
|
365
530
|
// Middleware
|
|
366
531
|
sqlGuard,
|
|
532
|
+
idempotencyGuard,
|
|
533
|
+
idempotencyAfterHook,
|
|
367
534
|
scanForInjection,
|
|
368
535
|
deepScan,
|
|
369
536
|
|