@aictrl/hush 0.1.0 → 0.1.6
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/.github/workflows/ci.yml +46 -0
- package/.github/workflows/e2e-opencode.yml +126 -0
- package/.github/workflows/opencode-review.yml +101 -0
- package/.github/workflows/publish.yml +44 -0
- package/CLAUDE.md +6 -0
- package/CONTRIBUTING.md +29 -0
- package/Dockerfile +25 -0
- package/GEMINI.md +6 -0
- package/README.md +86 -71
- package/dist/cli.js +11 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.js +54 -36
- package/dist/index.js.map +1 -1
- package/dist/middleware/redactor.d.ts.map +1 -1
- package/dist/middleware/redactor.js +12 -7
- package/dist/middleware/redactor.js.map +1 -1
- package/dist/vault/token-vault.d.ts.map +1 -1
- package/dist/vault/token-vault.js +103 -16
- package/dist/vault/token-vault.js.map +1 -1
- package/install.sh +37 -0
- package/logo.svg +31 -0
- package/package.json +5 -4
- package/scripts/e2e-gateway-harness.ts +62 -0
- package/scripts/e2e-mock-upstream.mjs +55 -0
- package/scripts/e2e-opencode.sh +217 -0
- package/src/cli.ts +20 -0
- package/src/index.ts +261 -0
- package/src/lib/dashboard.ts +180 -0
- package/src/lib/logger.ts +72 -0
- package/src/middleware/redactor.ts +155 -0
- package/src/vault/token-vault.ts +249 -0
- package/tests/proxy.test.ts +258 -0
- package/tests/redaction.test.ts +102 -0
- package/tests/universal-proxy.test.ts +160 -0
- package/tests/vault.test.ts +73 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import nock from 'nock';
|
|
4
|
+
import { app } from '../src/index';
|
|
5
|
+
|
|
6
|
+
// Deterministic token for bulat@aictrl.dev (SHA-256 first 6 hex chars)
|
|
7
|
+
const EMAIL_TOKEN = '[USER_EMAIL_f22c5a]';
|
|
8
|
+
|
|
9
|
+
describe('Hush Proxy E2E Tests', () => {
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
nock.cleanAll();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
nock.restore();
|
|
17
|
+
nock.activate();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('Anthropic Proxy (/v1/messages)', () => {
|
|
21
|
+
it('should redact outbound messages and rehydrate inbound results', async () => {
|
|
22
|
+
// Mock Anthropic API
|
|
23
|
+
const scope = nock('https://api.anthropic.com')
|
|
24
|
+
.post('/v1/messages', (body) => {
|
|
25
|
+
// Verify redaction happened before forwarding
|
|
26
|
+
return JSON.stringify(body).includes('[USER_EMAIL_');
|
|
27
|
+
})
|
|
28
|
+
.reply(200, {
|
|
29
|
+
id: 'msg_123',
|
|
30
|
+
content: [{ type: 'text', text: `Hello ${EMAIL_TOKEN}` }]
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const response = await request(app)
|
|
34
|
+
.post('/v1/messages')
|
|
35
|
+
.set('x-api-key', 'test-key')
|
|
36
|
+
.send({
|
|
37
|
+
model: 'claude-3-5-sonnet-latest',
|
|
38
|
+
messages: [{ role: 'user', content: 'My email is bulat@aictrl.dev' }]
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(response.status).toBe(200);
|
|
42
|
+
// Verify rehydration happened on the way back
|
|
43
|
+
expect(response.body.content[0].text).toBe('Hello bulat@aictrl.dev');
|
|
44
|
+
expect(scope.isDone()).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should handle streaming responses with rehydration', async () => {
|
|
48
|
+
// 1. Mock the first seeding request
|
|
49
|
+
nock('https://api.anthropic.com')
|
|
50
|
+
.post('/v1/messages')
|
|
51
|
+
.reply(200, {
|
|
52
|
+
id: 'msg_seed',
|
|
53
|
+
content: [{ type: 'text', text: 'OK' }]
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 2. Mock the second streaming response
|
|
57
|
+
const streamData = [
|
|
58
|
+
'data: {"type": "message_start", "message": {"id": "msg_123"}}\n\n',
|
|
59
|
+
`data: {"type": "content_block_delta", "delta": {"type": "text_delta", "text": "Hello ${EMAIL_TOKEN}"}}\n\n`,
|
|
60
|
+
'data: {"type": "message_stop"}\n\n'
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
nock('https://api.anthropic.com')
|
|
64
|
+
.post('/v1/messages')
|
|
65
|
+
.reply(200, streamData.join(''), {
|
|
66
|
+
'Content-Type': 'text/event-stream'
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Seed the vault first
|
|
70
|
+
await request(app)
|
|
71
|
+
.post('/v1/messages')
|
|
72
|
+
.set('x-api-key', 'test-key')
|
|
73
|
+
.send({
|
|
74
|
+
messages: [{ role: 'user', content: 'bulat@aictrl.dev' }]
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Execute streaming request
|
|
78
|
+
const response = await request(app)
|
|
79
|
+
.post('/v1/messages')
|
|
80
|
+
.set('x-api-key', 'test-key')
|
|
81
|
+
.send({
|
|
82
|
+
stream: true,
|
|
83
|
+
messages: [{ role: 'user', content: 'hi' }]
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(response.status).toBe(200);
|
|
87
|
+
expect(response.text).toContain('Hello bulat@aictrl.dev');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('OpenAI Proxy (/v1/chat/completions)', () => {
|
|
92
|
+
it('should redact and proxy OpenAI requests', async () => {
|
|
93
|
+
const scope = nock('https://api.openai.com')
|
|
94
|
+
.post('/v1/chat/completions')
|
|
95
|
+
.reply(200, {
|
|
96
|
+
choices: [{ message: { content: `Rehydrated: ${EMAIL_TOKEN}` } }]
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const response = await request(app)
|
|
100
|
+
.post('/v1/chat/completions')
|
|
101
|
+
.set('Authorization', 'Bearer test-token')
|
|
102
|
+
.send({
|
|
103
|
+
model: 'gpt-4o',
|
|
104
|
+
messages: [{ role: 'user', content: 'Email is bulat@aictrl.dev' }]
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(response.status).toBe(200);
|
|
108
|
+
expect(response.body.choices[0].message.content).toBe('Rehydrated: bulat@aictrl.dev');
|
|
109
|
+
expect(scope.isDone()).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should forward upstream error status and body', async () => {
|
|
113
|
+
nock('https://api.openai.com')
|
|
114
|
+
.post('/v1/chat/completions')
|
|
115
|
+
.reply(429, { error: { message: 'Rate limit exceeded' } });
|
|
116
|
+
|
|
117
|
+
const response = await request(app)
|
|
118
|
+
.post('/v1/chat/completions')
|
|
119
|
+
.set('Authorization', 'Bearer test-token')
|
|
120
|
+
.send({ model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] });
|
|
121
|
+
|
|
122
|
+
expect(response.status).toBe(429);
|
|
123
|
+
expect(JSON.parse(response.text).error.message).toBe('Rate limit exceeded');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('ZhipuAI GLM Proxy (/api/paas/v4/chat/completions)', () => {
|
|
128
|
+
it('should redact PII and proxy GLM-5 requests', async () => {
|
|
129
|
+
const scope = nock('https://api.z.ai')
|
|
130
|
+
.post('/api/paas/v4/chat/completions', (body) => {
|
|
131
|
+
return JSON.stringify(body).includes('[USER_EMAIL_');
|
|
132
|
+
})
|
|
133
|
+
.reply(200, {
|
|
134
|
+
id: 'chatcmpl-glm5-abc123',
|
|
135
|
+
model: 'glm-5',
|
|
136
|
+
choices: [{ message: { role: 'assistant', content: `Got it, your email is ${EMAIL_TOKEN}` } }],
|
|
137
|
+
usage: { prompt_tokens: 15, completion_tokens: 12, total_tokens: 27 }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const response = await request(app)
|
|
141
|
+
.post('/api/paas/v4/chat/completions')
|
|
142
|
+
.set('Authorization', 'Bearer zhipu-test-key')
|
|
143
|
+
.send({
|
|
144
|
+
model: 'glm-5',
|
|
145
|
+
messages: [{ role: 'user', content: 'My email is bulat@aictrl.dev' }]
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(200);
|
|
149
|
+
expect(response.body.choices[0].message.content).toBe('Got it, your email is bulat@aictrl.dev');
|
|
150
|
+
expect(scope.isDone()).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should redact multiple PII types in GLM requests', async () => {
|
|
154
|
+
const scope = nock('https://api.z.ai')
|
|
155
|
+
.post('/api/paas/v4/chat/completions', (body) => {
|
|
156
|
+
const bodyStr = JSON.stringify(body);
|
|
157
|
+
// Verify ALL PII types were redacted before reaching upstream
|
|
158
|
+
return bodyStr.includes('[USER_EMAIL_') &&
|
|
159
|
+
bodyStr.includes('[NETWORK_IP_') &&
|
|
160
|
+
!bodyStr.includes('bulat@aictrl.dev') &&
|
|
161
|
+
!bodyStr.includes('192.168.1.100');
|
|
162
|
+
})
|
|
163
|
+
.reply(200, {
|
|
164
|
+
id: 'chatcmpl-glm5-multi',
|
|
165
|
+
model: 'glm-5',
|
|
166
|
+
choices: [{ message: { role: 'assistant', content: 'I will not store any of that information.' } }],
|
|
167
|
+
usage: { prompt_tokens: 30, completion_tokens: 10, total_tokens: 40 }
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const response = await request(app)
|
|
171
|
+
.post('/api/paas/v4/chat/completions')
|
|
172
|
+
.set('Authorization', 'Bearer zhipu-test-key')
|
|
173
|
+
.send({
|
|
174
|
+
model: 'glm-5',
|
|
175
|
+
messages: [{ role: 'user', content: 'Server bulat@aictrl.dev is at 192.168.1.100' }]
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(response.status).toBe(200);
|
|
179
|
+
expect(scope.isDone()).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should handle GLM streaming responses with rehydration', async () => {
|
|
183
|
+
// 1. Seed the vault via a non-streaming request
|
|
184
|
+
nock('https://api.z.ai')
|
|
185
|
+
.post('/api/paas/v4/chat/completions')
|
|
186
|
+
.reply(200, {
|
|
187
|
+
id: 'chatcmpl-glm5-seed',
|
|
188
|
+
model: 'glm-5',
|
|
189
|
+
choices: [{ message: { content: 'OK' } }]
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// 2. Mock streaming response that echoes back the token
|
|
193
|
+
const streamData = [
|
|
194
|
+
`data: {"id":"chatcmpl-glm5-stream","model":"glm-5","choices":[{"delta":{"content":"Hello ${EMAIL_TOKEN}"}}]}\n\n`,
|
|
195
|
+
'data: [DONE]\n\n'
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
nock('https://api.z.ai')
|
|
199
|
+
.post('/api/paas/v4/chat/completions')
|
|
200
|
+
.reply(200, streamData.join(''), {
|
|
201
|
+
'Content-Type': 'text/event-stream'
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Seed the vault
|
|
205
|
+
await request(app)
|
|
206
|
+
.post('/api/paas/v4/chat/completions')
|
|
207
|
+
.set('Authorization', 'Bearer zhipu-test-key')
|
|
208
|
+
.send({
|
|
209
|
+
model: 'glm-5',
|
|
210
|
+
messages: [{ role: 'user', content: 'bulat@aictrl.dev' }]
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Execute streaming request
|
|
214
|
+
const response = await request(app)
|
|
215
|
+
.post('/api/paas/v4/chat/completions')
|
|
216
|
+
.set('Authorization', 'Bearer zhipu-test-key')
|
|
217
|
+
.send({
|
|
218
|
+
stream: true,
|
|
219
|
+
model: 'glm-5',
|
|
220
|
+
messages: [{ role: 'user', content: 'hi' }]
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(response.status).toBe(200);
|
|
224
|
+
expect(response.text).toContain('Hello bulat@aictrl.dev');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should forward GLM upstream errors', async () => {
|
|
228
|
+
nock('https://api.z.ai')
|
|
229
|
+
.post('/api/paas/v4/chat/completions')
|
|
230
|
+
.reply(429, { error: { message: 'Rate limit exceeded', code: '1261' } });
|
|
231
|
+
|
|
232
|
+
const response = await request(app)
|
|
233
|
+
.post('/api/paas/v4/chat/completions')
|
|
234
|
+
.set('Authorization', 'Bearer zhipu-test-key')
|
|
235
|
+
.send({ model: 'glm-5', messages: [{ role: 'user', content: 'hi' }] });
|
|
236
|
+
|
|
237
|
+
expect(response.status).toBe(429);
|
|
238
|
+
expect(JSON.parse(response.text).error.message).toBe('Rate limit exceeded');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should reject requests without Authorization header', async () => {
|
|
242
|
+
const response = await request(app)
|
|
243
|
+
.post('/api/paas/v4/chat/completions')
|
|
244
|
+
.send({ model: 'glm-5', messages: [{ role: 'user', content: 'hi' }] });
|
|
245
|
+
|
|
246
|
+
expect(response.status).toBe(401);
|
|
247
|
+
expect(response.body.error).toBe('Missing ZhipuAI Authorization');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('Health Check', () => {
|
|
252
|
+
it('should return running status', async () => {
|
|
253
|
+
const response = await request(app).get('/health');
|
|
254
|
+
expect(response.status).toBe(200);
|
|
255
|
+
expect(response.body.status).toBe('running');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { Redactor } from '../src/middleware/redactor';
|
|
3
|
+
import { TokenVault } from '../src/vault/token-vault';
|
|
4
|
+
|
|
5
|
+
describe('Semantic Security Flow (Redaction + Rehydration)', () => {
|
|
6
|
+
let redactor: Redactor;
|
|
7
|
+
let vault: TokenVault;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
redactor = new Redactor();
|
|
11
|
+
vault = new TokenVault();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should redact common PII types from tool arguments', () => {
|
|
15
|
+
const args = {
|
|
16
|
+
email: 'user@example.com',
|
|
17
|
+
ipv4: '192.168.1.1',
|
|
18
|
+
ipv6: '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
|
|
19
|
+
config: {
|
|
20
|
+
apiKey: 'sk-1234567890abcdef12345',
|
|
21
|
+
secretToken: 'very-secret-string-of-length-32'
|
|
22
|
+
},
|
|
23
|
+
message: 'Contact me at support@company.org or visit 10.0.0.1'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const { content, hasRedacted, tokens } = redactor.redact(args);
|
|
27
|
+
|
|
28
|
+
expect(hasRedacted).toBe(true);
|
|
29
|
+
expect(content.email).toMatch(/^\[USER_EMAIL_[a-f0-9]{6}\]$/);
|
|
30
|
+
expect(content.ipv4).toMatch(/^\[NETWORK_IP_[a-f0-9]{6}\]$/);
|
|
31
|
+
expect(content.ipv6).toMatch(/^\[NETWORK_IP_V6_[a-f0-9]{6}\]$/);
|
|
32
|
+
expect(content.config.apiKey).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/);
|
|
33
|
+
expect(content.config.secretToken).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/);
|
|
34
|
+
expect(content.message).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
|
|
35
|
+
expect(content.message).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
|
|
36
|
+
expect(tokens.size).toBe(7);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should redact credit card numbers', () => {
|
|
40
|
+
const input = 'My card is 4111-1111-1111-1111 and it expires soon';
|
|
41
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
42
|
+
|
|
43
|
+
expect(hasRedacted).toBe(true);
|
|
44
|
+
expect(content).toMatch(/My card is \[PAYMENT_CARD_[a-f0-9]{6}\] and it expires soon/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should redact phone numbers including complex formats', () => {
|
|
48
|
+
const input = 'Call me at 555-010-0199 or +1 (555) 123-4567. UK: +44 20 7946 0958';
|
|
49
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
50
|
+
|
|
51
|
+
expect(hasRedacted).toBe(true);
|
|
52
|
+
expect(content).toMatch(/\[PHONE_NUMBER_[a-f0-9]{6}\]/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should not redact numeric IDs that look like partial phones', () => {
|
|
56
|
+
const input = 'User ID: 12345678, Version: 1.0-alpha';
|
|
57
|
+
const { hasRedacted } = redactor.redact(input);
|
|
58
|
+
|
|
59
|
+
expect(hasRedacted).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should re-hydrate redacted content accurately using the vault', () => {
|
|
63
|
+
const rawArgs = {
|
|
64
|
+
user: 'bulat@aictrl.dev',
|
|
65
|
+
key: 'api-key: super-secret-key-12345'
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 1. Redact outbound (to LLM/Server)
|
|
69
|
+
const { content: redacted, tokens } = redactor.redact(rawArgs);
|
|
70
|
+
vault.saveTokens(tokens);
|
|
71
|
+
|
|
72
|
+
expect(redacted.user).toMatch(/^\[USER_EMAIL_[a-f0-9]{6}\]$/);
|
|
73
|
+
expect(redacted.key).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/);
|
|
74
|
+
|
|
75
|
+
// 2. Simulate result containing tokens coming back
|
|
76
|
+
const resultWithTokens = {
|
|
77
|
+
status: 'Success',
|
|
78
|
+
log: `Processing request for ${redacted.user} with key ${redacted.key.match(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/)![0]}`
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// 3. Re-hydrate locally for developer visibility
|
|
82
|
+
const finalResult = vault.rehydrate(resultWithTokens);
|
|
83
|
+
|
|
84
|
+
expect(finalResult.log).toBe('Processing request for bulat@aictrl.dev with key super-secret-key-12345');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should produce deterministic tokens for the same input', () => {
|
|
88
|
+
const { content: first } = redactor.redact('email: test@foo.com');
|
|
89
|
+
const { content: second } = redactor.redact('email: test@foo.com');
|
|
90
|
+
|
|
91
|
+
// Same input → same hash → same token
|
|
92
|
+
expect(first).toBe(second);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should handle non-object inputs gracefully', () => {
|
|
96
|
+
const input = 'Call 192.168.0.1';
|
|
97
|
+
const { content, hasRedacted } = redactor.redact(input);
|
|
98
|
+
|
|
99
|
+
expect(hasRedacted).toBe(true);
|
|
100
|
+
expect(content).toMatch(/^Call \[NETWORK_IP_[a-f0-9]{6}\]$/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import nock from 'nock';
|
|
4
|
+
import { app } from '../src/index';
|
|
5
|
+
|
|
6
|
+
describe('Universal Proxy Mode', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
nock.cleanAll();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
nock.restore();
|
|
13
|
+
nock.activate();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('Method-agnostic proxyRequest', () => {
|
|
17
|
+
it('should forward GET requests without a body', async () => {
|
|
18
|
+
const scope = nock('https://api.anthropic.com')
|
|
19
|
+
.get('/v1/models')
|
|
20
|
+
.reply(200, { models: ['claude-3'] });
|
|
21
|
+
|
|
22
|
+
// GET /v1/models is not a known route, so it hits the catch-all.
|
|
23
|
+
// We need HUSH_UPSTREAM set to Anthropic for this test, but since
|
|
24
|
+
// env is already set at module load, we test via the catch-all going to Google.
|
|
25
|
+
// Instead, test via a known route that we can mock as GET — but existing
|
|
26
|
+
// routes are POST only. So let's test via catch-all to Google.
|
|
27
|
+
const scope2 = nock('https://generativelanguage.googleapis.com')
|
|
28
|
+
.get('/v1/some-endpoint')
|
|
29
|
+
.reply(200, { result: 'ok' });
|
|
30
|
+
|
|
31
|
+
const response = await request(app)
|
|
32
|
+
.get('/v1/some-endpoint');
|
|
33
|
+
|
|
34
|
+
expect(response.status).toBe(200);
|
|
35
|
+
expect(response.body.result).toBe('ok');
|
|
36
|
+
expect(scope2.isDone()).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should forward DELETE requests without a body', async () => {
|
|
40
|
+
const scope = nock('https://generativelanguage.googleapis.com')
|
|
41
|
+
.delete('/v1/some-resource/123')
|
|
42
|
+
.reply(200, { deleted: true });
|
|
43
|
+
|
|
44
|
+
const response = await request(app)
|
|
45
|
+
.delete('/v1/some-resource/123');
|
|
46
|
+
|
|
47
|
+
expect(response.status).toBe(200);
|
|
48
|
+
expect(response.body.deleted).toBe(true);
|
|
49
|
+
expect(scope.isDone()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should forward PUT requests with body and redaction', async () => {
|
|
53
|
+
const scope = nock('https://generativelanguage.googleapis.com')
|
|
54
|
+
.put('/v1/some-resource/123', (body) => {
|
|
55
|
+
// Verify email was redacted
|
|
56
|
+
return JSON.stringify(body).includes('[USER_EMAIL_');
|
|
57
|
+
})
|
|
58
|
+
.reply(200, { updated: true });
|
|
59
|
+
|
|
60
|
+
const response = await request(app)
|
|
61
|
+
.put('/v1/some-resource/123')
|
|
62
|
+
.send({ data: 'Contact me at bulat@aictrl.dev' });
|
|
63
|
+
|
|
64
|
+
expect(response.status).toBe(200);
|
|
65
|
+
expect(response.body.updated).toBe(true);
|
|
66
|
+
expect(scope.isDone()).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('Auth header passthrough', () => {
|
|
71
|
+
it('should forward Authorization header through catch-all', async () => {
|
|
72
|
+
const scope = nock('https://generativelanguage.googleapis.com', {
|
|
73
|
+
reqheaders: {
|
|
74
|
+
'Authorization': 'Bearer my-secret-token',
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
.get('/v1/some-protected')
|
|
78
|
+
.reply(200, { access: 'granted' });
|
|
79
|
+
|
|
80
|
+
const response = await request(app)
|
|
81
|
+
.get('/v1/some-protected')
|
|
82
|
+
.set('Authorization', 'Bearer my-secret-token');
|
|
83
|
+
|
|
84
|
+
expect(response.status).toBe(200);
|
|
85
|
+
expect(response.body.access).toBe('granted');
|
|
86
|
+
expect(scope.isDone()).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should forward x-api-key header through catch-all', async () => {
|
|
90
|
+
const scope = nock('https://generativelanguage.googleapis.com', {
|
|
91
|
+
reqheaders: {
|
|
92
|
+
'x-api-key': 'sk-ant-test-key',
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
.get('/v1/some-api')
|
|
96
|
+
.reply(200, { ok: true });
|
|
97
|
+
|
|
98
|
+
const response = await request(app)
|
|
99
|
+
.get('/v1/some-api')
|
|
100
|
+
.set('x-api-key', 'sk-ant-test-key');
|
|
101
|
+
|
|
102
|
+
expect(response.status).toBe(200);
|
|
103
|
+
expect(scope.isDone()).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Catch-all with redaction', () => {
|
|
108
|
+
it('should redact PII in catch-all POST requests', async () => {
|
|
109
|
+
const scope = nock('https://generativelanguage.googleapis.com')
|
|
110
|
+
.post('/v1/unknown-endpoint', (body) => {
|
|
111
|
+
const bodyStr = JSON.stringify(body);
|
|
112
|
+
return bodyStr.includes('[USER_EMAIL_') && !bodyStr.includes('bulat@aictrl.dev');
|
|
113
|
+
})
|
|
114
|
+
.reply(200, { processed: true });
|
|
115
|
+
|
|
116
|
+
const response = await request(app)
|
|
117
|
+
.post('/v1/unknown-endpoint')
|
|
118
|
+
.send({ message: 'Email me at bulat@aictrl.dev' });
|
|
119
|
+
|
|
120
|
+
expect(response.status).toBe(200);
|
|
121
|
+
expect(response.body.processed).toBe(true);
|
|
122
|
+
expect(scope.isDone()).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should rehydrate tokens in catch-all responses', async () => {
|
|
126
|
+
// First, seed the vault via a known route
|
|
127
|
+
nock('https://api.anthropic.com')
|
|
128
|
+
.post('/v1/messages')
|
|
129
|
+
.reply(200, {
|
|
130
|
+
id: 'msg_seed',
|
|
131
|
+
content: [{ type: 'text', text: 'OK' }],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await request(app)
|
|
135
|
+
.post('/v1/messages')
|
|
136
|
+
.set('x-api-key', 'test-key')
|
|
137
|
+
.send({ messages: [{ role: 'user', content: 'bulat@aictrl.dev' }] });
|
|
138
|
+
|
|
139
|
+
// Now test catch-all rehydration
|
|
140
|
+
nock('https://generativelanguage.googleapis.com')
|
|
141
|
+
.post('/v1/custom-endpoint')
|
|
142
|
+
.reply(200, { response: 'Your email is [USER_EMAIL_f22c5a]' });
|
|
143
|
+
|
|
144
|
+
const response = await request(app)
|
|
145
|
+
.post('/v1/custom-endpoint')
|
|
146
|
+
.send({ query: 'what is my email?' });
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(200);
|
|
149
|
+
expect(response.body.response).toBe('Your email is bulat@aictrl.dev');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('Health check unaffected', () => {
|
|
154
|
+
it('should still return health status', async () => {
|
|
155
|
+
const response = await request(app).get('/health');
|
|
156
|
+
expect(response.status).toBe(200);
|
|
157
|
+
expect(response.body.status).toBe('running');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { TokenVault } from '../src/vault/token-vault';
|
|
3
|
+
|
|
4
|
+
describe('TokenVault TTL and Pruning', () => {
|
|
5
|
+
it('should expire tokens after TTL', async () => {
|
|
6
|
+
// Set a very short TTL: 100ms
|
|
7
|
+
const vault = new TokenVault(100);
|
|
8
|
+
const tokens = new Map([['[TOKEN_1]', 'secret-value']]);
|
|
9
|
+
|
|
10
|
+
vault.saveTokens(tokens);
|
|
11
|
+
expect(vault.get('[TOKEN_1]')).toBe('secret-value');
|
|
12
|
+
|
|
13
|
+
// Fast-forward time by 150ms
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
vi.advanceTimersByTime(150);
|
|
16
|
+
|
|
17
|
+
// Pruning happens during the next save
|
|
18
|
+
vault.saveTokens(new Map([['[TOKEN_2]', 'other-value']]));
|
|
19
|
+
|
|
20
|
+
expect(vault.get('[TOKEN_1]')).toBeUndefined();
|
|
21
|
+
expect(vault.get('[TOKEN_2]')).toBe('other-value');
|
|
22
|
+
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should rehydrate using non-expired tokens', () => {
|
|
27
|
+
const vault = new TokenVault(1000);
|
|
28
|
+
const tokens = new Map([['[TOKEN_1]', 'bulat@aictrl.dev']]);
|
|
29
|
+
vault.saveTokens(tokens);
|
|
30
|
+
|
|
31
|
+
const input = 'Hello [TOKEN_1]';
|
|
32
|
+
expect(vault.rehydrate(input)).toBe('Hello bulat@aictrl.dev');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('StreamingRehydrator', () => {
|
|
37
|
+
it('should rehydrate tokens split across chunks', () => {
|
|
38
|
+
const vault = new TokenVault();
|
|
39
|
+
vault.saveTokens(new Map([['[HUSH_EML_1234]', 'test@example.com']]));
|
|
40
|
+
|
|
41
|
+
const rehydrator = vault.createStreamingRehydrator();
|
|
42
|
+
|
|
43
|
+
// Chunk 1: Ends mid-token
|
|
44
|
+
const chunk1 = 'My email is [HUSH_E';
|
|
45
|
+
expect(rehydrator(chunk1)).toBe('My email is ');
|
|
46
|
+
|
|
47
|
+
// Chunk 2: Completes token and adds more text
|
|
48
|
+
const chunk2 = 'ML_1234] and more.';
|
|
49
|
+
expect(rehydrator(chunk2)).toBe('test@example.com and more.');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should handle multiple tokens and partial starts', () => {
|
|
53
|
+
const vault = new TokenVault();
|
|
54
|
+
vault.saveTokens(new Map([
|
|
55
|
+
['[HUSH_SEC_1]', 'secret-1'],
|
|
56
|
+
['[HUSH_SEC_2]', 'secret-2']
|
|
57
|
+
]));
|
|
58
|
+
|
|
59
|
+
const rehydrator = vault.createStreamingRehydrator();
|
|
60
|
+
|
|
61
|
+
expect(rehydrator('Part 1: [HU')).toBe('Part 1: ');
|
|
62
|
+
expect(rehydrator('SH_SEC_1] and [')).toBe('secret-1 and ');
|
|
63
|
+
expect(rehydrator('HUSH_SEC_2] done.')).toBe('secret-2 done.');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should release non-token text immediately', () => {
|
|
67
|
+
const vault = new TokenVault();
|
|
68
|
+
const rehydrator = vault.createStreamingRehydrator();
|
|
69
|
+
|
|
70
|
+
expect(rehydrator('Hello World. ')).toBe('Hello World. ');
|
|
71
|
+
expect(rehydrator('No tokens here.')).toBe('No tokens here.');
|
|
72
|
+
});
|
|
73
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "nodenext",
|
|
4
|
+
"target": "esnext",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"lib": ["esnext"],
|
|
8
|
+
"types": ["node"],
|
|
9
|
+
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
|
|
14
|
+
"noUncheckedIndexedAccess": true,
|
|
15
|
+
|
|
16
|
+
"strict": true,
|
|
17
|
+
"verbatimModuleSyntax": true,
|
|
18
|
+
"isolatedModules": true,
|
|
19
|
+
"noUncheckedSideEffectImports": true,
|
|
20
|
+
"moduleDetection": "force",
|
|
21
|
+
"skipLibCheck": true,
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*"],
|
|
24
|
+
"exclude": ["node_modules", "dist", "tests"]
|
|
25
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
coverage: {
|
|
8
|
+
reporter: ['text', 'json', 'html', 'json-summary'],
|
|
9
|
+
exclude: ['node_modules', 'dist', 'tests'],
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|