agent-relay 1.0.8 → 1.0.9
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 +158 -0
- package/dist/bridge/config.d.ts +41 -0
- package/dist/bridge/config.d.ts.map +1 -0
- package/dist/bridge/config.js +143 -0
- package/dist/bridge/config.js.map +1 -0
- package/dist/bridge/index.d.ts +10 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +10 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/bridge/multi-project-client.d.ts +99 -0
- package/dist/bridge/multi-project-client.d.ts.map +1 -0
- package/dist/bridge/multi-project-client.js +386 -0
- package/dist/bridge/multi-project-client.js.map +1 -0
- package/dist/bridge/spawner.d.ts +46 -0
- package/dist/bridge/spawner.d.ts.map +1 -0
- package/dist/bridge/spawner.js +223 -0
- package/dist/bridge/spawner.js.map +1 -0
- package/dist/bridge/types.d.ts +55 -0
- package/dist/bridge/types.d.ts.map +1 -0
- package/dist/bridge/types.js +6 -0
- package/dist/bridge/types.js.map +1 -0
- package/dist/bridge/utils.d.ts +30 -0
- package/dist/bridge/utils.d.ts.map +1 -0
- package/dist/bridge/utils.js +54 -0
- package/dist/bridge/utils.js.map +1 -0
- package/dist/cli/index.js +564 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/agent-registry.d.ts.map +1 -1
- package/dist/daemon/agent-registry.js +6 -1
- package/dist/daemon/agent-registry.js.map +1 -1
- package/dist/daemon/connection.d.ts +22 -0
- package/dist/daemon/connection.d.ts.map +1 -1
- package/dist/daemon/connection.js +59 -13
- package/dist/daemon/connection.js.map +1 -1
- package/dist/daemon/router.d.ts +27 -0
- package/dist/daemon/router.d.ts.map +1 -1
- package/dist/daemon/router.js +108 -3
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/server.d.ts +8 -0
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +95 -23
- package/dist/daemon/server.js.map +1 -1
- package/dist/dashboard/metrics.d.ts +105 -0
- package/dist/dashboard/metrics.d.ts.map +1 -0
- package/dist/dashboard/metrics.js +192 -0
- package/dist/dashboard/metrics.js.map +1 -0
- package/dist/dashboard/needs-attention.d.ts +24 -0
- package/dist/dashboard/needs-attention.d.ts.map +1 -0
- package/dist/dashboard/needs-attention.js +78 -0
- package/dist/dashboard/needs-attention.js.map +1 -0
- package/dist/dashboard/public/bridge.html +1272 -0
- package/dist/dashboard/public/index.html +2017 -879
- package/dist/dashboard/public/js/app.js +184 -0
- package/dist/dashboard/public/js/app.js.map +7 -0
- package/dist/dashboard/public/metrics.html +999 -0
- package/dist/dashboard/server.d.ts +13 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +568 -13
- package/dist/dashboard/server.js.map +1 -1
- package/dist/dashboard/start.js +1 -1
- package/dist/dashboard/start.js.map +1 -1
- package/dist/dashboard-v2/index.d.ts +10 -0
- package/dist/dashboard-v2/index.d.ts.map +1 -0
- package/dist/dashboard-v2/index.js +54 -0
- package/dist/dashboard-v2/index.js.map +1 -0
- package/dist/dashboard-v2/lib/api.d.ts +95 -0
- package/dist/dashboard-v2/lib/api.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/api.js +270 -0
- package/dist/dashboard-v2/lib/api.js.map +1 -0
- package/dist/dashboard-v2/lib/colors.d.ts +61 -0
- package/dist/dashboard-v2/lib/colors.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/colors.js +198 -0
- package/dist/dashboard-v2/lib/colors.js.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts +74 -0
- package/dist/dashboard-v2/lib/hierarchy.d.ts.map +1 -0
- package/dist/dashboard-v2/lib/hierarchy.js +196 -0
- package/dist/dashboard-v2/lib/hierarchy.js.map +1 -0
- package/dist/dashboard-v2/types/index.d.ts +154 -0
- package/dist/dashboard-v2/types/index.d.ts.map +1 -0
- package/dist/dashboard-v2/types/index.js +6 -0
- package/dist/dashboard-v2/types/index.js.map +1 -0
- package/dist/storage/adapter.d.ts +21 -1
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/adapter.js +36 -0
- package/dist/storage/adapter.js.map +1 -1
- package/dist/storage/sqlite-adapter.d.ts +34 -0
- package/dist/storage/sqlite-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-adapter.js +253 -12
- package/dist/storage/sqlite-adapter.js.map +1 -1
- package/dist/utils/agent-config.d.ts +45 -0
- package/dist/utils/agent-config.d.ts.map +1 -0
- package/dist/utils/agent-config.js +118 -0
- package/dist/utils/agent-config.js.map +1 -0
- package/dist/wrapper/client.d.ts +8 -0
- package/dist/wrapper/client.d.ts.map +1 -1
- package/dist/wrapper/client.js +26 -0
- package/dist/wrapper/client.js.map +1 -1
- package/dist/wrapper/parser.d.ts +17 -0
- package/dist/wrapper/parser.d.ts.map +1 -1
- package/dist/wrapper/parser.js +334 -10
- package/dist/wrapper/parser.js.map +1 -1
- package/dist/wrapper/tmux-wrapper.d.ts +37 -2
- package/dist/wrapper/tmux-wrapper.d.ts.map +1 -1
- package/dist/wrapper/tmux-wrapper.js +178 -18
- package/dist/wrapper/tmux-wrapper.js.map +1 -1
- package/docs/AGENTS.md +105 -0
- package/docs/ARCHITECTURE_DECISIONS.md +175 -0
- package/docs/COMPETITIVE_ANALYSIS.md +897 -0
- package/docs/DESIGN_BRIDGE_STAFFING.md +878 -0
- package/docs/MONETIZATION.md +1679 -0
- package/docs/agent-relay-snippet.md +61 -0
- package/docs/dashboard-v2-plan.md +179 -0
- package/package.json +5 -2
|
@@ -0,0 +1,1679 @@
|
|
|
1
|
+
# Agent Relay Monetization Strategy
|
|
2
|
+
|
|
3
|
+
> RFC: Open Core Model with Tiered Features
|
|
4
|
+
|
|
5
|
+
**Status**: Draft
|
|
6
|
+
**Author**: Khaliq Gant
|
|
7
|
+
**Created**: 2025-12-26
|
|
8
|
+
**Last Updated**: 2025-12-26
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Executive Summary
|
|
13
|
+
|
|
14
|
+
This document outlines a monetization strategy for Agent Relay using an **Open Core** model. The core messaging functionality remains MIT-licensed and free, while premium features targeting teams and enterprises are offered through paid tiers.
|
|
15
|
+
|
|
16
|
+
### Recommended Pricing Tiers
|
|
17
|
+
|
|
18
|
+
| Tier | Price | Target User |
|
|
19
|
+
|------|-------|-------------|
|
|
20
|
+
| **Community** | Free | Individual developers, OSS projects |
|
|
21
|
+
| **Pro** | $29-49/month per machine | Professional developers, small teams |
|
|
22
|
+
| **Team** | $99-199/month | Engineering teams, multi-machine setups |
|
|
23
|
+
| **Enterprise** | Custom | Large organizations, compliance needs |
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Table of Contents
|
|
28
|
+
|
|
29
|
+
1. [Market Analysis](#1-market-analysis)
|
|
30
|
+
2. [Feature Matrix](#2-feature-matrix)
|
|
31
|
+
3. [Technical Specifications](#3-technical-specifications)
|
|
32
|
+
- [3.1 Licensing System](#31-licensing-system)
|
|
33
|
+
- [3.2 Authentication & API Keys](#32-authentication--api-keys)
|
|
34
|
+
- [3.3 TLS Encryption](#33-tls-encryption)
|
|
35
|
+
- [3.4 Scale Optimizations](#34-scale-optimizations)
|
|
36
|
+
- [3.5 Message Retention](#35-message-retention)
|
|
37
|
+
- [3.6 Webhooks](#36-webhooks)
|
|
38
|
+
4. [Implementation Roadmap](#4-implementation-roadmap)
|
|
39
|
+
5. [Pricing Justification](#5-pricing-justification)
|
|
40
|
+
6. [Risks & Mitigations](#6-risks--mitigations)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 1. Market Analysis
|
|
45
|
+
|
|
46
|
+
### Target Users
|
|
47
|
+
|
|
48
|
+
| Segment | Size | Willingness to Pay | Key Needs |
|
|
49
|
+
|---------|------|-------------------|-----------|
|
|
50
|
+
| **Hobbyists** | Large | Low | Free, easy setup |
|
|
51
|
+
| **Indie Developers** | Medium | Medium | Reliability, simple pricing |
|
|
52
|
+
| **Startups** | Medium | Medium-High | Scale, integrations |
|
|
53
|
+
| **Enterprise** | Small | High | Security, compliance, support |
|
|
54
|
+
|
|
55
|
+
### Competitive Landscape
|
|
56
|
+
|
|
57
|
+
| Competitor | Model | Price Range | Differentiator |
|
|
58
|
+
|-----------|-------|-------------|----------------|
|
|
59
|
+
| LangGraph | Open Core | Free - $500/mo | Workflow orchestration |
|
|
60
|
+
| CrewAI | SaaS | Free - Custom | Pre-built agent roles |
|
|
61
|
+
| AutoGen | MIT (Microsoft) | Free | Deep MS integration |
|
|
62
|
+
| **Agent Relay** | Open Core | Free - Custom | Real-time messaging, CLI-native |
|
|
63
|
+
|
|
64
|
+
### Our Advantages
|
|
65
|
+
|
|
66
|
+
1. **Zero-modification integration**: Works with any CLI agent via output patterns
|
|
67
|
+
2. **Sub-5ms latency**: Unix socket architecture
|
|
68
|
+
3. **Simple mental model**: Relay messaging, not workflow orchestration
|
|
69
|
+
4. **Composable**: Works alongside other tools (Mimir, Beads, etc.)
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 2. Feature Matrix
|
|
74
|
+
|
|
75
|
+
### Community Tier (Free)
|
|
76
|
+
|
|
77
|
+
| Feature | Limit | Notes |
|
|
78
|
+
|---------|-------|-------|
|
|
79
|
+
| Agents | 10 | Per daemon instance |
|
|
80
|
+
| Message throughput | 100 msg/sec | Soft limit |
|
|
81
|
+
| Message retention | 7 days | SQLite storage |
|
|
82
|
+
| Dashboard | Basic | Real-time view |
|
|
83
|
+
| Storage | SQLite only | Local file |
|
|
84
|
+
| Transport | Unix socket | Single machine |
|
|
85
|
+
| Support | Community | GitHub issues |
|
|
86
|
+
|
|
87
|
+
### Pro Tier ($29-49/month)
|
|
88
|
+
|
|
89
|
+
| Feature | Limit | Notes |
|
|
90
|
+
|---------|-------|-------|
|
|
91
|
+
| Agents | 100 | Per daemon instance |
|
|
92
|
+
| Message throughput | 1,000 msg/sec | Optimized routing |
|
|
93
|
+
| Message retention | 90 days | Configurable |
|
|
94
|
+
| Dashboard | Enhanced | Metrics, analytics |
|
|
95
|
+
| **Authentication** | API keys | Per-agent keys |
|
|
96
|
+
| **TLS encryption** | Full | Socket + storage |
|
|
97
|
+
| **Webhooks** | 10 endpoints | HTTP callbacks |
|
|
98
|
+
| Support | Email | 48hr response |
|
|
99
|
+
|
|
100
|
+
### Team Tier ($99-199/month)
|
|
101
|
+
|
|
102
|
+
| Feature | Limit | Notes |
|
|
103
|
+
|---------|-------|-------|
|
|
104
|
+
| Agents | 500 | Across machines |
|
|
105
|
+
| Message throughput | 5,000 msg/sec | Distributed |
|
|
106
|
+
| Message retention | 365 days | Configurable |
|
|
107
|
+
| **Multi-machine** | TCP transport | Cluster mode |
|
|
108
|
+
| **Team dashboard** | Shared | Role-based access |
|
|
109
|
+
| **Audit logs** | Full | Compliance-ready |
|
|
110
|
+
| **SSO** | SAML/OIDC | GitHub, Google, Okta |
|
|
111
|
+
| Support | Priority | 24hr response |
|
|
112
|
+
|
|
113
|
+
### Enterprise Tier (Custom)
|
|
114
|
+
|
|
115
|
+
| Feature | Limit | Notes |
|
|
116
|
+
|---------|-------|-------|
|
|
117
|
+
| Agents | Unlimited | - |
|
|
118
|
+
| Message throughput | Unlimited | - |
|
|
119
|
+
| Message retention | Unlimited | - |
|
|
120
|
+
| **Air-gapped** | Offline license | No phone-home |
|
|
121
|
+
| **Compliance** | SOC2, HIPAA | Documentation |
|
|
122
|
+
| **Custom SLA** | 99.9%+ | Contractual |
|
|
123
|
+
| **Dedicated support** | Slack/Teams | 4hr response |
|
|
124
|
+
| **Custom features** | 2/year | Built to spec |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 3. Technical Specifications
|
|
129
|
+
|
|
130
|
+
### 3.1 Licensing System
|
|
131
|
+
|
|
132
|
+
#### Overview
|
|
133
|
+
|
|
134
|
+
A lightweight license validation system that:
|
|
135
|
+
- Validates license keys on daemon startup
|
|
136
|
+
- Enforces feature gates at runtime
|
|
137
|
+
- Tracks usage for billing (optional telemetry)
|
|
138
|
+
- Supports offline/air-gapped mode for Enterprise
|
|
139
|
+
|
|
140
|
+
#### Architecture
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
src/
|
|
144
|
+
licensing/
|
|
145
|
+
index.ts # Public API
|
|
146
|
+
license-validator.ts # Key validation
|
|
147
|
+
feature-flags.ts # Feature gating
|
|
148
|
+
usage-tracker.ts # Telemetry (opt-in)
|
|
149
|
+
offline-license.ts # Air-gapped support
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
#### License Key Format
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
ar_[tier]_[random]_[checksum]
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
ar_pro_a1b2c3d4e5f6g7h8_x9y0
|
|
159
|
+
ar_team_k8j7h6g5f4d3s2a1_m3n4
|
|
160
|
+
ar_ent_q1w2e3r4t5y6u7i8_p9o0
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### Validation Flow
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
interface LicenseInfo {
|
|
167
|
+
key: string;
|
|
168
|
+
tier: 'community' | 'pro' | 'team' | 'enterprise';
|
|
169
|
+
validUntil: Date;
|
|
170
|
+
features: string[];
|
|
171
|
+
limits: {
|
|
172
|
+
maxAgents: number;
|
|
173
|
+
maxMessagesPerSecond: number;
|
|
174
|
+
retentionDays: number;
|
|
175
|
+
};
|
|
176
|
+
offline?: boolean; // Enterprise air-gapped
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function validateLicense(key: string): Promise<LicenseInfo> {
|
|
180
|
+
// 1. Check local cache
|
|
181
|
+
const cached = await licenseCache.get(key);
|
|
182
|
+
if (cached && !isExpired(cached)) {
|
|
183
|
+
return cached;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 2. Validate with license server (unless offline mode)
|
|
187
|
+
if (!isOfflineLicense(key)) {
|
|
188
|
+
const remote = await fetchLicense(key);
|
|
189
|
+
await licenseCache.set(key, remote);
|
|
190
|
+
return remote;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 3. Validate offline license signature
|
|
194
|
+
return validateOfflineLicense(key);
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
#### Feature Flags
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// licensing/feature-flags.ts
|
|
202
|
+
|
|
203
|
+
export const TIER_FEATURES = {
|
|
204
|
+
community: [
|
|
205
|
+
'basic_messaging',
|
|
206
|
+
'dashboard_basic',
|
|
207
|
+
'sqlite_storage',
|
|
208
|
+
],
|
|
209
|
+
pro: [
|
|
210
|
+
// Includes all community features
|
|
211
|
+
'authentication',
|
|
212
|
+
'api_keys',
|
|
213
|
+
'tls_encryption',
|
|
214
|
+
'storage_encryption',
|
|
215
|
+
'extended_retention',
|
|
216
|
+
'webhooks',
|
|
217
|
+
'high_throughput',
|
|
218
|
+
'dashboard_metrics',
|
|
219
|
+
],
|
|
220
|
+
team: [
|
|
221
|
+
// Includes all pro features
|
|
222
|
+
'multi_machine',
|
|
223
|
+
'tcp_transport',
|
|
224
|
+
'team_dashboard',
|
|
225
|
+
'role_based_access',
|
|
226
|
+
'audit_logs',
|
|
227
|
+
'sso_saml',
|
|
228
|
+
'sso_oidc',
|
|
229
|
+
],
|
|
230
|
+
enterprise: [
|
|
231
|
+
// Includes all team features
|
|
232
|
+
'unlimited_retention',
|
|
233
|
+
'offline_license',
|
|
234
|
+
'custom_sla',
|
|
235
|
+
'dedicated_support',
|
|
236
|
+
],
|
|
237
|
+
} as const;
|
|
238
|
+
|
|
239
|
+
export function hasFeature(tier: string, feature: string): boolean {
|
|
240
|
+
const tierIndex = ['community', 'pro', 'team', 'enterprise'].indexOf(tier);
|
|
241
|
+
|
|
242
|
+
for (let i = tierIndex; i >= 0; i--) {
|
|
243
|
+
const tierName = ['community', 'pro', 'team', 'enterprise'][i];
|
|
244
|
+
if (TIER_FEATURES[tierName].includes(feature)) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Database Schema
|
|
254
|
+
|
|
255
|
+
```sql
|
|
256
|
+
-- License tracking (local cache)
|
|
257
|
+
CREATE TABLE license_cache (
|
|
258
|
+
key_hash TEXT PRIMARY KEY,
|
|
259
|
+
tier TEXT NOT NULL,
|
|
260
|
+
valid_until INTEGER NOT NULL,
|
|
261
|
+
features TEXT NOT NULL, -- JSON array
|
|
262
|
+
limits TEXT NOT NULL, -- JSON object
|
|
263
|
+
cached_at INTEGER NOT NULL
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
-- Usage tracking (for metered billing, opt-in)
|
|
267
|
+
CREATE TABLE usage_daily (
|
|
268
|
+
date TEXT NOT NULL, -- '2025-01-15'
|
|
269
|
+
metric TEXT NOT NULL, -- 'agents_peak', 'messages_sent'
|
|
270
|
+
value INTEGER NOT NULL,
|
|
271
|
+
PRIMARY KEY (date, metric)
|
|
272
|
+
);
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
### 3.2 Authentication & API Keys
|
|
278
|
+
|
|
279
|
+
#### Overview
|
|
280
|
+
|
|
281
|
+
Pro tier adds optional authentication:
|
|
282
|
+
- API keys for programmatic access
|
|
283
|
+
- Per-agent identity verification
|
|
284
|
+
- Key rotation and revocation
|
|
285
|
+
- Usage tracking per key
|
|
286
|
+
|
|
287
|
+
#### Protocol Changes
|
|
288
|
+
|
|
289
|
+
**HELLO payload extension** (`protocol/types.ts`):
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
export interface HelloPayload {
|
|
293
|
+
agent: string;
|
|
294
|
+
capabilities: { ... };
|
|
295
|
+
|
|
296
|
+
// NEW: Authentication
|
|
297
|
+
auth?: {
|
|
298
|
+
type: 'api_key' | 'token';
|
|
299
|
+
credential: string;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Existing optional fields
|
|
303
|
+
cli?: string;
|
|
304
|
+
program?: string;
|
|
305
|
+
model?: string;
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**WELCOME payload extension**:
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
export interface WelcomePayload {
|
|
313
|
+
session_id: string;
|
|
314
|
+
server: { ... };
|
|
315
|
+
|
|
316
|
+
// NEW: License info
|
|
317
|
+
license?: {
|
|
318
|
+
tier: string;
|
|
319
|
+
features: string[];
|
|
320
|
+
limits: {
|
|
321
|
+
maxAgents: number;
|
|
322
|
+
retentionDays: number;
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**New ERROR codes**:
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
export type ErrorCode =
|
|
332
|
+
| 'BAD_REQUEST'
|
|
333
|
+
| 'UNAUTHORIZED' // Missing or invalid credentials
|
|
334
|
+
| 'FORBIDDEN' // Valid credentials, insufficient permissions
|
|
335
|
+
| 'QUOTA_EXCEEDED' // Agent/message limit reached
|
|
336
|
+
| 'NOT_FOUND'
|
|
337
|
+
| 'INTERNAL'
|
|
338
|
+
| 'RESUME_TOO_OLD';
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
#### API Key Management
|
|
342
|
+
|
|
343
|
+
**Database schema**:
|
|
344
|
+
|
|
345
|
+
```sql
|
|
346
|
+
CREATE TABLE api_keys (
|
|
347
|
+
id TEXT PRIMARY KEY,
|
|
348
|
+
key_hash TEXT UNIQUE NOT NULL, -- SHA256(key)
|
|
349
|
+
key_prefix TEXT NOT NULL, -- First 8 chars for display
|
|
350
|
+
name TEXT NOT NULL,
|
|
351
|
+
description TEXT,
|
|
352
|
+
|
|
353
|
+
-- Permissions
|
|
354
|
+
scopes TEXT NOT NULL DEFAULT '["send","receive"]', -- JSON array
|
|
355
|
+
allowed_agents TEXT, -- JSON array, null = all
|
|
356
|
+
|
|
357
|
+
-- Limits
|
|
358
|
+
rate_limit_per_minute INTEGER DEFAULT 1000,
|
|
359
|
+
|
|
360
|
+
-- Metadata
|
|
361
|
+
created_at INTEGER NOT NULL,
|
|
362
|
+
created_by TEXT,
|
|
363
|
+
last_used_at INTEGER,
|
|
364
|
+
expires_at INTEGER,
|
|
365
|
+
revoked_at INTEGER,
|
|
366
|
+
|
|
367
|
+
-- Usage counters (updated periodically)
|
|
368
|
+
total_messages INTEGER DEFAULT 0
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
CREATE INDEX idx_api_keys_hash ON api_keys (key_hash);
|
|
372
|
+
CREATE INDEX idx_api_keys_expires ON api_keys (expires_at) WHERE revoked_at IS NULL;
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Key generation**:
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
// licensing/api-keys.ts
|
|
379
|
+
|
|
380
|
+
import crypto from 'crypto';
|
|
381
|
+
|
|
382
|
+
export interface ApiKeyCreate {
|
|
383
|
+
name: string;
|
|
384
|
+
description?: string;
|
|
385
|
+
scopes?: ('send' | 'receive' | 'admin')[];
|
|
386
|
+
allowedAgents?: string[];
|
|
387
|
+
expiresIn?: number; // milliseconds
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface ApiKey {
|
|
391
|
+
id: string;
|
|
392
|
+
key: string; // Only returned on creation
|
|
393
|
+
keyPrefix: string;
|
|
394
|
+
name: string;
|
|
395
|
+
scopes: string[];
|
|
396
|
+
createdAt: Date;
|
|
397
|
+
expiresAt?: Date;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function generateApiKey(): { key: string; hash: string; prefix: string } {
|
|
401
|
+
// Format: ar_key_[32 random chars]
|
|
402
|
+
const random = crypto.randomBytes(24).toString('base64url');
|
|
403
|
+
const key = `ar_key_${random}`;
|
|
404
|
+
const hash = crypto.createHash('sha256').update(key).digest('hex');
|
|
405
|
+
const prefix = key.substring(0, 15); // 'ar_key_XXXXXXX'
|
|
406
|
+
|
|
407
|
+
return { key, hash, prefix };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function createApiKey(
|
|
411
|
+
storage: StorageAdapter,
|
|
412
|
+
options: ApiKeyCreate
|
|
413
|
+
): Promise<ApiKey> {
|
|
414
|
+
const { key, hash, prefix } = generateApiKey();
|
|
415
|
+
const id = crypto.randomUUID();
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
|
|
418
|
+
await storage.exec(`
|
|
419
|
+
INSERT INTO api_keys (id, key_hash, key_prefix, name, description, scopes, allowed_agents, created_at, expires_at)
|
|
420
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
421
|
+
`, [
|
|
422
|
+
id,
|
|
423
|
+
hash,
|
|
424
|
+
prefix,
|
|
425
|
+
options.name,
|
|
426
|
+
options.description || null,
|
|
427
|
+
JSON.stringify(options.scopes || ['send', 'receive']),
|
|
428
|
+
options.allowedAgents ? JSON.stringify(options.allowedAgents) : null,
|
|
429
|
+
now,
|
|
430
|
+
options.expiresIn ? now + options.expiresIn : null,
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
id,
|
|
435
|
+
key, // Only time the full key is available
|
|
436
|
+
keyPrefix: prefix,
|
|
437
|
+
name: options.name,
|
|
438
|
+
scopes: options.scopes || ['send', 'receive'],
|
|
439
|
+
createdAt: new Date(now),
|
|
440
|
+
expiresAt: options.expiresIn ? new Date(now + options.expiresIn) : undefined,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function validateApiKey(
|
|
445
|
+
storage: StorageAdapter,
|
|
446
|
+
key: string
|
|
447
|
+
): Promise<{ valid: boolean; reason?: string; keyInfo?: any }> {
|
|
448
|
+
const hash = crypto.createHash('sha256').update(key).digest('hex');
|
|
449
|
+
|
|
450
|
+
const row = await storage.get(`
|
|
451
|
+
SELECT * FROM api_keys
|
|
452
|
+
WHERE key_hash = ? AND revoked_at IS NULL
|
|
453
|
+
`, [hash]);
|
|
454
|
+
|
|
455
|
+
if (!row) {
|
|
456
|
+
return { valid: false, reason: 'Invalid API key' };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (row.expires_at && row.expires_at < Date.now()) {
|
|
460
|
+
return { valid: false, reason: 'API key expired' };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Update last used
|
|
464
|
+
await storage.exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, [Date.now(), row.id]);
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
valid: true,
|
|
468
|
+
keyInfo: {
|
|
469
|
+
id: row.id,
|
|
470
|
+
name: row.name,
|
|
471
|
+
scopes: JSON.parse(row.scopes),
|
|
472
|
+
allowedAgents: row.allowed_agents ? JSON.parse(row.allowed_agents) : null,
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
#### Connection Authentication
|
|
479
|
+
|
|
480
|
+
**Changes to `daemon/connection.ts`**:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
// connection.ts - handleHello method
|
|
484
|
+
|
|
485
|
+
private async handleHello(envelope: Envelope<HelloPayload>): Promise<void> {
|
|
486
|
+
if (this._state !== 'HANDSHAKING') {
|
|
487
|
+
this.sendError('BAD_REQUEST', 'Unexpected HELLO', false);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Check if authentication is required
|
|
492
|
+
if (this.config.requireAuth) {
|
|
493
|
+
const auth = envelope.payload.auth;
|
|
494
|
+
|
|
495
|
+
if (!auth) {
|
|
496
|
+
this.sendError('UNAUTHORIZED', 'Authentication required', true);
|
|
497
|
+
this.close();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const validation = await this.authProvider.validate(auth);
|
|
502
|
+
|
|
503
|
+
if (!validation.valid) {
|
|
504
|
+
this.sendError('UNAUTHORIZED', validation.reason || 'Invalid credentials', true);
|
|
505
|
+
this.close();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Store auth context for permission checks
|
|
510
|
+
this._authContext = validation.context;
|
|
511
|
+
|
|
512
|
+
// Check if this agent name is allowed
|
|
513
|
+
if (validation.context.allowedAgents) {
|
|
514
|
+
if (!validation.context.allowedAgents.includes(envelope.payload.agent)) {
|
|
515
|
+
this.sendError('FORBIDDEN', `Agent name "${envelope.payload.agent}" not allowed for this key`, true);
|
|
516
|
+
this.close();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Check agent limit
|
|
523
|
+
const currentAgents = this.getAgentCount();
|
|
524
|
+
const maxAgents = this._license?.limits.maxAgents ?? 10;
|
|
525
|
+
|
|
526
|
+
if (currentAgents >= maxAgents) {
|
|
527
|
+
this.sendError('QUOTA_EXCEEDED', `Agent limit reached (${maxAgents}). Upgrade to increase limit.`, true);
|
|
528
|
+
this.close();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Continue with existing logic...
|
|
533
|
+
this._agentName = envelope.payload.agent;
|
|
534
|
+
// ...
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
#### Dashboard API
|
|
539
|
+
|
|
540
|
+
**New endpoints in `dashboard/server.ts`**:
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
// Require Pro tier for API key management
|
|
544
|
+
const requirePro = (req, res, next) => {
|
|
545
|
+
if (!license || !hasFeature(license.tier, 'api_keys')) {
|
|
546
|
+
return res.status(403).json({ error: 'Pro tier required for API key management' });
|
|
547
|
+
}
|
|
548
|
+
next();
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// List API keys (without full key values)
|
|
552
|
+
app.get('/api/keys', requirePro, async (req, res) => {
|
|
553
|
+
const keys = await storage.all(`
|
|
554
|
+
SELECT id, key_prefix, name, description, scopes, created_at, last_used_at, expires_at
|
|
555
|
+
FROM api_keys
|
|
556
|
+
WHERE revoked_at IS NULL
|
|
557
|
+
ORDER BY created_at DESC
|
|
558
|
+
`);
|
|
559
|
+
|
|
560
|
+
res.json({
|
|
561
|
+
keys: keys.map(k => ({
|
|
562
|
+
id: k.id,
|
|
563
|
+
keyPrefix: k.key_prefix,
|
|
564
|
+
name: k.name,
|
|
565
|
+
description: k.description,
|
|
566
|
+
scopes: JSON.parse(k.scopes),
|
|
567
|
+
createdAt: new Date(k.created_at).toISOString(),
|
|
568
|
+
lastUsedAt: k.last_used_at ? new Date(k.last_used_at).toISOString() : null,
|
|
569
|
+
expiresAt: k.expires_at ? new Date(k.expires_at).toISOString() : null,
|
|
570
|
+
})),
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Create new API key
|
|
575
|
+
app.post('/api/keys', requirePro, async (req, res) => {
|
|
576
|
+
const { name, description, scopes, allowedAgents, expiresInDays } = req.body;
|
|
577
|
+
|
|
578
|
+
if (!name) {
|
|
579
|
+
return res.status(400).json({ error: 'Name is required' });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const apiKey = await createApiKey(storage, {
|
|
583
|
+
name,
|
|
584
|
+
description,
|
|
585
|
+
scopes,
|
|
586
|
+
allowedAgents,
|
|
587
|
+
expiresIn: expiresInDays ? expiresInDays * 24 * 60 * 60 * 1000 : undefined,
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Return full key only on creation
|
|
591
|
+
res.json({
|
|
592
|
+
id: apiKey.id,
|
|
593
|
+
key: apiKey.key, // IMPORTANT: Only shown once
|
|
594
|
+
keyPrefix: apiKey.keyPrefix,
|
|
595
|
+
name: apiKey.name,
|
|
596
|
+
message: 'Save this key now. It will not be shown again.',
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// Revoke API key
|
|
601
|
+
app.delete('/api/keys/:id', requirePro, async (req, res) => {
|
|
602
|
+
const { id } = req.params;
|
|
603
|
+
|
|
604
|
+
await storage.exec(`UPDATE api_keys SET revoked_at = ? WHERE id = ?`, [Date.now(), id]);
|
|
605
|
+
|
|
606
|
+
res.json({ success: true });
|
|
607
|
+
});
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
### 3.3 TLS Encryption
|
|
613
|
+
|
|
614
|
+
#### Overview
|
|
615
|
+
|
|
616
|
+
Pro tier adds optional TLS encryption for:
|
|
617
|
+
- Socket transport (daemon ↔ client)
|
|
618
|
+
- SQLite storage (at-rest encryption)
|
|
619
|
+
|
|
620
|
+
#### Socket TLS
|
|
621
|
+
|
|
622
|
+
**Configuration** (`daemon/server.ts`):
|
|
623
|
+
|
|
624
|
+
```typescript
|
|
625
|
+
export interface TlsConfig {
|
|
626
|
+
enabled: boolean;
|
|
627
|
+
certPath: string; // PEM certificate
|
|
628
|
+
keyPath: string; // PEM private key
|
|
629
|
+
caPath?: string; // CA for client verification (mTLS)
|
|
630
|
+
mutualTls?: boolean; // Require client certificates
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export interface DaemonConfig extends ConnectionConfig {
|
|
634
|
+
socketPath: string;
|
|
635
|
+
pidFilePath: string;
|
|
636
|
+
storagePath?: string;
|
|
637
|
+
|
|
638
|
+
// NEW
|
|
639
|
+
tls?: TlsConfig;
|
|
640
|
+
}
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
**Server implementation**:
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
// daemon/server.ts
|
|
647
|
+
|
|
648
|
+
import tls from 'node:tls';
|
|
649
|
+
|
|
650
|
+
constructor(config: Partial<DaemonConfig> = {}) {
|
|
651
|
+
this.config = { ...DEFAULT_DAEMON_CONFIG, ...config };
|
|
652
|
+
|
|
653
|
+
// Validate TLS config requires Pro tier
|
|
654
|
+
if (this.config.tls?.enabled && !hasFeature(this.license?.tier, 'tls_encryption')) {
|
|
655
|
+
throw new Error('TLS encryption requires Pro tier');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (this.config.tls?.enabled) {
|
|
659
|
+
const tlsOptions: tls.TlsOptions = {
|
|
660
|
+
key: fs.readFileSync(this.config.tls.keyPath),
|
|
661
|
+
cert: fs.readFileSync(this.config.tls.certPath),
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
if (this.config.tls.mutualTls) {
|
|
665
|
+
tlsOptions.requestCert = true;
|
|
666
|
+
tlsOptions.rejectUnauthorized = true;
|
|
667
|
+
if (this.config.tls.caPath) {
|
|
668
|
+
tlsOptions.ca = [fs.readFileSync(this.config.tls.caPath)];
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
this.server = tls.createServer(tlsOptions, this.handleConnection.bind(this));
|
|
673
|
+
console.log('[daemon] TLS enabled');
|
|
674
|
+
} else {
|
|
675
|
+
this.server = net.createServer(this.handleConnection.bind(this));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
**Client implementation** (`wrapper/client.ts`):
|
|
681
|
+
|
|
682
|
+
```typescript
|
|
683
|
+
export interface ClientTlsConfig {
|
|
684
|
+
enabled: boolean;
|
|
685
|
+
caPath?: string; // CA to verify server
|
|
686
|
+
clientCertPath?: string; // For mTLS
|
|
687
|
+
clientKeyPath?: string; // For mTLS
|
|
688
|
+
rejectUnauthorized?: boolean;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export interface ClientConfig {
|
|
692
|
+
// ... existing
|
|
693
|
+
tls?: ClientTlsConfig;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
connect(): Promise<void> {
|
|
697
|
+
// ...
|
|
698
|
+
|
|
699
|
+
const connectCallback = () => {
|
|
700
|
+
this.setState('HANDSHAKING');
|
|
701
|
+
this.sendHello();
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
if (this.config.tls?.enabled) {
|
|
705
|
+
const tlsOptions: tls.ConnectionOptions = {
|
|
706
|
+
rejectUnauthorized: this.config.tls.rejectUnauthorized ?? true,
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
if (this.config.tls.caPath) {
|
|
710
|
+
tlsOptions.ca = [fs.readFileSync(this.config.tls.caPath)];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (this.config.tls.clientCertPath && this.config.tls.clientKeyPath) {
|
|
714
|
+
tlsOptions.cert = fs.readFileSync(this.config.tls.clientCertPath);
|
|
715
|
+
tlsOptions.key = fs.readFileSync(this.config.tls.clientKeyPath);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this.socket = tls.connect(this.config.socketPath, tlsOptions, connectCallback);
|
|
719
|
+
} else {
|
|
720
|
+
this.socket = net.createConnection(this.config.socketPath, connectCallback);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ...
|
|
724
|
+
}
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
#### Storage Encryption
|
|
728
|
+
|
|
729
|
+
**Using SQLCipher or better-sqlite3 encryption**:
|
|
730
|
+
|
|
731
|
+
```typescript
|
|
732
|
+
// storage/sqlite-adapter.ts
|
|
733
|
+
|
|
734
|
+
export interface SqliteAdapterOptions {
|
|
735
|
+
dbPath: string;
|
|
736
|
+
messageRetentionMs?: number;
|
|
737
|
+
cleanupIntervalMs?: number;
|
|
738
|
+
|
|
739
|
+
// NEW
|
|
740
|
+
encryptionKey?: string; // Pro tier
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private async openDatabase(driver: SqliteDriverName): Promise<SqliteDatabase> {
|
|
744
|
+
if (driver === 'better-sqlite3') {
|
|
745
|
+
const mod = await import('better-sqlite3');
|
|
746
|
+
const DatabaseCtor: any = (mod as any).default ?? mod;
|
|
747
|
+
const db: any = new DatabaseCtor(this.dbPath);
|
|
748
|
+
|
|
749
|
+
// Enable encryption if key provided
|
|
750
|
+
if (this.encryptionKey) {
|
|
751
|
+
if (!hasFeature(this.licenseTier, 'storage_encryption')) {
|
|
752
|
+
throw new Error('Storage encryption requires Pro tier');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// SQLCipher compatible
|
|
756
|
+
db.pragma(`key = '${this.encryptionKey}'`);
|
|
757
|
+
|
|
758
|
+
// Verify encryption is working
|
|
759
|
+
try {
|
|
760
|
+
db.pragma('cipher_version');
|
|
761
|
+
} catch (e) {
|
|
762
|
+
throw new Error('SQLCipher not available. Install better-sqlite3 with SQLCipher support.');
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
db.pragma('journal_mode = WAL');
|
|
767
|
+
return db;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ... node:sqlite fallback
|
|
771
|
+
}
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
**CLI integration**:
|
|
775
|
+
|
|
776
|
+
```bash
|
|
777
|
+
# Generate encryption key
|
|
778
|
+
agent-relay keygen --output ~/.agent-relay/db.key
|
|
779
|
+
|
|
780
|
+
# Start with encryption
|
|
781
|
+
agent-relay up --db-key ~/.agent-relay/db.key
|
|
782
|
+
|
|
783
|
+
# Or via environment
|
|
784
|
+
export AGENT_RELAY_DB_KEY=$(cat ~/.agent-relay/db.key)
|
|
785
|
+
agent-relay up
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
---
|
|
789
|
+
|
|
790
|
+
### 3.4 Scale Optimizations
|
|
791
|
+
|
|
792
|
+
#### Overview
|
|
793
|
+
|
|
794
|
+
Pro tier enables optimizations for high-throughput scenarios:
|
|
795
|
+
- Batched message persistence
|
|
796
|
+
- Connection pooling
|
|
797
|
+
- Worker thread offloading
|
|
798
|
+
|
|
799
|
+
#### Batched Persistence
|
|
800
|
+
|
|
801
|
+
**Current problem**: Each message is persisted synchronously, blocking the router.
|
|
802
|
+
|
|
803
|
+
**Solution**: Batch writes with configurable flush interval.
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
// router.ts
|
|
807
|
+
|
|
808
|
+
export interface RouterOptions {
|
|
809
|
+
storage?: StorageAdapter;
|
|
810
|
+
registry?: AgentRegistry;
|
|
811
|
+
delivery?: Partial<DeliveryReliabilityOptions>;
|
|
812
|
+
|
|
813
|
+
// NEW: Pro tier optimizations
|
|
814
|
+
batchPersistence?: {
|
|
815
|
+
enabled: boolean;
|
|
816
|
+
flushIntervalMs: number; // Default: 50ms
|
|
817
|
+
maxBatchSize: number; // Default: 100
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export class Router {
|
|
822
|
+
private pendingPersist: DeliverEnvelope[] = [];
|
|
823
|
+
private persistTimer?: NodeJS.Timeout;
|
|
824
|
+
private batchConfig: Required<RouterOptions['batchPersistence']>;
|
|
825
|
+
|
|
826
|
+
constructor(options: RouterOptions = {}) {
|
|
827
|
+
// ...
|
|
828
|
+
|
|
829
|
+
this.batchConfig = {
|
|
830
|
+
enabled: options.batchPersistence?.enabled ?? false,
|
|
831
|
+
flushIntervalMs: options.batchPersistence?.flushIntervalMs ?? 50,
|
|
832
|
+
maxBatchSize: options.batchPersistence?.maxBatchSize ?? 100,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private persistDeliverEnvelope(envelope: DeliverEnvelope): void {
|
|
837
|
+
if (!this.storage) return;
|
|
838
|
+
|
|
839
|
+
if (this.batchConfig.enabled) {
|
|
840
|
+
this.queuePersist(envelope);
|
|
841
|
+
} else {
|
|
842
|
+
// Original sync behavior
|
|
843
|
+
this.storage.saveMessage({...}).catch(console.error);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private queuePersist(envelope: DeliverEnvelope): void {
|
|
848
|
+
this.pendingPersist.push(envelope);
|
|
849
|
+
|
|
850
|
+
// Flush immediately if batch is full
|
|
851
|
+
if (this.pendingPersist.length >= this.batchConfig.maxBatchSize) {
|
|
852
|
+
this.flushPersist();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Schedule flush
|
|
857
|
+
if (!this.persistTimer) {
|
|
858
|
+
this.persistTimer = setTimeout(() => {
|
|
859
|
+
this.flushPersist();
|
|
860
|
+
}, this.batchConfig.flushIntervalMs);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
private async flushPersist(): Promise<void> {
|
|
865
|
+
if (this.persistTimer) {
|
|
866
|
+
clearTimeout(this.persistTimer);
|
|
867
|
+
this.persistTimer = undefined;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const batch = this.pendingPersist;
|
|
871
|
+
this.pendingPersist = [];
|
|
872
|
+
|
|
873
|
+
if (batch.length === 0) return;
|
|
874
|
+
|
|
875
|
+
const messages = batch.map(e => ({
|
|
876
|
+
id: e.id,
|
|
877
|
+
ts: e.ts,
|
|
878
|
+
from: e.from ?? 'unknown',
|
|
879
|
+
to: e.to ?? 'unknown',
|
|
880
|
+
topic: e.topic,
|
|
881
|
+
kind: e.payload.kind,
|
|
882
|
+
body: e.payload.body,
|
|
883
|
+
data: e.payload.data,
|
|
884
|
+
thread: e.payload.thread,
|
|
885
|
+
deliverySeq: e.delivery.seq,
|
|
886
|
+
deliverySessionId: e.delivery.session_id,
|
|
887
|
+
sessionId: e.delivery.session_id,
|
|
888
|
+
status: 'unread' as const,
|
|
889
|
+
is_urgent: false,
|
|
890
|
+
}));
|
|
891
|
+
|
|
892
|
+
try {
|
|
893
|
+
await this.storage!.saveMessageBatch(messages);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
console.error('[router] Batch persist failed:', err);
|
|
896
|
+
// Re-queue failed batch (with limit to prevent infinite growth)
|
|
897
|
+
if (this.pendingPersist.length < 1000) {
|
|
898
|
+
this.pendingPersist.unshift(...batch);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
**SQLite batch insert**:
|
|
906
|
+
|
|
907
|
+
```typescript
|
|
908
|
+
// storage/sqlite-adapter.ts
|
|
909
|
+
|
|
910
|
+
async saveMessageBatch(messages: StoredMessage[]): Promise<void> {
|
|
911
|
+
if (!this.db) throw new Error('Not initialized');
|
|
912
|
+
|
|
913
|
+
const stmt = this.db.prepare(`
|
|
914
|
+
INSERT OR REPLACE INTO messages
|
|
915
|
+
(id, ts, sender, recipient, topic, kind, body, data, thread, delivery_seq, delivery_session_id, session_id, status, is_urgent)
|
|
916
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
917
|
+
`);
|
|
918
|
+
|
|
919
|
+
// Use transaction for atomicity and performance
|
|
920
|
+
this.db.exec('BEGIN TRANSACTION');
|
|
921
|
+
try {
|
|
922
|
+
for (const msg of messages) {
|
|
923
|
+
stmt.run(
|
|
924
|
+
msg.id, msg.ts, msg.from, msg.to, msg.topic ?? null,
|
|
925
|
+
msg.kind, msg.body, msg.data ? JSON.stringify(msg.data) : null,
|
|
926
|
+
msg.thread ?? null, msg.deliverySeq ?? null, msg.deliverySessionId ?? null,
|
|
927
|
+
msg.sessionId ?? null, msg.status, msg.is_urgent ? 1 : 0
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
this.db.exec('COMMIT');
|
|
931
|
+
} catch (err) {
|
|
932
|
+
this.db.exec('ROLLBACK');
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
#### Rate Limiting
|
|
939
|
+
|
|
940
|
+
**Per-connection rate limiting**:
|
|
941
|
+
|
|
942
|
+
```typescript
|
|
943
|
+
// daemon/connection.ts
|
|
944
|
+
|
|
945
|
+
interface RateLimitState {
|
|
946
|
+
tokens: number;
|
|
947
|
+
lastRefill: number;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
private rateLimit: RateLimitState = {
|
|
951
|
+
tokens: 100, // Messages per second
|
|
952
|
+
lastRefill: Date.now(),
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
private checkRateLimit(): boolean {
|
|
956
|
+
const now = Date.now();
|
|
957
|
+
const elapsed = now - this.rateLimit.lastRefill;
|
|
958
|
+
|
|
959
|
+
// Refill tokens (1 token per 10ms = 100/sec)
|
|
960
|
+
const refill = Math.floor(elapsed / 10);
|
|
961
|
+
if (refill > 0) {
|
|
962
|
+
this.rateLimit.tokens = Math.min(100, this.rateLimit.tokens + refill);
|
|
963
|
+
this.rateLimit.lastRefill = now;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (this.rateLimit.tokens <= 0) {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
this.rateLimit.tokens--;
|
|
971
|
+
return true;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private handleSend(envelope: Envelope<SendPayload>): void {
|
|
975
|
+
if (this._state !== 'ACTIVE') {
|
|
976
|
+
this.sendError('BAD_REQUEST', 'Not in ACTIVE state', false);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Check rate limit
|
|
981
|
+
if (!this.checkRateLimit()) {
|
|
982
|
+
this.send({
|
|
983
|
+
v: PROTOCOL_VERSION,
|
|
984
|
+
type: 'BUSY',
|
|
985
|
+
id: uuid(),
|
|
986
|
+
ts: Date.now(),
|
|
987
|
+
payload: {
|
|
988
|
+
retry_after_ms: 100,
|
|
989
|
+
queue_depth: 0,
|
|
990
|
+
},
|
|
991
|
+
});
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Forward to router
|
|
996
|
+
if (this.onMessage) {
|
|
997
|
+
this.onMessage(envelope);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
---
|
|
1003
|
+
|
|
1004
|
+
### 3.5 Message Retention
|
|
1005
|
+
|
|
1006
|
+
#### Overview
|
|
1007
|
+
|
|
1008
|
+
Configurable retention periods by tier:
|
|
1009
|
+
- Community: 7 days (fixed)
|
|
1010
|
+
- Pro: Up to 90 days
|
|
1011
|
+
- Team: Up to 365 days
|
|
1012
|
+
- Enterprise: Unlimited
|
|
1013
|
+
|
|
1014
|
+
#### Implementation
|
|
1015
|
+
|
|
1016
|
+
**Configuration**:
|
|
1017
|
+
|
|
1018
|
+
```typescript
|
|
1019
|
+
// storage/sqlite-adapter.ts
|
|
1020
|
+
|
|
1021
|
+
const RETENTION_LIMITS = {
|
|
1022
|
+
community: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
1023
|
+
pro: 90 * 24 * 60 * 60 * 1000, // 90 days
|
|
1024
|
+
team: 365 * 24 * 60 * 60 * 1000, // 365 days
|
|
1025
|
+
enterprise: Infinity, // Unlimited
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
export interface SqliteAdapterOptions {
|
|
1029
|
+
dbPath: string;
|
|
1030
|
+
messageRetentionMs?: number;
|
|
1031
|
+
cleanupIntervalMs?: number;
|
|
1032
|
+
licenseTier?: string;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
constructor(options: SqliteAdapterOptions) {
|
|
1036
|
+
this.dbPath = options.dbPath;
|
|
1037
|
+
|
|
1038
|
+
const tier = options.licenseTier || 'community';
|
|
1039
|
+
const maxRetention = RETENTION_LIMITS[tier] || RETENTION_LIMITS.community;
|
|
1040
|
+
|
|
1041
|
+
// User can set lower retention, but not higher than tier allows
|
|
1042
|
+
this.retentionMs = Math.min(
|
|
1043
|
+
options.messageRetentionMs ?? RETENTION_LIMITS.community,
|
|
1044
|
+
maxRetention
|
|
1045
|
+
);
|
|
1046
|
+
|
|
1047
|
+
this.cleanupIntervalMs = options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
|
|
1048
|
+
}
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
**CLI options**:
|
|
1052
|
+
|
|
1053
|
+
```bash
|
|
1054
|
+
# Set retention (Pro tier example)
|
|
1055
|
+
agent-relay up --retention 30d
|
|
1056
|
+
|
|
1057
|
+
# Query current retention
|
|
1058
|
+
agent-relay status --verbose
|
|
1059
|
+
# Output: Retention: 30 days (Pro tier max: 90 days)
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
---
|
|
1063
|
+
|
|
1064
|
+
### 3.6 Webhooks
|
|
1065
|
+
|
|
1066
|
+
#### Overview
|
|
1067
|
+
|
|
1068
|
+
Pro tier enables HTTP webhooks for external integrations:
|
|
1069
|
+
- Message events (sent, delivered, failed)
|
|
1070
|
+
- Agent events (connected, disconnected)
|
|
1071
|
+
- System events (daemon started, stopped)
|
|
1072
|
+
|
|
1073
|
+
#### Architecture
|
|
1074
|
+
|
|
1075
|
+
```
|
|
1076
|
+
src/
|
|
1077
|
+
webhooks/
|
|
1078
|
+
index.ts # Public API
|
|
1079
|
+
manager.ts # Registration and dispatch
|
|
1080
|
+
types.ts # Event definitions
|
|
1081
|
+
queue.ts # Async dispatch with retry
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
#### Event Types
|
|
1085
|
+
|
|
1086
|
+
```typescript
|
|
1087
|
+
// webhooks/types.ts
|
|
1088
|
+
|
|
1089
|
+
export type WebhookEvent =
|
|
1090
|
+
// Message events
|
|
1091
|
+
| 'message.sent'
|
|
1092
|
+
| 'message.delivered'
|
|
1093
|
+
| 'message.failed'
|
|
1094
|
+
|
|
1095
|
+
// Agent events
|
|
1096
|
+
| 'agent.connected'
|
|
1097
|
+
| 'agent.disconnected'
|
|
1098
|
+
|
|
1099
|
+
// System events
|
|
1100
|
+
| 'system.started'
|
|
1101
|
+
| 'system.stopped';
|
|
1102
|
+
|
|
1103
|
+
export interface WebhookPayload {
|
|
1104
|
+
event: WebhookEvent;
|
|
1105
|
+
timestamp: string;
|
|
1106
|
+
data: unknown;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
export interface MessageSentPayload {
|
|
1110
|
+
id: string;
|
|
1111
|
+
from: string;
|
|
1112
|
+
to: string;
|
|
1113
|
+
preview: string; // First 100 chars
|
|
1114
|
+
timestamp: string;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export interface AgentConnectedPayload {
|
|
1118
|
+
name: string;
|
|
1119
|
+
cli?: string;
|
|
1120
|
+
sessionId: string;
|
|
1121
|
+
timestamp: string;
|
|
1122
|
+
}
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
#### Database Schema
|
|
1126
|
+
|
|
1127
|
+
```sql
|
|
1128
|
+
CREATE TABLE webhooks (
|
|
1129
|
+
id TEXT PRIMARY KEY,
|
|
1130
|
+
url TEXT NOT NULL,
|
|
1131
|
+
events TEXT NOT NULL, -- JSON array of event types
|
|
1132
|
+
secret TEXT, -- For HMAC signature
|
|
1133
|
+
description TEXT,
|
|
1134
|
+
|
|
1135
|
+
-- Status
|
|
1136
|
+
enabled INTEGER DEFAULT 1,
|
|
1137
|
+
failure_count INTEGER DEFAULT 0,
|
|
1138
|
+
last_failure TEXT, -- Error message
|
|
1139
|
+
last_success_at INTEGER,
|
|
1140
|
+
|
|
1141
|
+
-- Metadata
|
|
1142
|
+
created_at INTEGER NOT NULL,
|
|
1143
|
+
updated_at INTEGER NOT NULL
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
CREATE TABLE webhook_deliveries (
|
|
1147
|
+
id TEXT PRIMARY KEY,
|
|
1148
|
+
webhook_id TEXT NOT NULL,
|
|
1149
|
+
event TEXT NOT NULL,
|
|
1150
|
+
payload TEXT NOT NULL,
|
|
1151
|
+
|
|
1152
|
+
-- Delivery status
|
|
1153
|
+
status TEXT NOT NULL, -- 'pending', 'success', 'failed'
|
|
1154
|
+
attempts INTEGER DEFAULT 0,
|
|
1155
|
+
last_attempt_at INTEGER,
|
|
1156
|
+
response_status INTEGER,
|
|
1157
|
+
response_body TEXT,
|
|
1158
|
+
error TEXT,
|
|
1159
|
+
|
|
1160
|
+
created_at INTEGER NOT NULL,
|
|
1161
|
+
|
|
1162
|
+
FOREIGN KEY (webhook_id) REFERENCES webhooks (id)
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
CREATE INDEX idx_webhook_deliveries_status ON webhook_deliveries (status);
|
|
1166
|
+
CREATE INDEX idx_webhook_deliveries_webhook ON webhook_deliveries (webhook_id);
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
#### Webhook Manager
|
|
1170
|
+
|
|
1171
|
+
```typescript
|
|
1172
|
+
// webhooks/manager.ts
|
|
1173
|
+
|
|
1174
|
+
import crypto from 'crypto';
|
|
1175
|
+
|
|
1176
|
+
export interface WebhookConfig {
|
|
1177
|
+
url: string;
|
|
1178
|
+
events: WebhookEvent[];
|
|
1179
|
+
secret?: string;
|
|
1180
|
+
description?: string;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
export interface WebhookDelivery {
|
|
1184
|
+
id: string;
|
|
1185
|
+
webhookId: string;
|
|
1186
|
+
event: WebhookEvent;
|
|
1187
|
+
payload: unknown;
|
|
1188
|
+
status: 'pending' | 'success' | 'failed';
|
|
1189
|
+
attempts: number;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
export class WebhookManager {
|
|
1193
|
+
private storage: StorageAdapter;
|
|
1194
|
+
private queue: AsyncQueue<WebhookDelivery>;
|
|
1195
|
+
|
|
1196
|
+
constructor(storage: StorageAdapter) {
|
|
1197
|
+
this.storage = storage;
|
|
1198
|
+
this.queue = new AsyncQueue(this.deliverWebhook.bind(this), {
|
|
1199
|
+
concurrency: 5,
|
|
1200
|
+
retryAttempts: 3,
|
|
1201
|
+
retryDelayMs: 1000,
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async register(config: WebhookConfig): Promise<string> {
|
|
1206
|
+
const id = crypto.randomUUID();
|
|
1207
|
+
const now = Date.now();
|
|
1208
|
+
|
|
1209
|
+
await this.storage.exec(`
|
|
1210
|
+
INSERT INTO webhooks (id, url, events, secret, description, created_at, updated_at)
|
|
1211
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1212
|
+
`, [
|
|
1213
|
+
id,
|
|
1214
|
+
config.url,
|
|
1215
|
+
JSON.stringify(config.events),
|
|
1216
|
+
config.secret || null,
|
|
1217
|
+
config.description || null,
|
|
1218
|
+
now,
|
|
1219
|
+
now,
|
|
1220
|
+
]);
|
|
1221
|
+
|
|
1222
|
+
return id;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async dispatch(event: WebhookEvent, data: unknown): Promise<void> {
|
|
1226
|
+
// Find webhooks subscribed to this event
|
|
1227
|
+
const webhooks = await this.storage.all(`
|
|
1228
|
+
SELECT * FROM webhooks
|
|
1229
|
+
WHERE enabled = 1 AND events LIKE ?
|
|
1230
|
+
`, [`%"${event}"%`]);
|
|
1231
|
+
|
|
1232
|
+
for (const webhook of webhooks) {
|
|
1233
|
+
const deliveryId = crypto.randomUUID();
|
|
1234
|
+
const payload = {
|
|
1235
|
+
event,
|
|
1236
|
+
timestamp: new Date().toISOString(),
|
|
1237
|
+
data,
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
// Record delivery attempt
|
|
1241
|
+
await this.storage.exec(`
|
|
1242
|
+
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
|
|
1243
|
+
VALUES (?, ?, ?, ?, 'pending', ?)
|
|
1244
|
+
`, [deliveryId, webhook.id, event, JSON.stringify(payload), Date.now()]);
|
|
1245
|
+
|
|
1246
|
+
// Queue for async delivery
|
|
1247
|
+
this.queue.push({
|
|
1248
|
+
id: deliveryId,
|
|
1249
|
+
webhookId: webhook.id,
|
|
1250
|
+
event,
|
|
1251
|
+
payload,
|
|
1252
|
+
status: 'pending',
|
|
1253
|
+
attempts: 0,
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
private async deliverWebhook(delivery: WebhookDelivery): Promise<void> {
|
|
1259
|
+
const webhook = await this.storage.get(`SELECT * FROM webhooks WHERE id = ?`, [delivery.webhookId]);
|
|
1260
|
+
if (!webhook) return;
|
|
1261
|
+
|
|
1262
|
+
const body = JSON.stringify(delivery.payload);
|
|
1263
|
+
const headers: Record<string, string> = {
|
|
1264
|
+
'Content-Type': 'application/json',
|
|
1265
|
+
'X-Webhook-Event': delivery.event,
|
|
1266
|
+
'X-Webhook-Delivery': delivery.id,
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
// Add HMAC signature if secret is configured
|
|
1270
|
+
if (webhook.secret) {
|
|
1271
|
+
const signature = crypto
|
|
1272
|
+
.createHmac('sha256', webhook.secret)
|
|
1273
|
+
.update(body)
|
|
1274
|
+
.digest('hex');
|
|
1275
|
+
headers['X-Webhook-Signature'] = `sha256=${signature}`;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
try {
|
|
1279
|
+
const response = await fetch(webhook.url, {
|
|
1280
|
+
method: 'POST',
|
|
1281
|
+
headers,
|
|
1282
|
+
body,
|
|
1283
|
+
signal: AbortSignal.timeout(10000), // 10s timeout
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
await this.storage.exec(`
|
|
1287
|
+
UPDATE webhook_deliveries
|
|
1288
|
+
SET status = ?, attempts = attempts + 1, last_attempt_at = ?, response_status = ?
|
|
1289
|
+
WHERE id = ?
|
|
1290
|
+
`, [
|
|
1291
|
+
response.ok ? 'success' : 'failed',
|
|
1292
|
+
Date.now(),
|
|
1293
|
+
response.status,
|
|
1294
|
+
delivery.id,
|
|
1295
|
+
]);
|
|
1296
|
+
|
|
1297
|
+
if (response.ok) {
|
|
1298
|
+
await this.storage.exec(`
|
|
1299
|
+
UPDATE webhooks SET failure_count = 0, last_success_at = ? WHERE id = ?
|
|
1300
|
+
`, [Date.now(), webhook.id]);
|
|
1301
|
+
} else {
|
|
1302
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1303
|
+
}
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
await this.storage.exec(`
|
|
1306
|
+
UPDATE webhooks SET failure_count = failure_count + 1, last_failure = ? WHERE id = ?
|
|
1307
|
+
`, [err.message, webhook.id]);
|
|
1308
|
+
|
|
1309
|
+
await this.storage.exec(`
|
|
1310
|
+
UPDATE webhook_deliveries SET status = 'failed', error = ?, attempts = attempts + 1 WHERE id = ?
|
|
1311
|
+
`, [err.message, delivery.id]);
|
|
1312
|
+
|
|
1313
|
+
throw err; // Let queue handle retry
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
#### Integration Points
|
|
1320
|
+
|
|
1321
|
+
**Router integration** (`router.ts`):
|
|
1322
|
+
|
|
1323
|
+
```typescript
|
|
1324
|
+
export class Router {
|
|
1325
|
+
private webhookManager?: WebhookManager;
|
|
1326
|
+
|
|
1327
|
+
constructor(options: RouterOptions = {}) {
|
|
1328
|
+
// ...
|
|
1329
|
+
if (options.webhookManager) {
|
|
1330
|
+
this.webhookManager = options.webhookManager;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
private sendDirect(from: string, to: string, envelope: SendEnvelope): boolean {
|
|
1335
|
+
// ... existing delivery logic
|
|
1336
|
+
|
|
1337
|
+
// Dispatch webhook
|
|
1338
|
+
if (this.webhookManager && sent) {
|
|
1339
|
+
this.webhookManager.dispatch('message.delivered', {
|
|
1340
|
+
id: deliver.id,
|
|
1341
|
+
from,
|
|
1342
|
+
to,
|
|
1343
|
+
preview: envelope.payload.body.substring(0, 100),
|
|
1344
|
+
timestamp: new Date(deliver.ts).toISOString(),
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return sent;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
**Server integration** (`daemon/server.ts`):
|
|
1354
|
+
|
|
1355
|
+
```typescript
|
|
1356
|
+
connection.onActive = () => {
|
|
1357
|
+
// ... existing registration logic
|
|
1358
|
+
|
|
1359
|
+
// Dispatch webhook
|
|
1360
|
+
if (this.webhookManager && connection.agentName) {
|
|
1361
|
+
this.webhookManager.dispatch('agent.connected', {
|
|
1362
|
+
name: connection.agentName,
|
|
1363
|
+
cli: connection.cli,
|
|
1364
|
+
sessionId: connection.sessionId,
|
|
1365
|
+
timestamp: new Date().toISOString(),
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
connection.onClose = () => {
|
|
1371
|
+
// ... existing cleanup logic
|
|
1372
|
+
|
|
1373
|
+
// Dispatch webhook
|
|
1374
|
+
if (this.webhookManager && connection.agentName) {
|
|
1375
|
+
this.webhookManager.dispatch('agent.disconnected', {
|
|
1376
|
+
name: connection.agentName,
|
|
1377
|
+
sessionId: connection.sessionId,
|
|
1378
|
+
timestamp: new Date().toISOString(),
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
#### Dashboard API
|
|
1385
|
+
|
|
1386
|
+
```typescript
|
|
1387
|
+
// dashboard/server.ts
|
|
1388
|
+
|
|
1389
|
+
// List webhooks
|
|
1390
|
+
app.get('/api/webhooks', requirePro, async (req, res) => {
|
|
1391
|
+
const webhooks = await storage.all(`
|
|
1392
|
+
SELECT id, url, events, description, enabled, failure_count, last_success_at, created_at
|
|
1393
|
+
FROM webhooks
|
|
1394
|
+
ORDER BY created_at DESC
|
|
1395
|
+
`);
|
|
1396
|
+
|
|
1397
|
+
res.json({
|
|
1398
|
+
webhooks: webhooks.map(w => ({
|
|
1399
|
+
id: w.id,
|
|
1400
|
+
url: w.url,
|
|
1401
|
+
events: JSON.parse(w.events),
|
|
1402
|
+
description: w.description,
|
|
1403
|
+
enabled: !!w.enabled,
|
|
1404
|
+
failureCount: w.failure_count,
|
|
1405
|
+
lastSuccessAt: w.last_success_at ? new Date(w.last_success_at).toISOString() : null,
|
|
1406
|
+
createdAt: new Date(w.created_at).toISOString(),
|
|
1407
|
+
})),
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
// Create webhook
|
|
1412
|
+
app.post('/api/webhooks', requirePro, async (req, res) => {
|
|
1413
|
+
const { url, events, secret, description } = req.body;
|
|
1414
|
+
|
|
1415
|
+
if (!url || !events?.length) {
|
|
1416
|
+
return res.status(400).json({ error: 'URL and events are required' });
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Validate URL
|
|
1420
|
+
try {
|
|
1421
|
+
new URL(url);
|
|
1422
|
+
} catch {
|
|
1423
|
+
return res.status(400).json({ error: 'Invalid URL' });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Validate events
|
|
1427
|
+
const validEvents = ['message.sent', 'message.delivered', 'agent.connected', 'agent.disconnected'];
|
|
1428
|
+
if (!events.every(e => validEvents.includes(e))) {
|
|
1429
|
+
return res.status(400).json({ error: 'Invalid event type' });
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const id = await webhookManager.register({ url, events, secret, description });
|
|
1433
|
+
|
|
1434
|
+
res.json({ id });
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
// Test webhook
|
|
1438
|
+
app.post('/api/webhooks/:id/test', requirePro, async (req, res) => {
|
|
1439
|
+
const { id } = req.params;
|
|
1440
|
+
|
|
1441
|
+
await webhookManager.dispatch('test', {
|
|
1442
|
+
message: 'This is a test webhook from Agent Relay',
|
|
1443
|
+
webhookId: id,
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
res.json({ success: true, message: 'Test webhook dispatched' });
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// Delete webhook
|
|
1450
|
+
app.delete('/api/webhooks/:id', requirePro, async (req, res) => {
|
|
1451
|
+
const { id } = req.params;
|
|
1452
|
+
|
|
1453
|
+
await storage.exec(`DELETE FROM webhooks WHERE id = ?`, [id]);
|
|
1454
|
+
|
|
1455
|
+
res.json({ success: true });
|
|
1456
|
+
});
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
---
|
|
1460
|
+
|
|
1461
|
+
## 4. Implementation Roadmap
|
|
1462
|
+
|
|
1463
|
+
### Phase 1: Foundation (Week 1-2)
|
|
1464
|
+
|
|
1465
|
+
| Task | Priority | Effort | Dependencies |
|
|
1466
|
+
|------|----------|--------|--------------|
|
|
1467
|
+
| Licensing system | P0 | 2 days | None |
|
|
1468
|
+
| Agent limits enforcement | P0 | 1 day | Licensing |
|
|
1469
|
+
| Feature flag system | P0 | 1 day | Licensing |
|
|
1470
|
+
| Dashboard license display | P1 | 1 day | Licensing |
|
|
1471
|
+
|
|
1472
|
+
**Milestone**: Free tier has enforced limits, upgrade prompts appear.
|
|
1473
|
+
|
|
1474
|
+
### Phase 2: Pro Features (Week 3-4)
|
|
1475
|
+
|
|
1476
|
+
| Task | Priority | Effort | Dependencies |
|
|
1477
|
+
|------|----------|--------|--------------|
|
|
1478
|
+
| API key management | P0 | 3 days | Licensing |
|
|
1479
|
+
| Connection authentication | P0 | 2 days | API keys |
|
|
1480
|
+
| Message retention config | P1 | 1 day | Licensing |
|
|
1481
|
+
| Webhooks | P1 | 3 days | API keys |
|
|
1482
|
+
| TLS encryption | P2 | 3 days | None |
|
|
1483
|
+
| Storage encryption | P2 | 2 days | TLS |
|
|
1484
|
+
|
|
1485
|
+
**Milestone**: Pro tier fully functional, customers can upgrade.
|
|
1486
|
+
|
|
1487
|
+
### Phase 3: Scale & Polish (Week 5-6)
|
|
1488
|
+
|
|
1489
|
+
| Task | Priority | Effort | Dependencies |
|
|
1490
|
+
|------|----------|--------|--------------|
|
|
1491
|
+
| Batched persistence | P1 | 2 days | None |
|
|
1492
|
+
| Rate limiting | P1 | 1 day | None |
|
|
1493
|
+
| Dashboard API key UI | P1 | 2 days | API keys |
|
|
1494
|
+
| Dashboard webhook UI | P1 | 2 days | Webhooks |
|
|
1495
|
+
| Documentation | P0 | 3 days | All |
|
|
1496
|
+
|
|
1497
|
+
**Milestone**: Production-ready Pro tier with full documentation.
|
|
1498
|
+
|
|
1499
|
+
### Phase 4: Team Tier (Week 7-10)
|
|
1500
|
+
|
|
1501
|
+
| Task | Priority | Effort | Dependencies |
|
|
1502
|
+
|------|----------|--------|--------------|
|
|
1503
|
+
| TCP transport | P0 | 5 days | TLS |
|
|
1504
|
+
| Multi-machine routing | P0 | 5 days | TCP |
|
|
1505
|
+
| SSO (SAML/OIDC) | P1 | 5 days | Auth |
|
|
1506
|
+
| Audit logs | P1 | 3 days | Storage |
|
|
1507
|
+
| Team dashboard | P1 | 5 days | Multi-machine |
|
|
1508
|
+
|
|
1509
|
+
**Milestone**: Team tier available for multi-machine deployments.
|
|
1510
|
+
|
|
1511
|
+
---
|
|
1512
|
+
|
|
1513
|
+
## 5. Pricing Justification
|
|
1514
|
+
|
|
1515
|
+
### Cost Analysis
|
|
1516
|
+
|
|
1517
|
+
| Component | Monthly Cost | Notes |
|
|
1518
|
+
|-----------|-------------|-------|
|
|
1519
|
+
| License server | $20 | Fly.io, minimal traffic |
|
|
1520
|
+
| Support (Pro) | $100/customer | ~2 emails/month |
|
|
1521
|
+
| Support (Team) | $300/customer | ~5 emails/month |
|
|
1522
|
+
| Development | $10,000/month | 1 FTE amortized |
|
|
1523
|
+
|
|
1524
|
+
### Break-even Analysis
|
|
1525
|
+
|
|
1526
|
+
| Tier | Price | Customers Needed | Notes |
|
|
1527
|
+
|------|-------|------------------|-------|
|
|
1528
|
+
| Pro @ $29/mo | $29 | 350 | Cover dev costs |
|
|
1529
|
+
| Pro @ $49/mo | $49 | 210 | Cover dev costs |
|
|
1530
|
+
| Team @ $149/mo | $149 | 70 | Cover dev costs |
|
|
1531
|
+
|
|
1532
|
+
### Competitive Pricing
|
|
1533
|
+
|
|
1534
|
+
| Competitor | Comparable Tier | Our Price | Delta |
|
|
1535
|
+
|-----------|-----------------|-----------|-------|
|
|
1536
|
+
| GitLab Premium | $29/user/mo | $49/machine/mo | -40% |
|
|
1537
|
+
| Sidekiq Pro | $99/app/mo | $49/machine/mo | -50% |
|
|
1538
|
+
| Redis Enterprise | $100+/mo | $49/machine/mo | -50% |
|
|
1539
|
+
|
|
1540
|
+
**Recommendation**: Start at $49/mo for Pro, $149/mo for Team. Lower than competitors establishes value.
|
|
1541
|
+
|
|
1542
|
+
---
|
|
1543
|
+
|
|
1544
|
+
## 6. Risks & Mitigations
|
|
1545
|
+
|
|
1546
|
+
### Technical Risks
|
|
1547
|
+
|
|
1548
|
+
| Risk | Probability | Impact | Mitigation |
|
|
1549
|
+
|------|-------------|--------|------------|
|
|
1550
|
+
| SQLCipher compatibility | Medium | High | Test on all supported platforms |
|
|
1551
|
+
| TLS performance overhead | Low | Medium | Benchmark before release |
|
|
1552
|
+
| Webhook delivery failures | Medium | Low | Retry queue with exponential backoff |
|
|
1553
|
+
| License server downtime | Low | High | Offline license for Enterprise |
|
|
1554
|
+
|
|
1555
|
+
### Business Risks
|
|
1556
|
+
|
|
1557
|
+
| Risk | Probability | Impact | Mitigation |
|
|
1558
|
+
|------|-------------|--------|------------|
|
|
1559
|
+
| Open source fork with Pro features | Medium | High | Move fast, focus on support value |
|
|
1560
|
+
| Pricing too high | Medium | Medium | Start low, increase with value |
|
|
1561
|
+
| Pricing too low | Low | Low | Easy to increase, hard to decrease |
|
|
1562
|
+
| Support burden | High | Medium | Good docs, FAQ, community forums |
|
|
1563
|
+
|
|
1564
|
+
### Legal Considerations
|
|
1565
|
+
|
|
1566
|
+
1. **License clarity**: MIT for core, proprietary for Pro features
|
|
1567
|
+
2. **Terms of service**: Required for commercial tiers
|
|
1568
|
+
3. **Privacy policy**: Required if collecting telemetry
|
|
1569
|
+
4. **GDPR compliance**: If serving EU customers
|
|
1570
|
+
|
|
1571
|
+
---
|
|
1572
|
+
|
|
1573
|
+
## Appendix A: CLI Commands
|
|
1574
|
+
|
|
1575
|
+
```bash
|
|
1576
|
+
# License management
|
|
1577
|
+
agent-relay license show
|
|
1578
|
+
agent-relay license activate <key>
|
|
1579
|
+
agent-relay license deactivate
|
|
1580
|
+
|
|
1581
|
+
# API key management (Pro)
|
|
1582
|
+
agent-relay keys list
|
|
1583
|
+
agent-relay keys create --name "CI Pipeline" --scopes send,receive
|
|
1584
|
+
agent-relay keys revoke <key-id>
|
|
1585
|
+
|
|
1586
|
+
# Webhook management (Pro)
|
|
1587
|
+
agent-relay webhooks list
|
|
1588
|
+
agent-relay webhooks add --url https://example.com/hook --events message.delivered
|
|
1589
|
+
agent-relay webhooks test <webhook-id>
|
|
1590
|
+
agent-relay webhooks delete <webhook-id>
|
|
1591
|
+
|
|
1592
|
+
# TLS (Pro)
|
|
1593
|
+
agent-relay up --tls-cert ./cert.pem --tls-key ./key.pem
|
|
1594
|
+
agent-relay up --tls-mutual --tls-ca ./ca.pem
|
|
1595
|
+
|
|
1596
|
+
# Retention (Pro)
|
|
1597
|
+
agent-relay up --retention 30d
|
|
1598
|
+
|
|
1599
|
+
# Storage encryption (Pro)
|
|
1600
|
+
agent-relay keygen --output ./db.key
|
|
1601
|
+
agent-relay up --db-key ./db.key
|
|
1602
|
+
```
|
|
1603
|
+
|
|
1604
|
+
---
|
|
1605
|
+
|
|
1606
|
+
## Appendix B: Environment Variables
|
|
1607
|
+
|
|
1608
|
+
```bash
|
|
1609
|
+
# License
|
|
1610
|
+
AGENT_RELAY_LICENSE_KEY=ar_pro_xxxx
|
|
1611
|
+
|
|
1612
|
+
# Authentication
|
|
1613
|
+
AGENT_RELAY_REQUIRE_AUTH=true
|
|
1614
|
+
AGENT_RELAY_API_KEY=ar_key_xxxx # For clients
|
|
1615
|
+
|
|
1616
|
+
# TLS
|
|
1617
|
+
AGENT_RELAY_TLS_CERT=/path/to/cert.pem
|
|
1618
|
+
AGENT_RELAY_TLS_KEY=/path/to/key.pem
|
|
1619
|
+
AGENT_RELAY_TLS_CA=/path/to/ca.pem
|
|
1620
|
+
AGENT_RELAY_TLS_MUTUAL=true
|
|
1621
|
+
|
|
1622
|
+
# Storage
|
|
1623
|
+
AGENT_RELAY_DB_KEY=<encryption-key>
|
|
1624
|
+
AGENT_RELAY_RETENTION_DAYS=30
|
|
1625
|
+
|
|
1626
|
+
# Telemetry (opt-in)
|
|
1627
|
+
AGENT_RELAY_TELEMETRY=true
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
---
|
|
1631
|
+
|
|
1632
|
+
## Appendix C: Dashboard Mockups
|
|
1633
|
+
|
|
1634
|
+
### Pro Features Panel
|
|
1635
|
+
|
|
1636
|
+
```
|
|
1637
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1638
|
+
│ License: Pro Expires: 2026-01-15 │
|
|
1639
|
+
├─────────────────────────────────────────────────────────────┤
|
|
1640
|
+
│ Agents: 47/100 Messages today: 12,847 │
|
|
1641
|
+
│ Retention: 30 days Webhooks: 3 active │
|
|
1642
|
+
└─────────────────────────────────────────────────────────────┘
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
### API Keys Management
|
|
1646
|
+
|
|
1647
|
+
```
|
|
1648
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1649
|
+
│ API Keys [+ Create] │
|
|
1650
|
+
├─────────────────────────────────────────────────────────────┤
|
|
1651
|
+
│ Name Key Last Used Actions │
|
|
1652
|
+
│ ───────────── ───────────────── ───────────── ───────── │
|
|
1653
|
+
│ CI Pipeline ar_key_a1b2... 2 hours ago [Revoke] │
|
|
1654
|
+
│ Dev Machine ar_key_x9y8... 5 mins ago [Revoke] │
|
|
1655
|
+
│ Staging ar_key_m3n4... Never [Revoke] │
|
|
1656
|
+
└─────────────────────────────────────────────────────────────┘
|
|
1657
|
+
```
|
|
1658
|
+
|
|
1659
|
+
### Webhooks Management
|
|
1660
|
+
|
|
1661
|
+
```
|
|
1662
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
1663
|
+
│ Webhooks [+ Create] │
|
|
1664
|
+
├─────────────────────────────────────────────────────────────┤
|
|
1665
|
+
│ URL Events Status │
|
|
1666
|
+
│ ───────────────────────────── ───────────── ───────── │
|
|
1667
|
+
│ https://api.slack.com/hook agent.* ✓ Active │
|
|
1668
|
+
│ https://my-app.com/webhook message.* ✓ Active │
|
|
1669
|
+
│ https://old-service.com/hook all ✗ Failing │
|
|
1670
|
+
└─────────────────────────────────────────────────────────────┘
|
|
1671
|
+
```
|
|
1672
|
+
|
|
1673
|
+
---
|
|
1674
|
+
|
|
1675
|
+
## Changelog
|
|
1676
|
+
|
|
1677
|
+
| Version | Date | Changes |
|
|
1678
|
+
|---------|------|---------|
|
|
1679
|
+
| 0.1 | 2025-12-26 | Initial draft |
|