taskmarket-feed 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/README.md +92 -0
- package/bun.lock +26 -0
- package/index.ts +1 -0
- package/package.json +28 -0
- package/railway.json +13 -0
- package/src/db.ts +93 -0
- package/src/feed.test.ts +308 -0
- package/src/index.ts +6 -0
- package/src/poller.ts +71 -0
- package/src/rss.ts +81 -0
- package/src/server.ts +114 -0
- package/src/types.ts +39 -0
- package/src/webhooks.ts +81 -0
- package/tsconfig.json +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @taskmarket/feed
|
|
2
|
+
|
|
3
|
+
RSS feeds and webhook subscriptions for [TaskMarket](https://market.daydreams.systems) activity.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **RSS feeds** — subscribe in any RSS reader
|
|
8
|
+
- `GET /rss/tasks/open` — live open bounties
|
|
9
|
+
- `GET /rss/tasks/completed` — recently completed tasks
|
|
10
|
+
- `GET /rss/agents/:id` — tasks for a specific agent
|
|
11
|
+
- **Webhooks** — receive events via HTTP POST with HMAC-SHA256 signatures
|
|
12
|
+
- `POST /subscribe` — register a webhook URL
|
|
13
|
+
- `DELETE /subscribe/:id` — unsubscribe
|
|
14
|
+
- `GET /subscriptions` — list active subscriptions
|
|
15
|
+
- **Polling** — checks TaskMarket API every 60s for new/completed tasks
|
|
16
|
+
- **SQLite** — subscriptions persisted with `bun:sqlite`
|
|
17
|
+
- **Retry logic** — 3 attempts with backoff on delivery failure
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @taskmarket/feed
|
|
23
|
+
taskmarket-feed
|
|
24
|
+
# Listening on port 3000
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or as a library:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createServer, startPoller } from '@taskmarket/feed';
|
|
31
|
+
|
|
32
|
+
startPoller();
|
|
33
|
+
const server = createServer();
|
|
34
|
+
console.log(`Listening on port ${server.port}`);
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Webhook Payload
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"event": "task.new",
|
|
42
|
+
"task": { "id": "0x...", "description": "...", "reward": "5000000", ... },
|
|
43
|
+
"timestamp": "2026-03-03T12:00:00.000Z"
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Verify the signature:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { createHmac } from 'crypto';
|
|
51
|
+
|
|
52
|
+
function verify(secret: string, body: string, header: string): boolean {
|
|
53
|
+
const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
|
54
|
+
return header === expected;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Subscription Filters
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"url": "https://your-server.com/webhook",
|
|
63
|
+
"secret": "your-hmac-secret",
|
|
64
|
+
"filters": {
|
|
65
|
+
"events": ["task.new"],
|
|
66
|
+
"tags": ["python"],
|
|
67
|
+
"minReward": 5
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Environment Variables
|
|
73
|
+
|
|
74
|
+
| Variable | Default | Description |
|
|
75
|
+
|-------------------|---------------------|--------------------------------|
|
|
76
|
+
| `PORT` | `3000` | Server port |
|
|
77
|
+
| `DB_PATH` | `subscriptions.db` | SQLite database path |
|
|
78
|
+
| `POLL_INTERVAL_MS`| `60000` | TaskMarket poll interval (ms) |
|
|
79
|
+
|
|
80
|
+
## Deploy on Railway
|
|
81
|
+
|
|
82
|
+
[](https://railway.app/template/taskmarket-feed)
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
railway login
|
|
86
|
+
railway init
|
|
87
|
+
railway up
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/bun.lock
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "feed",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/bun": "latest",
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"typescript": "^5",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
"packages": {
|
|
16
|
+
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
|
17
|
+
|
|
18
|
+
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
|
19
|
+
|
|
20
|
+
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
|
21
|
+
|
|
22
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
23
|
+
|
|
24
|
+
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
|
25
|
+
}
|
|
26
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
console.log("Hello via Bun!");
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "taskmarket-feed",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "RSS feeds and webhook subscriptions for TaskMarket activity",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"taskmarket-feed": "./src/server.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "bun run src/server.ts",
|
|
13
|
+
"test": "bun test"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"taskmarket",
|
|
17
|
+
"rss",
|
|
18
|
+
"webhooks",
|
|
19
|
+
"feed"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "latest"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"typescript": "^5"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/railway.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://railway.app/railway.schema.json",
|
|
3
|
+
"build": {
|
|
4
|
+
"builder": "NIXPACKS"
|
|
5
|
+
},
|
|
6
|
+
"deploy": {
|
|
7
|
+
"startCommand": "bun run src/server.ts",
|
|
8
|
+
"healthcheckPath": "/health",
|
|
9
|
+
"healthcheckTimeout": 300,
|
|
10
|
+
"restartPolicyType": "ON_FAILURE",
|
|
11
|
+
"restartPolicyMaxRetries": 10
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import type { Subscription, SubscriptionFilters } from './types';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
|
|
5
|
+
let _db: Database | null = null;
|
|
6
|
+
|
|
7
|
+
export function getDb(path = process.env.DB_PATH || 'subscriptions.db'): Database {
|
|
8
|
+
if (!_db) {
|
|
9
|
+
_db = new Database(path);
|
|
10
|
+
_db.run(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
url TEXT NOT NULL,
|
|
14
|
+
secret TEXT NOT NULL,
|
|
15
|
+
filters TEXT NOT NULL DEFAULT '{}',
|
|
16
|
+
created_at TEXT NOT NULL,
|
|
17
|
+
fail_count INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
last_delivery TEXT
|
|
19
|
+
)
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
return _db;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resetDb() { _db = null; }
|
|
26
|
+
|
|
27
|
+
export function createSubscription(
|
|
28
|
+
url: string,
|
|
29
|
+
secret: string,
|
|
30
|
+
filters: SubscriptionFilters = {}
|
|
31
|
+
): Subscription {
|
|
32
|
+
const db = getDb();
|
|
33
|
+
const sub: Subscription = {
|
|
34
|
+
id: randomUUID(),
|
|
35
|
+
url,
|
|
36
|
+
secret,
|
|
37
|
+
filters,
|
|
38
|
+
createdAt: new Date().toISOString(),
|
|
39
|
+
failCount: 0,
|
|
40
|
+
lastDelivery: null,
|
|
41
|
+
};
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO subscriptions (id, url, secret, filters, created_at, fail_count, last_delivery)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?, 0, NULL)`,
|
|
45
|
+
[sub.id, sub.url, sub.secret, JSON.stringify(sub.filters), sub.createdAt]
|
|
46
|
+
);
|
|
47
|
+
return sub;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getSubscription(id: string): Subscription | null {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
const row = db.query('SELECT * FROM subscriptions WHERE id = ?').get(id) as any;
|
|
53
|
+
return row ? rowToSub(row) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getAllSubscriptions(): Subscription[] {
|
|
57
|
+
const db = getDb();
|
|
58
|
+
const rows = db.query('SELECT * FROM subscriptions').all() as any[];
|
|
59
|
+
return rows.map(rowToSub);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function deleteSubscription(id: string): boolean {
|
|
63
|
+
const db = getDb();
|
|
64
|
+
const r = db.run('DELETE FROM subscriptions WHERE id = ?', [id]);
|
|
65
|
+
return r.changes > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function recordDelivery(id: string, success: boolean): void {
|
|
69
|
+
const db = getDb();
|
|
70
|
+
if (success) {
|
|
71
|
+
db.run(
|
|
72
|
+
'UPDATE subscriptions SET fail_count = 0, last_delivery = ? WHERE id = ?',
|
|
73
|
+
[new Date().toISOString(), id]
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
db.run(
|
|
77
|
+
'UPDATE subscriptions SET fail_count = fail_count + 1 WHERE id = ?',
|
|
78
|
+
[id]
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rowToSub(row: any): Subscription {
|
|
84
|
+
return {
|
|
85
|
+
id: row.id,
|
|
86
|
+
url: row.url,
|
|
87
|
+
secret: row.secret,
|
|
88
|
+
filters: JSON.parse(row.filters || '{}'),
|
|
89
|
+
createdAt: row.created_at,
|
|
90
|
+
failCount: row.fail_count,
|
|
91
|
+
lastDelivery: row.last_delivery,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/feed.test.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
2
|
+
import { buildFeed } from './rss';
|
|
3
|
+
import { signPayload, matchesFilter, matchesEvent } from './webhooks';
|
|
4
|
+
import {
|
|
5
|
+
getDb, resetDb,
|
|
6
|
+
createSubscription, getSubscription,
|
|
7
|
+
getAllSubscriptions, deleteSubscription, recordDelivery,
|
|
8
|
+
} from './db';
|
|
9
|
+
import { createServer } from './server';
|
|
10
|
+
import { resetPoller } from './poller';
|
|
11
|
+
import type { Task, SubscriptionFilters } from './types';
|
|
12
|
+
|
|
13
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function makeTask(overrides: Partial<Task> = {}): Task {
|
|
16
|
+
return {
|
|
17
|
+
id: '0xabc123',
|
|
18
|
+
requester: '0xDeAdBeEf',
|
|
19
|
+
description: 'Write a Python script to scrape data and output CSV',
|
|
20
|
+
reward: '5000000', // $5 USDC
|
|
21
|
+
status: 'open',
|
|
22
|
+
mode: 'bounty',
|
|
23
|
+
tags: ['python', 'data'],
|
|
24
|
+
createdAt: '2026-03-01T10:00:00.000Z',
|
|
25
|
+
expiryTime: '2026-03-07T10:00:00.000Z',
|
|
26
|
+
submissionCount: 0,
|
|
27
|
+
worker: null,
|
|
28
|
+
workerAgentId: null,
|
|
29
|
+
requesterAgentId: '12345',
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── 1. RSS: valid XML structure ───────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe('RSS Feed', () => {
|
|
37
|
+
it('produces valid XML declaration', () => {
|
|
38
|
+
const xml = buildFeed('Test', 'Desc', 'http://example.com', []);
|
|
39
|
+
expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('includes rss version 2.0', () => {
|
|
43
|
+
const xml = buildFeed('Test', 'Desc', 'http://example.com', []);
|
|
44
|
+
expect(xml).toContain('version="2.0"');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders channel title and description', () => {
|
|
48
|
+
const xml = buildFeed('My Title', 'My Desc', 'http://example.com', []);
|
|
49
|
+
expect(xml).toContain('<title>My Title</title>');
|
|
50
|
+
expect(xml).toContain('<description>My Desc</description>');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders one item per task', () => {
|
|
54
|
+
const xml = buildFeed('T', 'D', 'http://example.com', [makeTask(), makeTask({ id: '0xdef456' })]);
|
|
55
|
+
expect(xml.match(/<item>/g)?.length).toBe(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('escapes HTML special chars in description', () => {
|
|
59
|
+
const task = makeTask({ description: '<script>alert("xss")</script>' });
|
|
60
|
+
const xml = buildFeed('T', 'D', 'http://example.com', [task]);
|
|
61
|
+
expect(xml).not.toContain('<script>');
|
|
62
|
+
expect(xml).toContain('<script>');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('includes task id as guid', () => {
|
|
66
|
+
const task = makeTask({ id: '0xdeadbeef' });
|
|
67
|
+
const xml = buildFeed('T', 'D', 'http://example.com', [task]);
|
|
68
|
+
expect(xml).toContain('<guid isPermaLink="false">0xdeadbeef</guid>');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('includes reward in item title', () => {
|
|
72
|
+
const task = makeTask({ reward: '10000000' }); // $10
|
|
73
|
+
const xml = buildFeed('T', 'D', 'http://example.com', [task]);
|
|
74
|
+
expect(xml).toContain('$10.00 USDC');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns empty channel with no tasks', () => {
|
|
78
|
+
const xml = buildFeed('T', 'D', 'http://example.com', []);
|
|
79
|
+
expect(xml).toContain('<channel>');
|
|
80
|
+
expect(xml).not.toContain('<item>');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── 2. HMAC generation ────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('HMAC Signing', () => {
|
|
87
|
+
it('produces sha256= prefix', () => {
|
|
88
|
+
const sig = signPayload('mysecret', '{"event":"task.new"}');
|
|
89
|
+
expect(sig).toStartWith('sha256=');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('produces consistent signature for same input', () => {
|
|
93
|
+
const body = '{"event":"task.new","task":{}}';
|
|
94
|
+
expect(signPayload('secret', body)).toBe(signPayload('secret', body));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('produces different signature for different secrets', () => {
|
|
98
|
+
const body = '{"event":"task.new"}';
|
|
99
|
+
expect(signPayload('secret1', body)).not.toBe(signPayload('secret2', body));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('produces different signature for different bodies', () => {
|
|
103
|
+
const sig1 = signPayload('secret', '{"event":"task.new"}');
|
|
104
|
+
const sig2 = signPayload('secret', '{"event":"task.completed"}');
|
|
105
|
+
expect(sig1).not.toBe(sig2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('produces 71-char string (sha256= + 64 hex)', () => {
|
|
109
|
+
const sig = signPayload('secret', 'body');
|
|
110
|
+
expect(sig.length).toBe(71);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── 3. Filter evaluation ──────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe('Subscription Filters', () => {
|
|
117
|
+
it('passes task with no filters set', () => {
|
|
118
|
+
expect(matchesFilter(makeTask(), {})).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('passes task matching required tag', () => {
|
|
122
|
+
expect(matchesFilter(makeTask({ tags: ['python'] }), { tags: ['python'] })).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('fails task missing required tag', () => {
|
|
126
|
+
expect(matchesFilter(makeTask({ tags: ['js'] }), { tags: ['python'] })).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('passes task above minReward', () => {
|
|
130
|
+
expect(matchesFilter(makeTask({ reward: '5000000' }), { minReward: 4 })).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('fails task below minReward', () => {
|
|
134
|
+
expect(matchesFilter(makeTask({ reward: '1000000' }), { minReward: 5 })).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('passes exact minReward boundary', () => {
|
|
138
|
+
expect(matchesFilter(makeTask({ reward: '5000000' }), { minReward: 5 })).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('matchesEvent passes when no event filter', () => {
|
|
142
|
+
expect(matchesEvent('task.new', {})).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('matchesEvent passes matching event', () => {
|
|
146
|
+
expect(matchesEvent('task.new', { events: ['task.new', 'task.completed'] })).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('matchesEvent fails non-matching event', () => {
|
|
150
|
+
expect(matchesEvent('task.accepted', { events: ['task.new'] })).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── 4. Database operations ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
describe('Database', () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
resetDb();
|
|
159
|
+
process.env.DB_PATH = ':memory:';
|
|
160
|
+
});
|
|
161
|
+
afterEach(() => resetDb());
|
|
162
|
+
|
|
163
|
+
it('creates a subscription', () => {
|
|
164
|
+
const sub = createSubscription('https://example.com/hook', 'secret', {});
|
|
165
|
+
expect(sub.id).toBeTruthy();
|
|
166
|
+
expect(sub.url).toBe('https://example.com/hook');
|
|
167
|
+
expect(sub.failCount).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('retrieves created subscription by id', () => {
|
|
171
|
+
const sub = createSubscription('https://example.com/hook', 'sec', {});
|
|
172
|
+
const fetched = getSubscription(sub.id);
|
|
173
|
+
expect(fetched?.id).toBe(sub.id);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('returns null for unknown subscription', () => {
|
|
177
|
+
expect(getSubscription('nonexistent')).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('lists all subscriptions', () => {
|
|
181
|
+
createSubscription('https://a.com', 'sec1', {});
|
|
182
|
+
createSubscription('https://b.com', 'sec2', {});
|
|
183
|
+
expect(getAllSubscriptions().length).toBe(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('deletes a subscription', () => {
|
|
187
|
+
const sub = createSubscription('https://example.com', 'sec', {});
|
|
188
|
+
expect(deleteSubscription(sub.id)).toBe(true);
|
|
189
|
+
expect(getSubscription(sub.id)).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('returns false deleting unknown subscription', () => {
|
|
193
|
+
expect(deleteSubscription('nonexistent')).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('increments fail_count on failed delivery', () => {
|
|
197
|
+
const sub = createSubscription('https://example.com', 'sec', {});
|
|
198
|
+
recordDelivery(sub.id, false);
|
|
199
|
+
expect(getSubscription(sub.id)?.failCount).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('resets fail_count on successful delivery', () => {
|
|
203
|
+
const sub = createSubscription('https://example.com', 'sec', {});
|
|
204
|
+
recordDelivery(sub.id, false);
|
|
205
|
+
recordDelivery(sub.id, false);
|
|
206
|
+
recordDelivery(sub.id, true);
|
|
207
|
+
expect(getSubscription(sub.id)?.failCount).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('stores and retrieves filters correctly', () => {
|
|
211
|
+
const filters: SubscriptionFilters = { events: ['task.new'], minReward: 10 };
|
|
212
|
+
const sub = createSubscription('https://example.com', 'sec', filters);
|
|
213
|
+
const fetched = getSubscription(sub.id);
|
|
214
|
+
expect(fetched?.filters).toEqual(filters);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── 5. HTTP Server endpoints ──────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
describe('HTTP Server', () => {
|
|
221
|
+
let server: ReturnType<typeof createServer>;
|
|
222
|
+
|
|
223
|
+
beforeEach(() => {
|
|
224
|
+
resetDb();
|
|
225
|
+
resetPoller();
|
|
226
|
+
process.env.DB_PATH = ':memory:';
|
|
227
|
+
process.env.PORT = '0'; // random port
|
|
228
|
+
server = createServer();
|
|
229
|
+
});
|
|
230
|
+
afterEach(() => { server.stop(true); resetDb(); });
|
|
231
|
+
|
|
232
|
+
async function req(path: string, opts?: RequestInit) {
|
|
233
|
+
return fetch(`http://localhost:${server.port}${path}`, opts);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
it('GET /health returns ok', async () => {
|
|
237
|
+
const res = await req('/health');
|
|
238
|
+
const data = await res.json() as any;
|
|
239
|
+
expect(res.status).toBe(200);
|
|
240
|
+
expect(data.ok).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('POST /subscribe creates subscription', async () => {
|
|
244
|
+
const res = await req('/subscribe', {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
247
|
+
body: JSON.stringify({ url: 'https://example.com/hook', secret: 'sec' }),
|
|
248
|
+
});
|
|
249
|
+
expect(res.status).toBe(201);
|
|
250
|
+
const data = await res.json() as any;
|
|
251
|
+
expect(data.ok).toBe(true);
|
|
252
|
+
expect(data.id).toBeTruthy();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('POST /subscribe rejects missing url', async () => {
|
|
256
|
+
const res = await req('/subscribe', {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: { 'Content-Type': 'application/json' },
|
|
259
|
+
body: JSON.stringify({ secret: 'sec' }),
|
|
260
|
+
});
|
|
261
|
+
expect(res.status).toBe(400);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('POST /subscribe rejects invalid url', async () => {
|
|
265
|
+
const res = await req('/subscribe', {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: { 'Content-Type': 'application/json' },
|
|
268
|
+
body: JSON.stringify({ url: 'not-a-url', secret: 'sec' }),
|
|
269
|
+
});
|
|
270
|
+
expect(res.status).toBe(400);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('GET /subscriptions returns list', async () => {
|
|
274
|
+
await req('/subscribe', {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: { 'Content-Type': 'application/json' },
|
|
277
|
+
body: JSON.stringify({ url: 'https://example.com/hook', secret: 'sec' }),
|
|
278
|
+
});
|
|
279
|
+
const res = await req('/subscriptions');
|
|
280
|
+
const data = await res.json() as any;
|
|
281
|
+
expect(data.subscriptions.length).toBe(1);
|
|
282
|
+
expect(data.subscriptions[0].url).toBe('https://example.com/hook');
|
|
283
|
+
expect(data.subscriptions[0].secret).toBeUndefined(); // never expose secret
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('DELETE /subscribe/:id removes subscription', async () => {
|
|
287
|
+
const create = await req('/subscribe', {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/json' },
|
|
290
|
+
body: JSON.stringify({ url: 'https://example.com/hook', secret: 'sec' }),
|
|
291
|
+
});
|
|
292
|
+
const { id } = await create.json() as any;
|
|
293
|
+
const del = await req(`/subscribe/${id}`, { method: 'DELETE' });
|
|
294
|
+
expect(del.status).toBe(200);
|
|
295
|
+
const list = await (await req('/subscriptions')).json() as any;
|
|
296
|
+
expect(list.subscriptions.length).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('DELETE /subscribe/:id returns 404 for unknown id', async () => {
|
|
300
|
+
const res = await req('/subscribe/nonexistent', { method: 'DELETE' });
|
|
301
|
+
expect(res.status).toBe(404);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('unknown routes return 404', async () => {
|
|
305
|
+
const res = await req('/notaroute');
|
|
306
|
+
expect(res.status).toBe(404);
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { buildFeed, fetchTasks, fetchAgentTasks } from './rss';
|
|
2
|
+
export { signPayload, dispatch, matchesFilter, matchesEvent } from './webhooks';
|
|
3
|
+
export { createSubscription, getAllSubscriptions, deleteSubscription, recordDelivery, getDb } from './db';
|
|
4
|
+
export { startPoller, stopPoller, poll, resetPoller } from './poller';
|
|
5
|
+
export { createServer } from './server';
|
|
6
|
+
export type { Task, Subscription, SubscriptionFilters, WebhookPayload, EventType } from './types';
|
package/src/poller.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { dispatch } from './webhooks';
|
|
2
|
+
import { fetchTasks } from './rss';
|
|
3
|
+
import type { Task } from './types';
|
|
4
|
+
|
|
5
|
+
const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL_MS || '60000', 10);
|
|
6
|
+
|
|
7
|
+
let knownOpen = new Map<string, Task>();
|
|
8
|
+
let knownCompleted = new Set<string>();
|
|
9
|
+
let pollTimer: Timer | null = null;
|
|
10
|
+
|
|
11
|
+
export async function poll(): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const [open, completed] = await Promise.all([
|
|
14
|
+
fetchTasks('open'),
|
|
15
|
+
fetchTasks('completed'),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
// Detect new open tasks
|
|
19
|
+
for (const task of open) {
|
|
20
|
+
if (!knownOpen.has(task.id)) {
|
|
21
|
+
knownOpen.set(task.id, task);
|
|
22
|
+
await dispatch('task.new', task);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Detect newly completed tasks
|
|
27
|
+
for (const task of completed) {
|
|
28
|
+
if (!knownCompleted.has(task.id)) {
|
|
29
|
+
knownCompleted.add(task.id);
|
|
30
|
+
await dispatch('task.completed', task);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Detect tasks that moved from open → accepted
|
|
35
|
+
for (const [id, task] of knownOpen) {
|
|
36
|
+
const stillOpen = open.find(t => t.id === id);
|
|
37
|
+
if (!stillOpen && task.status !== 'completed') {
|
|
38
|
+
knownOpen.delete(id);
|
|
39
|
+
// Re-fetch to get current status
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`https://api-market.daydreams.systems/api/tasks/${id}`);
|
|
42
|
+
const data = await res.json() as any;
|
|
43
|
+
const updated: Task = data.data || data;
|
|
44
|
+
if (updated.status === 'accepted') {
|
|
45
|
+
await dispatch('task.accepted', updated);
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.error('[poller] error:', e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function startPoller(): void {
|
|
56
|
+
poll(); // immediate first run
|
|
57
|
+
pollTimer = setInterval(poll, POLL_INTERVAL);
|
|
58
|
+
console.log(`[poller] polling every ${POLL_INTERVAL / 1000}s`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function stopPoller(): void {
|
|
62
|
+
if (pollTimer) {
|
|
63
|
+
clearInterval(pollTimer);
|
|
64
|
+
pollTimer = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resetPoller(): void {
|
|
69
|
+
knownOpen = new Map();
|
|
70
|
+
knownCompleted = new Set();
|
|
71
|
+
}
|
package/src/rss.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Task } from './types';
|
|
2
|
+
|
|
3
|
+
const API_BASE = 'https://api-market.daydreams.systems';
|
|
4
|
+
|
|
5
|
+
function esc(s: string): string {
|
|
6
|
+
return s
|
|
7
|
+
.replace(/&/g, '&')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"')
|
|
11
|
+
.replace(/'/g, ''');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rewardUsdc(task: Task): string {
|
|
15
|
+
return (parseInt(task.reward, 10) / 1_000_000).toFixed(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function taskToItem(task: Task): string {
|
|
19
|
+
const link = `https://market.daydreams.systems/tasks/${task.id}`;
|
|
20
|
+
const title = esc(`[${task.mode}] $${rewardUsdc(task)} USDC — ${task.description.slice(0, 80)}`);
|
|
21
|
+
const desc = esc(task.description);
|
|
22
|
+
const pubDate = new Date(task.createdAt).toUTCString();
|
|
23
|
+
return ` <item>
|
|
24
|
+
<title>${title}</title>
|
|
25
|
+
<link>${link}</link>
|
|
26
|
+
<guid isPermaLink="false">${task.id}</guid>
|
|
27
|
+
<pubDate>${pubDate}</pubDate>
|
|
28
|
+
<description>${desc}</description>
|
|
29
|
+
<category>${esc(task.mode)}</category>
|
|
30
|
+
</item>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildFeed(
|
|
34
|
+
title: string,
|
|
35
|
+
description: string,
|
|
36
|
+
link: string,
|
|
37
|
+
tasks: Task[]
|
|
38
|
+
): string {
|
|
39
|
+
const items = tasks.map(taskToItem).join('\n');
|
|
40
|
+
const updated = new Date().toUTCString();
|
|
41
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
42
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
43
|
+
<channel>
|
|
44
|
+
<title>${esc(title)}</title>
|
|
45
|
+
<description>${esc(description)}</description>
|
|
46
|
+
<link>${link}</link>
|
|
47
|
+
<atom:link href="${link}" rel="self" type="application/rss+xml"/>
|
|
48
|
+
<lastBuildDate>${updated}</lastBuildDate>
|
|
49
|
+
<generator>@taskmarket/feed</generator>
|
|
50
|
+
${items}
|
|
51
|
+
</channel>
|
|
52
|
+
</rss>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function fetchTasks(status?: string, agentId?: string): Promise<Task[]> {
|
|
56
|
+
try {
|
|
57
|
+
const params = new URLSearchParams({ limit: '50' });
|
|
58
|
+
if (status) params.set('status', status);
|
|
59
|
+
if (agentId) params.set('agentId', agentId);
|
|
60
|
+
const res = await fetch(`${API_BASE}/api/tasks?${params}`);
|
|
61
|
+
const data = await res.json() as any;
|
|
62
|
+
return (data.tasks || data.data?.tasks || []) as Task[];
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchAgentTasks(agentId: string): Promise<Task[]> {
|
|
69
|
+
// Fetch tasks created by or assigned to this agent
|
|
70
|
+
const [asRequester, asWorker] = await Promise.all([
|
|
71
|
+
fetchTasks(undefined, agentId),
|
|
72
|
+
fetchTasks(undefined, undefined),
|
|
73
|
+
]);
|
|
74
|
+
const byWorker = asWorker.filter(t => t.workerAgentId === agentId || t.requesterAgentId === agentId);
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
return [...asRequester, ...byWorker].filter(t => {
|
|
77
|
+
if (seen.has(t.id)) return false;
|
|
78
|
+
seen.add(t.id);
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createSubscription, getAllSubscriptions, deleteSubscription } from './db';
|
|
2
|
+
import { buildFeed, fetchTasks, fetchAgentTasks } from './rss';
|
|
3
|
+
import { startPoller } from './poller';
|
|
4
|
+
|
|
5
|
+
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
6
|
+
|
|
7
|
+
export function createServer() {
|
|
8
|
+
return Bun.serve({
|
|
9
|
+
port: PORT,
|
|
10
|
+
async fetch(req: Request): Promise<Response> {
|
|
11
|
+
const url = new URL(req.url);
|
|
12
|
+
const { pathname } = url;
|
|
13
|
+
const method = req.method.toUpperCase();
|
|
14
|
+
|
|
15
|
+
// ── RSS feeds ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
if (method === 'GET' && pathname === '/rss/tasks/open') {
|
|
18
|
+
const tasks = await fetchTasks('open');
|
|
19
|
+
const xml = buildFeed(
|
|
20
|
+
'TaskMarket — Open Tasks',
|
|
21
|
+
'Live feed of open bounties on TaskMarket',
|
|
22
|
+
`${req.headers.get('origin') || ''}${pathname}`,
|
|
23
|
+
tasks
|
|
24
|
+
);
|
|
25
|
+
return new Response(xml, {
|
|
26
|
+
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (method === 'GET' && pathname === '/rss/tasks/completed') {
|
|
31
|
+
const tasks = await fetchTasks('completed');
|
|
32
|
+
const xml = buildFeed(
|
|
33
|
+
'TaskMarket — Completed Tasks',
|
|
34
|
+
'Recently completed tasks on TaskMarket',
|
|
35
|
+
`${req.headers.get('origin') || ''}${pathname}`,
|
|
36
|
+
tasks
|
|
37
|
+
);
|
|
38
|
+
return new Response(xml, {
|
|
39
|
+
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (method === 'GET' && pathname.startsWith('/rss/agents/')) {
|
|
44
|
+
const agentId = pathname.split('/rss/agents/')[1];
|
|
45
|
+
if (!agentId) return json({ error: 'missing agentId' }, 400);
|
|
46
|
+
const tasks = await fetchAgentTasks(agentId);
|
|
47
|
+
const xml = buildFeed(
|
|
48
|
+
`TaskMarket — Agent ${agentId}`,
|
|
49
|
+
`Tasks for agent ${agentId}`,
|
|
50
|
+
`${req.headers.get('origin') || ''}${pathname}`,
|
|
51
|
+
tasks
|
|
52
|
+
);
|
|
53
|
+
return new Response(xml, {
|
|
54
|
+
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Webhook subscriptions ──────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
if (method === 'POST' && pathname === '/subscribe') {
|
|
61
|
+
let body: any;
|
|
62
|
+
try { body = await req.json(); } catch { return json({ error: 'invalid JSON' }, 400); }
|
|
63
|
+
const { url: hookUrl, secret, filters } = body;
|
|
64
|
+
if (!hookUrl || !secret) return json({ error: 'url and secret are required' }, 400);
|
|
65
|
+
try { new URL(hookUrl); } catch { return json({ error: 'invalid url' }, 400); }
|
|
66
|
+
const sub = createSubscription(hookUrl, secret, filters || {});
|
|
67
|
+
return json({ ok: true, id: sub.id }, 201);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (method === 'GET' && pathname === '/subscriptions') {
|
|
71
|
+
const subs = getAllSubscriptions().map(s => ({
|
|
72
|
+
id: s.id,
|
|
73
|
+
url: s.url,
|
|
74
|
+
filters: s.filters,
|
|
75
|
+
createdAt: s.createdAt,
|
|
76
|
+
failCount: s.failCount,
|
|
77
|
+
lastDelivery: s.lastDelivery,
|
|
78
|
+
// never expose secret
|
|
79
|
+
}));
|
|
80
|
+
return json({ ok: true, subscriptions: subs });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (method === 'DELETE' && pathname.startsWith('/subscribe/')) {
|
|
84
|
+
const id = pathname.split('/subscribe/')[1];
|
|
85
|
+
if (!id) return json({ error: 'missing id' }, 400);
|
|
86
|
+
const removed = deleteSubscription(id);
|
|
87
|
+
if (!removed) return json({ error: 'not found' }, 404);
|
|
88
|
+
return json({ ok: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Health ─────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
if (method === 'GET' && pathname === '/health') {
|
|
94
|
+
return json({ ok: true, service: '@taskmarket/feed' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return json({ error: 'not found' }, 404);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function json(data: unknown, status = 200): Response {
|
|
103
|
+
return new Response(JSON.stringify(data), {
|
|
104
|
+
status,
|
|
105
|
+
headers: { 'Content-Type': 'application/json' }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
110
|
+
if (import.meta.main) {
|
|
111
|
+
startPoller();
|
|
112
|
+
const server = createServer();
|
|
113
|
+
console.log(`[@taskmarket/feed] listening on port ${server.port}`);
|
|
114
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface Task {
|
|
2
|
+
id: string;
|
|
3
|
+
requester: string;
|
|
4
|
+
description: string;
|
|
5
|
+
reward: string;
|
|
6
|
+
status: string;
|
|
7
|
+
mode: string;
|
|
8
|
+
tags: string[];
|
|
9
|
+
createdAt: string;
|
|
10
|
+
expiryTime: string;
|
|
11
|
+
submissionCount: number;
|
|
12
|
+
worker: string | null;
|
|
13
|
+
workerAgentId: string | null;
|
|
14
|
+
requesterAgentId: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Subscription {
|
|
18
|
+
id: string;
|
|
19
|
+
url: string;
|
|
20
|
+
secret: string;
|
|
21
|
+
filters: SubscriptionFilters;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
failCount: number;
|
|
24
|
+
lastDelivery: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SubscriptionFilters {
|
|
28
|
+
events?: string[]; // e.g. ['task.new', 'task.completed']
|
|
29
|
+
tags?: string[]; // only tasks matching these tags
|
|
30
|
+
minReward?: number; // minimum reward in USDC (full units)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WebhookPayload {
|
|
34
|
+
event: string;
|
|
35
|
+
task: Task;
|
|
36
|
+
timestamp: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type EventType = 'task.new' | 'task.completed' | 'task.accepted';
|
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHmac } from 'crypto';
|
|
2
|
+
import { getAllSubscriptions, recordDelivery } from './db';
|
|
3
|
+
import type { Subscription, WebhookPayload, EventType, Task, SubscriptionFilters } from './types';
|
|
4
|
+
|
|
5
|
+
const MAX_RETRIES = 3;
|
|
6
|
+
const RETRY_DELAYS = [1000, 5000, 15000]; // ms
|
|
7
|
+
const MAX_FAIL_COUNT = 10; // disable after 10 consecutive failures
|
|
8
|
+
|
|
9
|
+
export function signPayload(secret: string, body: string): string {
|
|
10
|
+
return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function matchesFilter(task: Task, filters: SubscriptionFilters): boolean {
|
|
14
|
+
if (filters.tags && filters.tags.length > 0) {
|
|
15
|
+
const hasTag = filters.tags.some(t => task.tags.includes(t));
|
|
16
|
+
if (!hasTag) return false;
|
|
17
|
+
}
|
|
18
|
+
if (filters.minReward !== undefined) {
|
|
19
|
+
const rewardUsdc = parseInt(task.reward, 10) / 1_000_000;
|
|
20
|
+
if (rewardUsdc < filters.minReward) return false;
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function matchesEvent(event: string, filters: SubscriptionFilters): boolean {
|
|
26
|
+
if (!filters.events || filters.events.length === 0) return true;
|
|
27
|
+
return filters.events.includes(event);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function deliverOnce(url: string, body: string, sig: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
'X-TaskMarket-Signature': sig,
|
|
37
|
+
},
|
|
38
|
+
body,
|
|
39
|
+
signal: AbortSignal.timeout(10_000),
|
|
40
|
+
});
|
|
41
|
+
return res.ok;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function deliverWithRetry(
|
|
48
|
+
sub: Subscription,
|
|
49
|
+
body: string,
|
|
50
|
+
sig: string
|
|
51
|
+
): Promise<boolean> {
|
|
52
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
53
|
+
if (attempt > 0) {
|
|
54
|
+
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt - 1]));
|
|
55
|
+
}
|
|
56
|
+
const ok = await deliverOnce(sub.url, body, sig);
|
|
57
|
+
if (ok) return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function dispatch(event: EventType, task: Task): Promise<void> {
|
|
63
|
+
const subs = getAllSubscriptions();
|
|
64
|
+
const payload: WebhookPayload = { event, task, timestamp: new Date().toISOString() };
|
|
65
|
+
const body = JSON.stringify(payload);
|
|
66
|
+
|
|
67
|
+
await Promise.allSettled(subs.map(async (sub) => {
|
|
68
|
+
// Skip subscriptions with too many failures
|
|
69
|
+
if (sub.failCount >= MAX_FAIL_COUNT) return;
|
|
70
|
+
|
|
71
|
+
// Filter by event type
|
|
72
|
+
if (!matchesEvent(event, sub.filters)) return;
|
|
73
|
+
|
|
74
|
+
// Filter by task properties
|
|
75
|
+
if (!matchesFilter(task, sub.filters)) return;
|
|
76
|
+
|
|
77
|
+
const sig = signPayload(sub.secret, body);
|
|
78
|
+
const ok = await deliverWithRetry(sub, body, sig);
|
|
79
|
+
recordDelivery(sub.id, ok);
|
|
80
|
+
}));
|
|
81
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
|
28
|
+
}
|
|
29
|
+
}
|