haechi 0.5.0 → 0.6.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.
@@ -7,24 +7,56 @@ export const DEFAULT_PROXY_PORT = 1016;
7
7
  export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "127.0.0.1", allowRemoteBind = false }) {
8
8
  assertSafeProxyBind({ host, allowRemoteBind });
9
9
  const { haechi, config, protocolAdapter } = runtime;
10
+ const rateLimiter = createRateLimiter();
10
11
 
11
12
  const server = createServer(async (request, response) => {
12
13
  try {
13
14
  if (request.method === "GET" && request.url === "/__haechi/health") {
15
+ // Intentionally unauthenticated; exposes only the mode.
14
16
  writeJson(response, 200, { ok: true, mode: config.mode });
15
17
  return;
16
18
  }
17
19
 
18
20
  assertRelativeProxyTarget(request.url);
19
21
  const routeContext = protocolAdapter.classifyRequest(request);
22
+
23
+ // Authenticate, resolve the policy profile, and rate-limit BEFORE reading
24
+ // the body, so a denied/throttled request cannot stream a large body.
25
+ const gate = await authorizeRequest({ runtime, request, routeContext, rateLimiter });
26
+ if (gate.denied) {
27
+ writeJson(response, gate.denied.status, {
28
+ error: gate.denied.error,
29
+ message: gate.denied.message
30
+ });
31
+ return;
32
+ }
33
+ const { identity, profile, policyEngine, modelAllowlist } = gate;
34
+ const authContext = { identity, profile, policyEngine };
35
+
20
36
  const body = await readBody(request, {
21
37
  maxBytes: config.limits.maxRequestBytes
22
38
  });
23
39
  const json = parseJsonBody(body);
24
40
 
41
+ // Model allowlist runs after body read (the model field is in the body).
42
+ if (modelAllowlist && typeof json?.model === "string" && !modelAllowlist.includes(json.model)) {
43
+ await recordProxyDecision({
44
+ runtime, routeContext, identity, profile,
45
+ decision: "model_not_allowed",
46
+ reason: `model:${json.model}`,
47
+ enforced: true,
48
+ blocked: true
49
+ });
50
+ writeJson(response, 403, {
51
+ error: "haechi_model_not_allowed",
52
+ message: `Model not allowed: ${json.model}`
53
+ });
54
+ return;
55
+ }
56
+
25
57
  if (isStreamingRequest(json, routeContext)) {
26
58
  if (config.streaming.requestMode === "inspect") {
27
- await handleInspectedStream({ runtime, request, response, routeContext, json });
59
+ await handleInspectedStream({ runtime, request, response, routeContext, json, authContext });
28
60
  return;
29
61
  }
30
62
 
@@ -32,6 +64,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
32
64
  await recordProxyDecision({
33
65
  runtime,
34
66
  routeContext,
67
+ identity,
68
+ profile,
35
69
  decision: "streaming_request_pass_through",
36
70
  reason: "streaming_request_pass_through",
37
71
  enforced: false,
@@ -59,6 +93,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
59
93
  const result = routeContext.protectRequest
60
94
  ? await haechi.protectJson(json, {
61
95
  ...routeContext,
96
+ ...authContext,
62
97
  operation: `request:${routeContext.operation}`,
63
98
  direction: "request",
64
99
  mode: config.policy.mode ?? config.mode
@@ -85,6 +120,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
85
120
  upstreamResponse,
86
121
  routeContext,
87
122
  runtime,
123
+ authContext,
88
124
  issuedTokens: result.issuedTokens ?? []
89
125
  });
90
126
 
@@ -120,7 +156,89 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
120
156
  };
121
157
  }
122
158
 
123
- async function handleInspectedStream({ runtime, request, response, routeContext, json }) {
159
+ // Authenticate resolve policy profile rate-limit. Returns the request's
160
+ // identity/profile/policyEngine/modelAllowlist, or a denial. Auth is required
161
+ // exactly when an authProvider is configured (auth.provider !== "none").
162
+ async function authorizeRequest({ runtime, request, routeContext, rateLimiter }) {
163
+ const { authProvider, policyProfiles } = runtime;
164
+ let identity = null;
165
+
166
+ if (authProvider) {
167
+ try {
168
+ identity = await authProvider.authenticate(request);
169
+ } catch {
170
+ await recordAuthDenied({ runtime, routeContext, reason: "provider_error" });
171
+ return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication failed" } };
172
+ }
173
+ if (!identity) {
174
+ const reason = hasBearerHeader(request) ? "invalid_token" : "no_token";
175
+ await recordAuthDenied({ runtime, routeContext, reason });
176
+ return { denied: { status: 401, error: "haechi_auth_denied", message: "Authentication required" } };
177
+ }
178
+ }
179
+
180
+ const resolved = policyProfiles.resolve(identity);
181
+
182
+ if (resolved.rate && resolved.rate.requestsPerMinute) {
183
+ const key = identity?.id ?? "anonymous";
184
+ if (!rateLimiter.allow(key, resolved.rate.requestsPerMinute)) {
185
+ await recordProxyDecision({
186
+ runtime, routeContext, identity, profile: resolved.profile,
187
+ decision: "rate_limited",
188
+ reason: `rate:${resolved.rate.requestsPerMinute}`,
189
+ enforced: true,
190
+ blocked: true
191
+ });
192
+ return { denied: { status: 429, error: "haechi_rate_limited", message: "Rate limit exceeded" } };
193
+ }
194
+ }
195
+
196
+ return {
197
+ identity,
198
+ profile: resolved.profile,
199
+ policyEngine: resolved.policyEngine,
200
+ modelAllowlist: resolved.modelAllowlist
201
+ };
202
+ }
203
+
204
+ function hasBearerHeader(request) {
205
+ const header = request?.headers?.authorization ?? request?.headers?.Authorization;
206
+ return typeof header === "string" && /^Bearer\s+/i.test(header.trim());
207
+ }
208
+
209
+ function createRateLimiter() {
210
+ // In-memory fixed-window counter. Per-process: resets on restart, not shared
211
+ // across replicas — acceptable for a single-process self-hosted preview.
212
+ const windows = new Map();
213
+ const windowMs = 60000;
214
+ return {
215
+ allow(key, limit) {
216
+ const now = Date.now();
217
+ const slot = windows.get(key);
218
+ if (!slot || now - slot.windowStart >= windowMs) {
219
+ windows.set(key, { windowStart: now, count: 1 });
220
+ return true;
221
+ }
222
+ if (slot.count >= limit) {
223
+ return false;
224
+ }
225
+ slot.count += 1;
226
+ return true;
227
+ }
228
+ };
229
+ }
230
+
231
+ async function recordAuthDenied({ runtime, routeContext, reason }) {
232
+ await recordProxyDecision({
233
+ runtime, routeContext, identity: null, profile: null,
234
+ decision: "auth_denied",
235
+ reason,
236
+ enforced: true,
237
+ blocked: true
238
+ });
239
+ }
240
+
241
+ async function handleInspectedStream({ runtime, request, response, routeContext, json, authContext = {} }) {
124
242
  const { haechi, config } = runtime;
125
243
 
126
244
  // Inspection needs to know the wire format and delta channel for this route.
@@ -137,6 +255,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
137
255
  const requestResult = routeContext.protectRequest
138
256
  ? await haechi.protectJson(json, {
139
257
  ...routeContext,
258
+ ...authContext,
140
259
  operation: `request:${routeContext.operation}`,
141
260
  direction: "request",
142
261
  mode: config.policy.mode ?? config.mode
@@ -162,6 +281,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
162
281
  const streamMode = config.streaming.responseMode ?? config.responseProtection.mode ?? config.policy.mode ?? config.mode;
163
282
  const protector = haechi.createStreamProtector({
164
283
  ...routeContext,
284
+ ...authContext,
165
285
  operation: `response-stream:${routeContext.operation}`,
166
286
  direction: "response",
167
287
  mode: streamMode,
@@ -177,7 +297,10 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
177
297
  protector
178
298
  });
179
299
 
180
- await recordStreamDecision({ runtime, routeContext, blocked, summary, mode: streamMode });
300
+ await recordStreamDecision({
301
+ runtime, routeContext, blocked, summary, mode: streamMode,
302
+ identity: authContext.identity ?? null, profile: authContext.profile ?? null
303
+ });
181
304
  response.end();
182
305
  }
183
306
 
@@ -200,7 +323,7 @@ async function* emptyAsyncIterable() {
200
323
  // No upstream body to inspect.
201
324
  }
202
325
 
203
- async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode }) {
326
+ async function recordStreamDecision({ runtime, routeContext, blocked, summary, mode, identity = null, profile = null }) {
204
327
  if (typeof runtime.auditSink?.record !== "function") {
205
328
  return;
206
329
  }
@@ -210,7 +333,8 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
210
333
  protocol: routeContext?.protocol ?? "proxy",
211
334
  operation: `response-stream:${routeContext?.operation ?? "unknown"}`,
212
335
  mode,
213
- identity: null,
336
+ identity,
337
+ profile,
214
338
  enforced: !["dry-run", "report-only"].includes(mode),
215
339
  blocked,
216
340
  decision: blocked ? "stream_blocked" : "stream_inspected",
@@ -221,7 +345,7 @@ async function recordStreamDecision({ runtime, routeContext, blocked, summary, m
221
345
  });
222
346
  }
223
347
 
224
- async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, issuedTokens = [] }) {
348
+ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, authContext = {}, issuedTokens = [] }) {
225
349
  const headers = Object.fromEntries(upstreamResponse.headers.entries());
226
350
 
227
351
  if (!runtime.config.responseProtection.enabled || !routeContext.protectResponse) {
@@ -311,6 +435,7 @@ async function maybeProtectResponse({ upstreamResponse, routeContext, runtime, i
311
435
 
312
436
  const result = await runtime.haechi.protectJson(json, {
313
437
  ...routeContext,
438
+ ...authContext,
314
439
  operation: `response:${routeContext.operation}`,
315
440
  direction: "response",
316
441
  mode: runtime.config.responseProtection.mode ?? runtime.config.policy.mode ?? runtime.config.mode
@@ -595,7 +720,7 @@ async function cancelReader(reader) {
595
720
  }
596
721
  }
597
722
 
598
- async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked }) {
723
+ async function recordProxyDecision({ runtime, routeContext, decision, reason, enforced, blocked, identity = null, profile = null }) {
599
724
  if (typeof runtime.auditSink?.record !== "function") {
600
725
  return;
601
726
  }
@@ -606,7 +731,8 @@ async function recordProxyDecision({ runtime, routeContext, decision, reason, en
606
731
  protocol: routeContext?.protocol ?? "proxy",
607
732
  operation: routeContext ? `proxy:${routeContext.protocol}:${routeContext.routeId ?? "unknown"}` : "proxy",
608
733
  mode: runtime.config.policy.mode ?? runtime.config.mode,
609
- identity: null,
734
+ identity,
735
+ profile,
610
736
  enforced,
611
737
  blocked,
612
738
  decision,