@agentlip/hub 0.1.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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * In-memory rate limiter supporting:
3
+ * - Global rate limiting (all requests)
4
+ * - Per-client rate limiting (keyed by auth token or 'anon')
5
+ *
6
+ * Uses token bucket algorithm with configurable window.
7
+ * Thread-safe for single-process usage.
8
+ *
9
+ * Security: client keys derived from auth tokens are never logged.
10
+ */
11
+
12
+ /**
13
+ * Bucket state for token bucket algorithm.
14
+ */
15
+ interface Bucket {
16
+ tokens: number;
17
+ lastRefill: number;
18
+ }
19
+
20
+ /**
21
+ * Rate limiter configuration.
22
+ */
23
+ export interface RateLimiterConfig {
24
+ /** Max requests per window */
25
+ limit: number;
26
+ /** Window size in milliseconds */
27
+ windowMs: number;
28
+ }
29
+
30
+ /**
31
+ * Rate limit check result.
32
+ */
33
+ export interface RateLimitResult {
34
+ allowed: boolean;
35
+ limit: number;
36
+ remaining: number;
37
+ /** Reset time as Unix timestamp (seconds) */
38
+ resetAt: number;
39
+ }
40
+
41
+ /**
42
+ * In-memory rate limiter using token bucket algorithm.
43
+ */
44
+ export class RateLimiter {
45
+ private buckets: Map<string, Bucket> = new Map();
46
+ private readonly limit: number;
47
+ private readonly windowMs: number;
48
+
49
+ constructor(config: RateLimiterConfig) {
50
+ this.limit = config.limit;
51
+ this.windowMs = config.windowMs;
52
+ }
53
+
54
+ /**
55
+ * Check if a request is allowed for the given key.
56
+ * Consumes a token if allowed.
57
+ *
58
+ * @param key - Unique identifier (e.g., 'global' or hashed client key)
59
+ * @returns Rate limit result
60
+ */
61
+ check(key: string): RateLimitResult {
62
+ const now = Date.now();
63
+ let bucket = this.buckets.get(key);
64
+
65
+ if (!bucket) {
66
+ // Initialize new bucket
67
+ bucket = {
68
+ tokens: this.limit,
69
+ lastRefill: now,
70
+ };
71
+ this.buckets.set(key, bucket);
72
+ }
73
+
74
+ // Calculate tokens to add based on elapsed time
75
+ const elapsed = now - bucket.lastRefill;
76
+ const tokensToAdd = (elapsed / this.windowMs) * this.limit;
77
+
78
+ // Refill bucket (capped at limit)
79
+ bucket.tokens = Math.min(this.limit, bucket.tokens + tokensToAdd);
80
+ bucket.lastRefill = now;
81
+
82
+ // Calculate reset time (when bucket would be full)
83
+ const tokensNeeded = this.limit - bucket.tokens;
84
+ const msUntilFull = tokensNeeded > 0 ? (tokensNeeded / this.limit) * this.windowMs : 0;
85
+ const resetAt = Math.ceil((now + msUntilFull) / 1000);
86
+
87
+ if (bucket.tokens >= 1) {
88
+ // Consume a token
89
+ bucket.tokens -= 1;
90
+ return {
91
+ allowed: true,
92
+ limit: this.limit,
93
+ remaining: Math.floor(bucket.tokens),
94
+ resetAt,
95
+ };
96
+ }
97
+
98
+ // Rate limited
99
+ return {
100
+ allowed: false,
101
+ limit: this.limit,
102
+ remaining: 0,
103
+ resetAt,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Clean up expired buckets to prevent memory leaks.
109
+ * Call periodically (e.g., every minute).
110
+ */
111
+ cleanup(): void {
112
+ const now = Date.now();
113
+ const expireAfter = this.windowMs * 2; // Keep buckets for 2x window
114
+
115
+ for (const [key, bucket] of this.buckets) {
116
+ if (now - bucket.lastRefill > expireAfter) {
117
+ this.buckets.delete(key);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Reset all buckets (for testing).
124
+ */
125
+ reset(): void {
126
+ this.buckets.clear();
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Default rate limiter configurations (from plan).
132
+ */
133
+ export const DEFAULT_RATE_LIMITS = {
134
+ /** Per-client rate limit: 100 req/s */
135
+ perClient: { limit: 100, windowMs: 1000 },
136
+ /** Global rate limit: 1000 req/s */
137
+ global: { limit: 1000, windowMs: 1000 },
138
+ };
139
+
140
+ /**
141
+ * Hub rate limiter instance managing both global and per-client limits.
142
+ */
143
+ export class HubRateLimiter {
144
+ private globalLimiter: RateLimiter;
145
+ private clientLimiter: RateLimiter;
146
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
147
+
148
+ constructor(
149
+ globalConfig: RateLimiterConfig = DEFAULT_RATE_LIMITS.global,
150
+ clientConfig: RateLimiterConfig = DEFAULT_RATE_LIMITS.perClient
151
+ ) {
152
+ this.globalLimiter = new RateLimiter(globalConfig);
153
+ this.clientLimiter = new RateLimiter(clientConfig);
154
+ }
155
+
156
+ /**
157
+ * Start periodic cleanup of expired buckets.
158
+ * @param intervalMs - Cleanup interval (default: 60s)
159
+ */
160
+ startCleanup(intervalMs = 60000): void {
161
+ if (this.cleanupInterval) return;
162
+ this.cleanupInterval = setInterval(() => {
163
+ this.globalLimiter.cleanup();
164
+ this.clientLimiter.cleanup();
165
+ }, intervalMs);
166
+ }
167
+
168
+ /**
169
+ * Stop cleanup timer.
170
+ */
171
+ stopCleanup(): void {
172
+ if (this.cleanupInterval) {
173
+ clearInterval(this.cleanupInterval);
174
+ this.cleanupInterval = null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Derive a stable client key from request.
180
+ * Uses Authorization header if present, otherwise 'anon'.
181
+ *
182
+ * Security: We hash the token to avoid storing it directly.
183
+ * The key is never logged.
184
+ *
185
+ * @param req - HTTP request
186
+ * @returns Stable client key (never the raw token)
187
+ */
188
+ private getClientKey(req: Request): string {
189
+ const authHeader = req.headers.get("Authorization");
190
+ if (!authHeader) {
191
+ return "anon";
192
+ }
193
+ // Use a hash of the auth header to create a stable key
194
+ // without storing the actual token
195
+ const hasher = new Bun.CryptoHasher("sha256");
196
+ hasher.update(authHeader);
197
+ return `client:${hasher.digest("hex").substring(0, 16)}`;
198
+ }
199
+
200
+ /**
201
+ * Check rate limits for a request.
202
+ * Checks both global and per-client limits.
203
+ *
204
+ * @param req - HTTP request
205
+ * @returns Combined rate limit result
206
+ */
207
+ check(req: Request): RateLimitResult {
208
+ // Check global limit first
209
+ const globalResult = this.globalLimiter.check("global");
210
+ if (!globalResult.allowed) {
211
+ return globalResult;
212
+ }
213
+
214
+ // Check per-client limit
215
+ const clientKey = this.getClientKey(req);
216
+ const clientResult = this.clientLimiter.check(clientKey);
217
+
218
+ // Return the more restrictive result
219
+ return {
220
+ allowed: clientResult.allowed,
221
+ limit: clientResult.limit,
222
+ remaining: clientResult.remaining,
223
+ resetAt: clientResult.resetAt,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Reset all rate limiters (for testing).
229
+ */
230
+ reset(): void {
231
+ this.globalLimiter.reset();
232
+ this.clientLimiter.reset();
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Create a 429 Too Many Requests response with standard headers.
238
+ *
239
+ * @param result - Rate limit result
240
+ * @returns HTTP 429 response
241
+ */
242
+ export function rateLimitedResponse(result: RateLimitResult): Response {
243
+ const retryAfter = Math.max(0, result.resetAt - Math.floor(Date.now() / 1000));
244
+
245
+ return new Response(
246
+ JSON.stringify({
247
+ error: "Rate limit exceeded",
248
+ code: "RATE_LIMITED",
249
+ details: {
250
+ limit: result.limit,
251
+ window: "1s",
252
+ retry_after: retryAfter,
253
+ },
254
+ }),
255
+ {
256
+ status: 429,
257
+ headers: {
258
+ "Content-Type": "application/json",
259
+ "X-RateLimit-Limit": String(result.limit),
260
+ "X-RateLimit-Remaining": "0",
261
+ "X-RateLimit-Reset": String(result.resetAt),
262
+ "Retry-After": String(retryAfter),
263
+ },
264
+ }
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Add rate limit headers to a successful response.
270
+ *
271
+ * @param response - Original response
272
+ * @param result - Rate limit result
273
+ * @returns Response with rate limit headers
274
+ */
275
+ export function addRateLimitHeaders(response: Response, result: RateLimitResult): Response {
276
+ const newHeaders = new Headers(response.headers);
277
+ newHeaders.set("X-RateLimit-Limit", String(result.limit));
278
+ newHeaders.set("X-RateLimit-Remaining", String(result.remaining));
279
+ newHeaders.set("X-RateLimit-Reset", String(result.resetAt));
280
+
281
+ return new Response(response.body, {
282
+ status: response.status,
283
+ statusText: response.statusText,
284
+ headers: newHeaders,
285
+ });
286
+ }
@@ -0,0 +1,138 @@
1
+ import {
2
+ mkdir,
3
+ writeFile,
4
+ readFile,
5
+ unlink,
6
+ chmod,
7
+ stat,
8
+ rename,
9
+ } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { randomBytes } from "node:crypto";
12
+
13
+ export interface ServerJsonData {
14
+ instance_id: string;
15
+ db_id: string;
16
+ port: number;
17
+ host: string;
18
+ auth_token: string;
19
+ pid: number;
20
+ started_at: string;
21
+ protocol_version: string;
22
+ schema_version?: number;
23
+ }
24
+
25
+ function agentlipDir(workspaceRoot: string): string {
26
+ return join(workspaceRoot, ".agentlip");
27
+ }
28
+
29
+ function serverJsonPath(workspaceRoot: string): string {
30
+ return join(agentlipDir(workspaceRoot), "server.json");
31
+ }
32
+
33
+ async function ensureMode0600(filePath: string): Promise<void> {
34
+ const mode = (await stat(filePath)).mode & 0o777;
35
+ if (mode === 0o600) return;
36
+
37
+ await chmod(filePath, 0o600);
38
+ const mode2 = (await stat(filePath)).mode & 0o777;
39
+ if (mode2 !== 0o600) {
40
+ throw new Error(
41
+ `Failed to set mode 0600 on ${filePath} (got ${mode2.toString(8)})`
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Write `.agentlip/server.json` atomically.
48
+ *
49
+ * Requirements (AGENTLIP_PLAN.md §4.2 / Gate J):
50
+ * - atomic write (temp file in same dir + rename)
51
+ * - mode 0600 (owner read/write only)
52
+ * - never log auth_token
53
+ */
54
+ export async function writeServerJson({
55
+ workspaceRoot,
56
+ data,
57
+ }: {
58
+ workspaceRoot: string;
59
+ data: ServerJsonData;
60
+ }): Promise<void> {
61
+ const dir = agentlipDir(workspaceRoot);
62
+ await mkdir(dir, { recursive: true, mode: 0o700 });
63
+
64
+ const finalPath = serverJsonPath(workspaceRoot);
65
+ const tmpPath = join(
66
+ dir,
67
+ `.server.json.tmp.${randomBytes(8).toString("hex")}`
68
+ );
69
+
70
+ const content = JSON.stringify(data, null, 2);
71
+
72
+ try {
73
+ // Write temp file first (same filesystem), then rename over final.
74
+ await writeFile(tmpPath, content, { mode: 0o600, flag: "wx" });
75
+ await ensureMode0600(tmpPath);
76
+
77
+ try {
78
+ await rename(tmpPath, finalPath);
79
+ } catch (err: any) {
80
+ // Windows can fail to overwrite existing target; best-effort fallback.
81
+ if (err?.code === "EEXIST" || err?.code === "EPERM") {
82
+ try {
83
+ await unlink(finalPath);
84
+ } catch (unlinkErr: any) {
85
+ if (unlinkErr?.code !== "ENOENT") throw unlinkErr;
86
+ }
87
+ await rename(tmpPath, finalPath);
88
+ } else {
89
+ throw err;
90
+ }
91
+ }
92
+
93
+ // Belt-and-suspenders: verify perms on final file.
94
+ await ensureMode0600(finalPath);
95
+ } finally {
96
+ // If rename failed mid-way, temp file may still exist.
97
+ try {
98
+ await unlink(tmpPath);
99
+ } catch (err: any) {
100
+ if (err?.code !== "ENOENT") throw err;
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Read and parse `.agentlip/server.json`.
107
+ * Returns null if missing.
108
+ */
109
+ export async function readServerJson({
110
+ workspaceRoot,
111
+ }: {
112
+ workspaceRoot: string;
113
+ }): Promise<ServerJsonData | null> {
114
+ try {
115
+ const content = await readFile(serverJsonPath(workspaceRoot), "utf-8");
116
+ return JSON.parse(content) as ServerJsonData;
117
+ } catch (err: any) {
118
+ if (err?.code === "ENOENT") return null;
119
+ throw err;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Remove `.agentlip/server.json`.
125
+ * No-op if missing.
126
+ */
127
+ export async function removeServerJson({
128
+ workspaceRoot,
129
+ }: {
130
+ workspaceRoot: string;
131
+ }): Promise<void> {
132
+ try {
133
+ await unlink(serverJsonPath(workspaceRoot));
134
+ } catch (err: any) {
135
+ if (err?.code === "ENOENT") return;
136
+ throw err;
137
+ }
138
+ }