tlc-claude-code 2.0.1 → 2.2.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.
Files changed (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Capture Bridge Tests - Phase 82 Tasks 1+2
3
+ *
4
+ * Tests for the Node.js bridge that connects Claude Code Stop hooks
5
+ * to the TLC memory capture pipeline.
6
+ *
7
+ * RED: capture-bridge.js does not exist yet.
8
+ */
9
+
10
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+
15
+ import {
16
+ parseStopHookInput,
17
+ extractLastUserMessage,
18
+ captureExchange,
19
+ drainSpool,
20
+ SPOOL_FILENAME,
21
+ } from './capture-bridge.js';
22
+
23
+ describe('capture-bridge', () => {
24
+ let testDir;
25
+
26
+ beforeEach(() => {
27
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-capture-bridge-'));
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ afterEach(() => {
32
+ fs.rmSync(testDir, { recursive: true, force: true });
33
+ });
34
+
35
+ // ── parseStopHookInput ───────────────────────────────────────────
36
+
37
+ describe('parseStopHookInput', () => {
38
+ it('extracts last_assistant_message from valid JSON', () => {
39
+ const input = JSON.stringify({
40
+ session_id: 'sess-123',
41
+ last_assistant_message: 'We should use PostgreSQL for JSONB support',
42
+ transcript_path: '/tmp/transcript.jsonl',
43
+ cwd: '/projects/myapp',
44
+ });
45
+
46
+ const result = parseStopHookInput(input);
47
+
48
+ expect(result).not.toBeNull();
49
+ expect(result.sessionId).toBe('sess-123');
50
+ expect(result.assistantMessage).toBe('We should use PostgreSQL for JSONB support');
51
+ expect(result.transcriptPath).toBe('/tmp/transcript.jsonl');
52
+ expect(result.cwd).toBe('/projects/myapp');
53
+ });
54
+
55
+ it('handles missing fields gracefully', () => {
56
+ const input = JSON.stringify({ session_id: 'sess-456' });
57
+
58
+ const result = parseStopHookInput(input);
59
+
60
+ expect(result).not.toBeNull();
61
+ expect(result.sessionId).toBe('sess-456');
62
+ expect(result.assistantMessage).toBeNull();
63
+ expect(result.transcriptPath).toBeNull();
64
+ });
65
+
66
+ it('returns null for invalid JSON', () => {
67
+ const result = parseStopHookInput('not json at all {{{');
68
+
69
+ expect(result).toBeNull();
70
+ });
71
+
72
+ it('returns null for empty input', () => {
73
+ expect(parseStopHookInput('')).toBeNull();
74
+ expect(parseStopHookInput(null)).toBeNull();
75
+ expect(parseStopHookInput(undefined)).toBeNull();
76
+ });
77
+ });
78
+
79
+ // ── extractLastUserMessage ───────────────────────────────────────
80
+
81
+ describe('extractLastUserMessage', () => {
82
+ it('reads last user turn from transcript JSONL', () => {
83
+ const transcriptPath = path.join(testDir, 'transcript.jsonl');
84
+ const lines = [
85
+ JSON.stringify({ role: 'user', content: 'first question' }),
86
+ JSON.stringify({ role: 'assistant', content: 'first answer' }),
87
+ JSON.stringify({ role: 'user', content: 'second question' }),
88
+ JSON.stringify({ role: 'assistant', content: 'second answer' }),
89
+ ];
90
+ fs.writeFileSync(transcriptPath, lines.join('\n') + '\n');
91
+
92
+ const result = extractLastUserMessage(transcriptPath);
93
+
94
+ expect(result).toBe('second question');
95
+ });
96
+
97
+ it('returns null for empty transcript', () => {
98
+ const transcriptPath = path.join(testDir, 'empty.jsonl');
99
+ fs.writeFileSync(transcriptPath, '');
100
+
101
+ const result = extractLastUserMessage(transcriptPath);
102
+
103
+ expect(result).toBeNull();
104
+ });
105
+
106
+ it('returns null for missing file', () => {
107
+ const result = extractLastUserMessage('/nonexistent/transcript.jsonl');
108
+
109
+ expect(result).toBeNull();
110
+ });
111
+
112
+ it('handles transcript with only assistant messages', () => {
113
+ const transcriptPath = path.join(testDir, 'no-user.jsonl');
114
+ const lines = [
115
+ JSON.stringify({ role: 'assistant', content: 'hello' }),
116
+ ];
117
+ fs.writeFileSync(transcriptPath, lines.join('\n') + '\n');
118
+
119
+ const result = extractLastUserMessage(transcriptPath);
120
+
121
+ expect(result).toBeNull();
122
+ });
123
+ });
124
+
125
+ // ── captureExchange ──────────────────────────────────────────────
126
+
127
+ describe('captureExchange', () => {
128
+ it('POSTs to capture endpoint with correct payload', async () => {
129
+ const mockFetch = vi.fn().mockResolvedValue({
130
+ ok: true,
131
+ json: async () => ({ captured: 1 }),
132
+ });
133
+
134
+ // Create .tlc.json so projectId can be detected
135
+ fs.writeFileSync(
136
+ path.join(testDir, '.tlc.json'),
137
+ JSON.stringify({ project: 'my-app' })
138
+ );
139
+
140
+ await captureExchange({
141
+ cwd: testDir,
142
+ assistantMessage: 'Use JWT tokens for auth',
143
+ userMessage: 'How should we handle auth?',
144
+ sessionId: 'sess-1',
145
+ }, { fetch: mockFetch });
146
+
147
+ expect(mockFetch).toHaveBeenCalledTimes(1);
148
+
149
+ const [url, options] = mockFetch.mock.calls[0];
150
+ expect(url).toContain('/api/projects/my-app/memory/capture');
151
+ expect(options.method).toBe('POST');
152
+
153
+ const body = JSON.parse(options.body);
154
+ expect(body.exchanges).toHaveLength(1);
155
+ expect(body.exchanges[0].assistant).toBe('Use JWT tokens for auth');
156
+ expect(body.exchanges[0].user).toBe('How should we handle auth?');
157
+ });
158
+
159
+ it('spools on POST failure', async () => {
160
+ const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
161
+
162
+ fs.writeFileSync(
163
+ path.join(testDir, '.tlc.json'),
164
+ JSON.stringify({ project: 'my-app' })
165
+ );
166
+
167
+ // Ensure spool directory exists
168
+ const spoolDir = path.join(testDir, '.tlc', 'memory');
169
+ fs.mkdirSync(spoolDir, { recursive: true });
170
+
171
+ await captureExchange({
172
+ cwd: testDir,
173
+ assistantMessage: 'Use Redis for caching',
174
+ userMessage: 'What cache should we use?',
175
+ sessionId: 'sess-2',
176
+ }, { fetch: mockFetch, spoolDir });
177
+
178
+ // Should have written to spool file
179
+ const spoolPath = path.join(spoolDir, SPOOL_FILENAME);
180
+ expect(fs.existsSync(spoolPath)).toBe(true);
181
+
182
+ const spoolContent = fs.readFileSync(spoolPath, 'utf-8').trim();
183
+ const spooled = JSON.parse(spoolContent);
184
+ expect(spooled.exchanges[0].assistant).toBe('Use Redis for caching');
185
+ });
186
+
187
+ it('truncates messages over 10KB', async () => {
188
+ const mockFetch = vi.fn().mockResolvedValue({
189
+ ok: true,
190
+ json: async () => ({ captured: 1 }),
191
+ });
192
+
193
+ fs.writeFileSync(
194
+ path.join(testDir, '.tlc.json'),
195
+ JSON.stringify({ project: 'my-app' })
196
+ );
197
+
198
+ const longMessage = 'x'.repeat(15000); // 15KB
199
+
200
+ await captureExchange({
201
+ cwd: testDir,
202
+ assistantMessage: longMessage,
203
+ userMessage: 'short question',
204
+ sessionId: 'sess-3',
205
+ }, { fetch: mockFetch });
206
+
207
+ expect(mockFetch).toHaveBeenCalledTimes(1);
208
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
209
+ expect(body.exchanges[0].assistant.length).toBeLessThanOrEqual(10240 + 20); // 10KB + truncation marker
210
+ });
211
+
212
+ it('never throws on any error', async () => {
213
+ // No .tlc.json, no spool dir, fetch fails — still should not throw
214
+ const mockFetch = vi.fn().mockRejectedValue(new Error('total failure'));
215
+
216
+ await expect(
217
+ captureExchange({
218
+ cwd: '/nonexistent/path',
219
+ assistantMessage: 'test',
220
+ userMessage: null,
221
+ sessionId: 'sess-4',
222
+ }, { fetch: mockFetch })
223
+ ).resolves.not.toThrow();
224
+ });
225
+
226
+ it('detects projectId from .tlc.json', async () => {
227
+ const mockFetch = vi.fn().mockResolvedValue({
228
+ ok: true,
229
+ json: async () => ({ captured: 1 }),
230
+ });
231
+
232
+ fs.writeFileSync(
233
+ path.join(testDir, '.tlc.json'),
234
+ JSON.stringify({ project: 'special-project' })
235
+ );
236
+
237
+ await captureExchange({
238
+ cwd: testDir,
239
+ assistantMessage: 'test',
240
+ userMessage: 'test',
241
+ sessionId: 'sess-5',
242
+ }, { fetch: mockFetch });
243
+
244
+ const url = mockFetch.mock.calls[0][0];
245
+ expect(url).toContain('/projects/special-project/');
246
+ });
247
+
248
+ it('falls back to directory name when no .tlc.json', async () => {
249
+ const mockFetch = vi.fn().mockResolvedValue({
250
+ ok: true,
251
+ json: async () => ({ captured: 1 }),
252
+ });
253
+
254
+ // No .tlc.json in testDir
255
+
256
+ await captureExchange({
257
+ cwd: testDir,
258
+ assistantMessage: 'test',
259
+ userMessage: 'test',
260
+ sessionId: 'sess-6',
261
+ }, { fetch: mockFetch });
262
+
263
+ expect(mockFetch).toHaveBeenCalledTimes(1);
264
+ const url = mockFetch.mock.calls[0][0];
265
+ // Should contain the directory basename as projectId
266
+ expect(url).toContain('/projects/');
267
+ });
268
+
269
+ it('skips capture when assistantMessage is empty', async () => {
270
+ const mockFetch = vi.fn();
271
+
272
+ await captureExchange({
273
+ cwd: testDir,
274
+ assistantMessage: '',
275
+ userMessage: 'test',
276
+ sessionId: 'sess-7',
277
+ }, { fetch: mockFetch });
278
+
279
+ expect(mockFetch).not.toHaveBeenCalled();
280
+ });
281
+ });
282
+
283
+ // ── drainSpool ───────────────────────────────────────────────────
284
+
285
+ describe('drainSpool', () => {
286
+ it('posts spooled entries and removes them on success', async () => {
287
+ const mockFetch = vi.fn().mockResolvedValue({
288
+ ok: true,
289
+ json: async () => ({ captured: 1 }),
290
+ });
291
+
292
+ const spoolDir = path.join(testDir, '.tlc', 'memory');
293
+ fs.mkdirSync(spoolDir, { recursive: true });
294
+
295
+ const spoolPath = path.join(spoolDir, SPOOL_FILENAME);
296
+ const entry1 = JSON.stringify({
297
+ projectId: 'my-app',
298
+ exchanges: [{ user: 'q1', assistant: 'a1', timestamp: Date.now() }],
299
+ });
300
+ const entry2 = JSON.stringify({
301
+ projectId: 'my-app',
302
+ exchanges: [{ user: 'q2', assistant: 'a2', timestamp: Date.now() }],
303
+ });
304
+ fs.writeFileSync(spoolPath, entry1 + '\n' + entry2 + '\n');
305
+
306
+ await drainSpool(spoolDir, { fetch: mockFetch });
307
+
308
+ expect(mockFetch).toHaveBeenCalledTimes(2);
309
+
310
+ // Spool file should be empty or removed
311
+ if (fs.existsSync(spoolPath)) {
312
+ expect(fs.readFileSync(spoolPath, 'utf-8').trim()).toBe('');
313
+ }
314
+ });
315
+
316
+ it('handles empty spool file without error', async () => {
317
+ const mockFetch = vi.fn();
318
+
319
+ const spoolDir = path.join(testDir, '.tlc', 'memory');
320
+ fs.mkdirSync(spoolDir, { recursive: true });
321
+ fs.writeFileSync(path.join(spoolDir, SPOOL_FILENAME), '');
322
+
323
+ await expect(drainSpool(spoolDir, { fetch: mockFetch })).resolves.not.toThrow();
324
+ expect(mockFetch).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it('handles missing spool file without error', async () => {
328
+ const mockFetch = vi.fn();
329
+
330
+ await expect(drainSpool(testDir, { fetch: mockFetch })).resolves.not.toThrow();
331
+ expect(mockFetch).not.toHaveBeenCalled();
332
+ });
333
+
334
+ it('preserves failed entries in spool', async () => {
335
+ // First call succeeds, second fails
336
+ const mockFetch = vi.fn()
337
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ captured: 1 }) })
338
+ .mockRejectedValueOnce(new Error('ECONNREFUSED'));
339
+
340
+ const spoolDir = path.join(testDir, '.tlc', 'memory');
341
+ fs.mkdirSync(spoolDir, { recursive: true });
342
+
343
+ const spoolPath = path.join(spoolDir, SPOOL_FILENAME);
344
+ const entry1 = JSON.stringify({
345
+ projectId: 'my-app',
346
+ exchanges: [{ user: 'q1', assistant: 'a1', timestamp: 1 }],
347
+ });
348
+ const entry2 = JSON.stringify({
349
+ projectId: 'my-app',
350
+ exchanges: [{ user: 'q2', assistant: 'a2', timestamp: 2 }],
351
+ });
352
+ fs.writeFileSync(spoolPath, entry1 + '\n' + entry2 + '\n');
353
+
354
+ await drainSpool(spoolDir, { fetch: mockFetch });
355
+
356
+ // Failed entry should remain in spool
357
+ const remaining = fs.readFileSync(spoolPath, 'utf-8').trim();
358
+ expect(remaining).not.toBe('');
359
+ const parsed = JSON.parse(remaining);
360
+ expect(parsed.exchanges[0].assistant).toBe('a2');
361
+ });
362
+ });
363
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Capture Guard - Endpoint hardening for the memory capture API.
3
+ *
4
+ * Provides payload size validation, exchange validation, content deduplication,
5
+ * and per-project rate limiting.
6
+ *
7
+ * @module capture-guard
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ /** Maximum payload size in bytes (100KB) */
13
+ const MAX_PAYLOAD_SIZE = 100 * 1024;
14
+
15
+ /** Deduplication window in milliseconds (60 seconds) */
16
+ const DEDUP_WINDOW_MS = 60 * 1000;
17
+
18
+ /** Maximum captures per minute per project */
19
+ const RATE_LIMIT_PER_MINUTE = 100;
20
+
21
+ /** Rate limit window in milliseconds (60 seconds) */
22
+ const RATE_LIMIT_WINDOW_MS = 60 * 1000;
23
+
24
+ /**
25
+ * Create a capture guard instance with internal state for dedup and rate limiting.
26
+ *
27
+ * @returns {{ validate: Function, deduplicate: Function, checkRateLimit: Function }}
28
+ */
29
+ function createCaptureGuard() {
30
+ /** Map of content hash → timestamp for deduplication */
31
+ const dedupCache = new Map();
32
+
33
+ /** Map of projectId → { count, windowStart } for rate limiting */
34
+ const rateLimits = new Map();
35
+
36
+ /**
37
+ * Hash exchange content for deduplication.
38
+ * @param {object} exchange
39
+ * @returns {string}
40
+ */
41
+ function hashExchange(exchange) {
42
+ const content = (exchange.user || '') + '|' + (exchange.assistant || '');
43
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
44
+ }
45
+
46
+ /**
47
+ * Clean expired entries from dedup cache.
48
+ */
49
+ function cleanDedupCache() {
50
+ const now = Date.now();
51
+ for (const [hash, timestamp] of dedupCache) {
52
+ if (now - timestamp > DEDUP_WINDOW_MS) {
53
+ dedupCache.delete(hash);
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Validate the capture payload for size and structure.
60
+ *
61
+ * @param {object} payload - The request body
62
+ * @param {string} projectId - Project identifier
63
+ * @returns {{ ok: boolean, status?: number, error?: string }}
64
+ */
65
+ function validate(payload, projectId) {
66
+ // Size check
67
+ const payloadSize = JSON.stringify(payload).length;
68
+ if (payloadSize > MAX_PAYLOAD_SIZE) {
69
+ return { ok: false, status: 413, error: `Payload too large: ${payloadSize} bytes (max ${MAX_PAYLOAD_SIZE})` };
70
+ }
71
+
72
+ // Exchange validation
73
+ if (!payload.exchanges || !Array.isArray(payload.exchanges)) {
74
+ return { ok: false, status: 400, error: 'exchanges array is required' };
75
+ }
76
+
77
+ for (const exchange of payload.exchanges) {
78
+ const hasUser = exchange.user && typeof exchange.user === 'string' && exchange.user.trim().length > 0;
79
+ const hasAssistant = exchange.assistant && typeof exchange.assistant === 'string' && exchange.assistant.trim().length > 0;
80
+ if (!hasUser && !hasAssistant) {
81
+ return { ok: false, status: 400, error: 'Each exchange must have at least one of user or assistant as a non-empty string' };
82
+ }
83
+ }
84
+
85
+ return { ok: true };
86
+ }
87
+
88
+ /**
89
+ * Filter out duplicate exchanges within the dedup window.
90
+ *
91
+ * @param {Array} exchanges - Array of exchange objects
92
+ * @param {string} projectId - Project identifier
93
+ * @returns {Array} Deduplicated exchanges
94
+ */
95
+ function deduplicate(exchanges, projectId) {
96
+ cleanDedupCache();
97
+ const now = Date.now();
98
+ const unique = [];
99
+
100
+ for (const exchange of exchanges) {
101
+ const hash = projectId + ':' + hashExchange(exchange);
102
+ const lastSeen = dedupCache.get(hash);
103
+
104
+ if (!lastSeen || (now - lastSeen) > DEDUP_WINDOW_MS) {
105
+ dedupCache.set(hash, now);
106
+ unique.push(exchange);
107
+ }
108
+ }
109
+
110
+ return unique;
111
+ }
112
+
113
+ /**
114
+ * Check if a project has exceeded its rate limit.
115
+ *
116
+ * @param {string} projectId - Project identifier
117
+ * @returns {{ ok: boolean, status?: number, error?: string }}
118
+ */
119
+ function checkRateLimit(projectId) {
120
+ const now = Date.now();
121
+ let entry = rateLimits.get(projectId);
122
+
123
+ if (!entry || (now - entry.windowStart) > RATE_LIMIT_WINDOW_MS) {
124
+ entry = { count: 0, windowStart: now };
125
+ rateLimits.set(projectId, entry);
126
+ }
127
+
128
+ entry.count++;
129
+
130
+ if (entry.count > RATE_LIMIT_PER_MINUTE) {
131
+ return { ok: false, status: 429, error: `Rate limit exceeded: ${RATE_LIMIT_PER_MINUTE} captures/minute` };
132
+ }
133
+
134
+ return { ok: true };
135
+ }
136
+
137
+ return { validate, deduplicate, checkRateLimit };
138
+ }
139
+
140
+ module.exports = { createCaptureGuard };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Capture Guard Tests - Phase 82 Task 4
3
+ *
4
+ * Tests for endpoint hardening: size limits, dedup, rate limiting.
5
+ *
6
+ * RED: capture-guard.js does not exist yet.
7
+ */
8
+
9
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
10
+
11
+ import { createCaptureGuard } from './capture-guard.js';
12
+
13
+ describe('capture-guard', () => {
14
+ let guard;
15
+
16
+ beforeEach(() => {
17
+ guard = createCaptureGuard();
18
+ vi.useFakeTimers();
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.useRealTimers();
23
+ });
24
+
25
+ describe('payload size validation', () => {
26
+ it('rejects payload over 100KB', () => {
27
+ const hugePayload = {
28
+ exchanges: [{
29
+ user: 'x'.repeat(50000),
30
+ assistant: 'y'.repeat(60000),
31
+ timestamp: Date.now(),
32
+ }],
33
+ };
34
+
35
+ const result = guard.validate(hugePayload, 'test-project');
36
+
37
+ expect(result.ok).toBe(false);
38
+ expect(result.status).toBe(413);
39
+ expect(result.error).toMatch(/payload.*too.*large/i);
40
+ });
41
+
42
+ it('accepts payload under 100KB', () => {
43
+ const normalPayload = {
44
+ exchanges: [{
45
+ user: 'How should we handle auth?',
46
+ assistant: 'Use JWT tokens',
47
+ timestamp: Date.now(),
48
+ }],
49
+ };
50
+
51
+ const result = guard.validate(normalPayload, 'test-project');
52
+
53
+ expect(result.ok).toBe(true);
54
+ });
55
+ });
56
+
57
+ describe('exchange validation', () => {
58
+ it('rejects exchange without user or assistant', () => {
59
+ const payload = {
60
+ exchanges: [{ timestamp: Date.now() }],
61
+ };
62
+
63
+ const result = guard.validate(payload, 'test-project');
64
+
65
+ expect(result.ok).toBe(false);
66
+ expect(result.status).toBe(400);
67
+ });
68
+
69
+ it('accepts exchange with only assistant message', () => {
70
+ const payload = {
71
+ exchanges: [{ assistant: 'some response', timestamp: Date.now() }],
72
+ };
73
+
74
+ const result = guard.validate(payload, 'test-project');
75
+
76
+ expect(result.ok).toBe(true);
77
+ });
78
+
79
+ it('accepts exchange with only user message', () => {
80
+ const payload = {
81
+ exchanges: [{ user: 'some question', timestamp: Date.now() }],
82
+ };
83
+
84
+ const result = guard.validate(payload, 'test-project');
85
+
86
+ expect(result.ok).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe('deduplication', () => {
91
+ it('deduplicates identical exchanges within 60s window', () => {
92
+ const exchange = {
93
+ user: 'What cache?',
94
+ assistant: 'Use Redis',
95
+ timestamp: Date.now(),
96
+ };
97
+
98
+ const first = guard.deduplicate([exchange], 'proj-1');
99
+ expect(first).toHaveLength(1);
100
+
101
+ // Same exchange again immediately
102
+ const second = guard.deduplicate([exchange], 'proj-1');
103
+ expect(second).toHaveLength(0);
104
+ });
105
+
106
+ it('allows same exchange after 60s window expires', () => {
107
+ const exchange = {
108
+ user: 'What cache?',
109
+ assistant: 'Use Redis',
110
+ timestamp: Date.now(),
111
+ };
112
+
113
+ const first = guard.deduplicate([exchange], 'proj-1');
114
+ expect(first).toHaveLength(1);
115
+
116
+ // Advance 61 seconds
117
+ vi.advanceTimersByTime(61000);
118
+
119
+ const second = guard.deduplicate([exchange], 'proj-1');
120
+ expect(second).toHaveLength(1);
121
+ });
122
+
123
+ it('deduplicates per-project (same exchange in different projects is allowed)', () => {
124
+ const exchange = {
125
+ user: 'What cache?',
126
+ assistant: 'Use Redis',
127
+ timestamp: Date.now(),
128
+ };
129
+
130
+ const proj1 = guard.deduplicate([exchange], 'proj-1');
131
+ expect(proj1).toHaveLength(1);
132
+
133
+ const proj2 = guard.deduplicate([exchange], 'proj-2');
134
+ expect(proj2).toHaveLength(1);
135
+ });
136
+ });
137
+
138
+ describe('rate limiting', () => {
139
+ it('allows requests under rate limit', () => {
140
+ for (let i = 0; i < 50; i++) {
141
+ const result = guard.checkRateLimit('proj-1');
142
+ expect(result.ok).toBe(true);
143
+ }
144
+ });
145
+
146
+ it('rate limits at 100 captures per minute', () => {
147
+ // Burn through 100 requests
148
+ for (let i = 0; i < 100; i++) {
149
+ guard.checkRateLimit('proj-1');
150
+ }
151
+
152
+ const result = guard.checkRateLimit('proj-1');
153
+
154
+ expect(result.ok).toBe(false);
155
+ expect(result.status).toBe(429);
156
+ });
157
+
158
+ it('resets rate limit after 60 seconds', () => {
159
+ // Hit the limit
160
+ for (let i = 0; i < 100; i++) {
161
+ guard.checkRateLimit('proj-1');
162
+ }
163
+ expect(guard.checkRateLimit('proj-1').ok).toBe(false);
164
+
165
+ // Advance 61 seconds
166
+ vi.advanceTimersByTime(61000);
167
+
168
+ expect(guard.checkRateLimit('proj-1').ok).toBe(true);
169
+ });
170
+
171
+ it('rate limits per-project', () => {
172
+ // Hit limit on proj-1
173
+ for (let i = 0; i < 100; i++) {
174
+ guard.checkRateLimit('proj-1');
175
+ }
176
+ expect(guard.checkRateLimit('proj-1').ok).toBe(false);
177
+
178
+ // proj-2 should still be allowed
179
+ expect(guard.checkRateLimit('proj-2').ok).toBe(true);
180
+ });
181
+ });
182
+ });