haechi 1.6.0 → 1.8.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.
- package/README.ko.md +7 -1
- package/README.md +9 -1
- package/docs/README.md +5 -0
- package/docs/current/api-stability.ko.md +5 -4
- package/docs/current/api-stability.md +5 -4
- package/docs/current/config-version.ko.md +2 -2
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +16 -1
- package/docs/current/configuration.md +50 -1
- package/docs/current/operations-runbook.ko.md +1 -1
- package/docs/current/operations-runbook.md +1 -1
- package/docs/current/plugin-signing-and-trust.ko.md +1 -1
- package/docs/current/plugin-signing-and-trust.md +1 -1
- package/docs/current/release-1.8-implementation-scope.ko.md +200 -0
- package/docs/current/release-1.8-implementation-scope.md +230 -0
- package/docs/current/release-process.ko.md +1 -1
- package/docs/current/release-process.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +12 -8
- package/docs/current/risk-register-release-gate.md +12 -8
- package/docs/current/shared-responsibility.ko.md +1 -1
- package/docs/current/shared-responsibility.md +1 -1
- package/docs/current/threat-model.ko.md +3 -2
- package/docs/current/threat-model.md +3 -2
- package/haechi.config.example.json +9 -0
- package/package.json +3 -2
- package/packages/cli/runtime.mjs +67 -0
- package/packages/crypto/index.mjs +101 -6
- package/packages/metrics/index.mjs +2 -1
- package/packages/proxy/index.mjs +116 -4
- package/packages/token-vault/index.mjs +3 -2
- package/packages/usage/index.mjs +177 -0
|
@@ -3,6 +3,13 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
|
|
5
5
|
const ALG = "AES-256-GCM";
|
|
6
|
+
export const CRYPTO_AAD_ENCODING_V2 = "nfkc-json-v2";
|
|
7
|
+
// v3 binds the envelope's freshness fields (createdAt/expiresAt) into the AAD
|
|
8
|
+
// itself, so tampering them (e.g. deleting/extending expiresAt on a stored
|
|
9
|
+
// envelope) fails the GCM auth tag instead of silently bypassing expiry. The
|
|
10
|
+
// v2 AAD encoding is already published and MUST NOT change (legacy decrypt
|
|
11
|
+
// stays on canonicalizeCryptoAad(aad) exactly as before) — v3 is additive.
|
|
12
|
+
export const CRYPTO_AAD_ENCODING_V3 = "nfkc-json-v3";
|
|
6
13
|
|
|
7
14
|
// Random 96-bit GCM IVs are only safe up to a bounded number of invocations per
|
|
8
15
|
// key: by the birthday bound the IV-collision probability stays negligible only
|
|
@@ -151,26 +158,42 @@ export function createLocalCryptoProvider({ keyFile }) {
|
|
|
151
158
|
readsPlaintext: true,
|
|
152
159
|
networkEgress: false
|
|
153
160
|
},
|
|
154
|
-
async encrypt({ plaintext, aad }) {
|
|
161
|
+
async encrypt({ plaintext, aad, expiresAt = null }) {
|
|
155
162
|
const { active: { kid, key } } = await loadKeys();
|
|
156
163
|
// Fail closed at the per-key random-IV invocation limit BEFORE choosing an
|
|
157
164
|
// IV, so we never generate a nonce past the safe budget (NIST SP 800-38D).
|
|
158
165
|
await consumeNonceBudget(kid);
|
|
159
166
|
const iv = randomBytes(12);
|
|
160
167
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
161
|
-
|
|
168
|
+
// Compute freshness FIRST so the AAD binds them (v3): a tampered
|
|
169
|
+
// createdAt/expiresAt on a stored envelope now fails the GCM auth tag
|
|
170
|
+
// instead of silently bypassing expiry.
|
|
171
|
+
const createdAt = new Date().toISOString();
|
|
172
|
+
const normalizedExpiresAt = expiresAt !== null && expiresAt !== undefined
|
|
173
|
+
? normalizeEnvelopeExpiry(expiresAt)
|
|
174
|
+
: null;
|
|
175
|
+
const aadBytes = Buffer.from(
|
|
176
|
+
canonicalizeCryptoAad({ aad, createdAt, expiresAt: normalizedExpiresAt }),
|
|
177
|
+
"utf8"
|
|
178
|
+
);
|
|
162
179
|
cipher.setAAD(aadBytes);
|
|
163
180
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
164
181
|
const tag = cipher.getAuthTag();
|
|
165
|
-
|
|
166
|
-
v:
|
|
182
|
+
const envelope = {
|
|
183
|
+
v: 3,
|
|
167
184
|
alg: ALG,
|
|
168
185
|
kid,
|
|
169
186
|
iv: iv.toString("base64url"),
|
|
170
187
|
ct: ciphertext.toString("base64url"),
|
|
171
188
|
tag: tag.toString("base64url"),
|
|
172
|
-
aadHash: sha256(aadBytes)
|
|
189
|
+
aadHash: sha256(aadBytes),
|
|
190
|
+
aadEncoding: CRYPTO_AAD_ENCODING_V3,
|
|
191
|
+
createdAt
|
|
173
192
|
};
|
|
193
|
+
if (normalizedExpiresAt !== null) {
|
|
194
|
+
envelope.expiresAt = normalizedExpiresAt;
|
|
195
|
+
}
|
|
196
|
+
return envelope;
|
|
174
197
|
},
|
|
175
198
|
// Keyed hash over a domain-separated derived key. The raw stored key is an
|
|
176
199
|
// AES-256-GCM key and must never be used for HMAC directly; every use case
|
|
@@ -189,12 +212,13 @@ export function createLocalCryptoProvider({ keyFile }) {
|
|
|
189
212
|
if (envelope.alg && envelope.alg !== ALG) {
|
|
190
213
|
throw new Error(`Unsupported local crypto algorithm: ${envelope.alg}`);
|
|
191
214
|
}
|
|
215
|
+
assertEnvelopeFresh(envelope);
|
|
192
216
|
const selected = envelope.kid ? byKid.get(envelope.kid) : active;
|
|
193
217
|
if (!selected) {
|
|
194
218
|
throw new Error(`Unknown key id in envelope: ${envelope.kid}`);
|
|
195
219
|
}
|
|
196
220
|
const { key } = selected;
|
|
197
|
-
const aadBytes = Buffer.from(
|
|
221
|
+
const aadBytes = Buffer.from(canonicalizeAadForEnvelope(envelope, aad), "utf8");
|
|
198
222
|
if (envelope.aadHash && envelope.aadHash !== sha256(aadBytes)) {
|
|
199
223
|
throw new Error("AAD hash mismatch");
|
|
200
224
|
}
|
|
@@ -397,6 +421,77 @@ export function canonicalize(value) {
|
|
|
397
421
|
return JSON.stringify(value);
|
|
398
422
|
}
|
|
399
423
|
|
|
424
|
+
export function canonicalizeCryptoAad(value) {
|
|
425
|
+
if (Array.isArray(value)) {
|
|
426
|
+
return `[${value.map((item) => canonicalizeCryptoAad(item)).join(",")}]`;
|
|
427
|
+
}
|
|
428
|
+
if (value && typeof value === "object") {
|
|
429
|
+
const seen = new Set();
|
|
430
|
+
const entries = [];
|
|
431
|
+
for (const key of Object.keys(value)) {
|
|
432
|
+
const normalizedKey = key.normalize("NFKC");
|
|
433
|
+
if (seen.has(normalizedKey)) {
|
|
434
|
+
throw new Error(`crypto AAD NFKC key collision: ${JSON.stringify(normalizedKey)}`);
|
|
435
|
+
}
|
|
436
|
+
seen.add(normalizedKey);
|
|
437
|
+
entries.push(`${JSON.stringify(normalizedKey)}:${canonicalizeCryptoAad(value[key])}`);
|
|
438
|
+
}
|
|
439
|
+
return `{${entries.sort().join(",")}}`;
|
|
440
|
+
}
|
|
441
|
+
if (typeof value === "string") {
|
|
442
|
+
return JSON.stringify(value.normalize("NFKC"));
|
|
443
|
+
}
|
|
444
|
+
return JSON.stringify(value);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function canonicalizeAadForEnvelope(envelope, aad) {
|
|
448
|
+
if (
|
|
449
|
+
envelope.aadEncoding &&
|
|
450
|
+
envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V2 &&
|
|
451
|
+
envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V3
|
|
452
|
+
) {
|
|
453
|
+
throw new Error(`Unsupported crypto AAD encoding: ${envelope.aadEncoding}`);
|
|
454
|
+
}
|
|
455
|
+
if (envelope.aadEncoding === CRYPTO_AAD_ENCODING_V3 || envelope.v === 3) {
|
|
456
|
+
// v3: freshness fields are bound into the AAD, so a tampered
|
|
457
|
+
// createdAt/expiresAt no longer canonicalizes to the same bytes the
|
|
458
|
+
// encryptor authenticated — the GCM tag check below fails closed.
|
|
459
|
+
return canonicalizeCryptoAad({
|
|
460
|
+
aad,
|
|
461
|
+
createdAt: envelope.createdAt ?? null,
|
|
462
|
+
expiresAt: envelope.expiresAt ?? null
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (envelope.aadEncoding === CRYPTO_AAD_ENCODING_V2 || envelope.v === 2) {
|
|
466
|
+
// v2 (legacy, UNCHANGED): freshness fields are NOT bound into the AAD, so
|
|
467
|
+
// they remain advisory/unauthenticated for v2 envelopes by design.
|
|
468
|
+
return canonicalizeCryptoAad(aad);
|
|
469
|
+
}
|
|
470
|
+
return canonicalize(aad);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function normalizeEnvelopeExpiry(expiresAt) {
|
|
474
|
+
const iso = expiresAt instanceof Date ? expiresAt.toISOString() : String(expiresAt);
|
|
475
|
+
const ts = Date.parse(iso);
|
|
476
|
+
if (!Number.isFinite(ts)) {
|
|
477
|
+
throw new Error("crypto envelope expiresAt must be a valid timestamp");
|
|
478
|
+
}
|
|
479
|
+
return new Date(ts).toISOString();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function assertEnvelopeFresh(envelope) {
|
|
483
|
+
if (!envelope.expiresAt) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const expiresAt = Date.parse(envelope.expiresAt);
|
|
487
|
+
if (!Number.isFinite(expiresAt)) {
|
|
488
|
+
throw new Error("crypto envelope expiresAt is invalid");
|
|
489
|
+
}
|
|
490
|
+
if (Date.now() >= expiresAt) {
|
|
491
|
+
throw new Error("Crypto envelope expired");
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
400
495
|
function sha256(value) {
|
|
401
496
|
return createHash("sha256").update(value).digest("base64url");
|
|
402
497
|
}
|
|
@@ -27,7 +27,8 @@ const COUNTERS = {
|
|
|
27
27
|
haechi_upstream_error_total: "Upstream requests that failed (non-timeout).",
|
|
28
28
|
haechi_response_unprotected_total: "Responses forwarded without protection (size/encoding/parse).",
|
|
29
29
|
haechi_internal_error_total: "Unexpected internal proxy errors.",
|
|
30
|
-
haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503)."
|
|
30
|
+
haechi_overloaded_total: "Requests rejected by the max-in-flight backpressure ceiling (503).",
|
|
31
|
+
haechi_usage_record_failed_total: "Usage recorder failures after a proxied request completed."
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
const HISTOGRAMS = {
|
package/packages/proxy/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { readFileSync } from "node:fs";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { once } from "node:events";
|
|
8
8
|
import { inspectResponseStream } from "../stream-filter/index.mjs";
|
|
9
|
+
import { recordUsageEvent } from "../usage/index.mjs";
|
|
9
10
|
|
|
10
11
|
export const DEFAULT_PROXY_PORT = 11016;
|
|
11
12
|
|
|
@@ -142,6 +143,16 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
142
143
|
const correlationId = randomUUID();
|
|
143
144
|
const startedAt = process.hrtime.bigint();
|
|
144
145
|
let routeId = "unknown";
|
|
146
|
+
let routeContext = null;
|
|
147
|
+
let usageIdentity = null;
|
|
148
|
+
let usageProfile = null;
|
|
149
|
+
let usageRequestBytes = null;
|
|
150
|
+
let usageResponseBytes = null;
|
|
151
|
+
let usageModel = null;
|
|
152
|
+
let usageDecision = "unknown";
|
|
153
|
+
let usageBlocked = false;
|
|
154
|
+
let usageUpstreamReached = false;
|
|
155
|
+
let usageUpstreamStatusCode = null;
|
|
145
156
|
|
|
146
157
|
// Observability routes are exempt from the in-flight ceiling and are NOT
|
|
147
158
|
// counted toward it: liveness/readiness/metrics must answer under saturation.
|
|
@@ -209,7 +220,7 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
assertRelativeProxyTarget(request.url);
|
|
212
|
-
|
|
223
|
+
routeContext = protocolAdapter.classifyRequest(request);
|
|
213
224
|
routeId = routeContext?.routeId ?? "unknown";
|
|
214
225
|
const mode = config.policy.mode ?? config.mode;
|
|
215
226
|
|
|
@@ -217,6 +228,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
217
228
|
// the body, so a denied/throttled request cannot stream a large body.
|
|
218
229
|
const gate = await authorizeRequest({ runtime, request, routeContext, rateLimiter, metrics, correlationId });
|
|
219
230
|
if (gate.denied) {
|
|
231
|
+
usageDecision = gate.denied.error?.replace(/^haechi_/, "") ?? "denied";
|
|
232
|
+
usageBlocked = true;
|
|
220
233
|
writeJson(response, gate.denied.status, {
|
|
221
234
|
error: gate.denied.error,
|
|
222
235
|
message: gate.denied.message
|
|
@@ -224,13 +237,17 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
224
237
|
return;
|
|
225
238
|
}
|
|
226
239
|
const { identity, profile, policyEngine, modelAllowlist } = gate;
|
|
240
|
+
usageIdentity = identity;
|
|
241
|
+
usageProfile = profile;
|
|
227
242
|
const authContext = { identity, profile, policyEngine, correlationId };
|
|
228
243
|
|
|
229
244
|
const body = await readBody(request, {
|
|
230
245
|
maxBytes: config.limits.maxRequestBytes,
|
|
231
246
|
response
|
|
232
247
|
});
|
|
248
|
+
usageRequestBytes = Buffer.byteLength(body, "utf8");
|
|
233
249
|
const json = parseJsonBody(body);
|
|
250
|
+
usageModel = typeof json?.model === "string" ? json.model : null;
|
|
234
251
|
|
|
235
252
|
// Model allowlist runs after body read (the model field is in the body).
|
|
236
253
|
if (modelAllowlist && typeof json?.model === "string" && !modelAllowlist.includes(json.model)) {
|
|
@@ -243,6 +260,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
243
260
|
});
|
|
244
261
|
countDecision(metrics, { routeContext, mode, decision: "model_not_allowed" });
|
|
245
262
|
metrics.increment("haechi_blocks_total");
|
|
263
|
+
usageDecision = "model_not_allowed";
|
|
264
|
+
usageBlocked = true;
|
|
246
265
|
writeJson(response, 403, {
|
|
247
266
|
error: "haechi_model_not_allowed",
|
|
248
267
|
message: `Model not allowed: ${json.model}`
|
|
@@ -252,7 +271,11 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
252
271
|
|
|
253
272
|
if (isStreamingRequest(json, routeContext)) {
|
|
254
273
|
if (config.streaming.requestMode === "inspect") {
|
|
255
|
-
await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics, forwardPolicy });
|
|
274
|
+
const streamOutcome = await handleInspectedStream({ runtime, request, response, routeContext, json, authContext, metrics, forwardPolicy });
|
|
275
|
+
usageDecision = streamOutcome.decision;
|
|
276
|
+
usageBlocked = streamOutcome.blocked;
|
|
277
|
+
usageUpstreamReached = streamOutcome.upstreamReached;
|
|
278
|
+
usageUpstreamStatusCode = streamOutcome.upstreamStatusCode;
|
|
256
279
|
return;
|
|
257
280
|
}
|
|
258
281
|
|
|
@@ -282,6 +305,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
282
305
|
forwardPolicy,
|
|
283
306
|
abortController: streamAbort
|
|
284
307
|
});
|
|
308
|
+
usageUpstreamReached = true;
|
|
309
|
+
usageUpstreamStatusCode = upstreamResponse.status;
|
|
285
310
|
// P1-CR-003 — sanitize response headers (strip the upstream's
|
|
286
311
|
// content-encoding/content-length/transfer/hop-by-hop) on this path
|
|
287
312
|
// too: Node fetch() auto-decompressed the body, so the original
|
|
@@ -299,10 +324,13 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
299
324
|
metrics,
|
|
300
325
|
correlationId
|
|
301
326
|
});
|
|
327
|
+
usageDecision = "streaming_request_pass_through";
|
|
302
328
|
return;
|
|
303
329
|
}
|
|
304
330
|
|
|
305
331
|
countDecision(metrics, { routeContext, mode, decision: "streaming_blocked" });
|
|
332
|
+
usageDecision = "streaming_blocked";
|
|
333
|
+
usageBlocked = true;
|
|
306
334
|
writeJson(response, 501, {
|
|
307
335
|
error: "haechi_streaming_unsupported",
|
|
308
336
|
message: "Streaming requests are blocked unless streaming.requestMode is set to pass-through or inspect"
|
|
@@ -323,6 +351,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
323
351
|
if (result.blocked) {
|
|
324
352
|
countDecision(metrics, { routeContext, mode, decision: "blocked" });
|
|
325
353
|
metrics.increment("haechi_blocks_total");
|
|
354
|
+
usageDecision = "blocked";
|
|
355
|
+
usageBlocked = true;
|
|
326
356
|
writeJson(response, 403, {
|
|
327
357
|
error: "haechi_policy_block",
|
|
328
358
|
summary: result.summary,
|
|
@@ -339,6 +369,8 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
339
369
|
metrics,
|
|
340
370
|
forwardPolicy
|
|
341
371
|
});
|
|
372
|
+
usageUpstreamReached = true;
|
|
373
|
+
usageUpstreamStatusCode = upstreamResponse.status;
|
|
342
374
|
|
|
343
375
|
const forwarded = await maybeProtectResponse({
|
|
344
376
|
upstreamResponse,
|
|
@@ -354,10 +386,15 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
354
386
|
mode,
|
|
355
387
|
decision: forwarded.decision ?? "forwarded"
|
|
356
388
|
});
|
|
389
|
+
usageDecision = forwarded.decision ?? "forwarded";
|
|
390
|
+
usageBlocked = forwarded.status >= 400 || forwarded.decision?.includes("blocked") || forwarded.decision === "response_blocked";
|
|
391
|
+
usageResponseBytes = Buffer.isBuffer(forwarded.body) ? forwarded.body.byteLength : null;
|
|
357
392
|
response.writeHead(forwarded.status, forwarded.headers);
|
|
358
393
|
response.end(forwarded.body);
|
|
359
394
|
} catch (error) {
|
|
360
395
|
const expected = typeof error?.statusCode === "number";
|
|
396
|
+
usageDecision = error?.errorCode ?? "proxy_error";
|
|
397
|
+
usageBlocked = true;
|
|
361
398
|
if (!expected) {
|
|
362
399
|
// Carry the error NAME/class + correlationId only — NEVER the payload,
|
|
363
400
|
// headers, token, or any PII.
|
|
@@ -380,6 +417,25 @@ export function createHaechiProxy({ runtime, port = DEFAULT_PROXY_PORT, host = "
|
|
|
380
417
|
}, extraHeaders);
|
|
381
418
|
} finally {
|
|
382
419
|
const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9;
|
|
420
|
+
await maybeRecordUsage({
|
|
421
|
+
runtime,
|
|
422
|
+
request,
|
|
423
|
+
routeContext,
|
|
424
|
+
identity: usageIdentity,
|
|
425
|
+
profile: usageProfile,
|
|
426
|
+
correlationId,
|
|
427
|
+
requestBytes: usageRequestBytes,
|
|
428
|
+
responseBytes: usageResponseBytes,
|
|
429
|
+
model: usageModel,
|
|
430
|
+
decision: usageDecision,
|
|
431
|
+
blocked: usageBlocked,
|
|
432
|
+
upstreamReached: usageUpstreamReached,
|
|
433
|
+
upstreamStatusCode: usageUpstreamStatusCode,
|
|
434
|
+
statusCode: response.statusCode,
|
|
435
|
+
durationMs: Math.round(elapsedSeconds * 1000),
|
|
436
|
+
exemptRoute,
|
|
437
|
+
metrics
|
|
438
|
+
});
|
|
383
439
|
// route label is a bounded route id (or "unknown") — never an identity/value.
|
|
384
440
|
metrics.observe("haechi_request_duration_seconds", elapsedSeconds, { route: routeId });
|
|
385
441
|
if (counted) {
|
|
@@ -545,6 +601,56 @@ function countDecision(metrics, { routeContext, mode, decision }) {
|
|
|
545
601
|
});
|
|
546
602
|
}
|
|
547
603
|
|
|
604
|
+
async function maybeRecordUsage({
|
|
605
|
+
runtime,
|
|
606
|
+
request,
|
|
607
|
+
routeContext,
|
|
608
|
+
identity,
|
|
609
|
+
profile,
|
|
610
|
+
correlationId,
|
|
611
|
+
requestBytes,
|
|
612
|
+
responseBytes,
|
|
613
|
+
model,
|
|
614
|
+
decision,
|
|
615
|
+
blocked,
|
|
616
|
+
upstreamReached,
|
|
617
|
+
upstreamStatusCode,
|
|
618
|
+
statusCode,
|
|
619
|
+
durationMs,
|
|
620
|
+
exemptRoute,
|
|
621
|
+
metrics
|
|
622
|
+
}) {
|
|
623
|
+
if (exemptRoute || !runtime.config.usage?.enabled) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
await recordUsageEvent({
|
|
628
|
+
usageRecorder: runtime.usageRecorder,
|
|
629
|
+
auditSink: runtime.auditSink,
|
|
630
|
+
audit: runtime.config.usage.audit,
|
|
631
|
+
event: {
|
|
632
|
+
correlationId,
|
|
633
|
+
routeContext,
|
|
634
|
+
identity,
|
|
635
|
+
profile,
|
|
636
|
+
method: request.method,
|
|
637
|
+
path: request.url,
|
|
638
|
+
requestBytes,
|
|
639
|
+
responseBytes,
|
|
640
|
+
statusCode,
|
|
641
|
+
outcomeDecision: decision,
|
|
642
|
+
blocked,
|
|
643
|
+
upstreamReached,
|
|
644
|
+
upstreamStatusCode,
|
|
645
|
+
model,
|
|
646
|
+
durationMs
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
} catch {
|
|
650
|
+
metrics?.increment("haechi_usage_record_failed_total");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
548
654
|
// Backward-compat fallback for a hand-built runtime object without metrics: a
|
|
549
655
|
// no-op collector with the same increment/observe/render contract.
|
|
550
656
|
function noopMetrics() {
|
|
@@ -685,7 +791,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
685
791
|
error: "haechi_streaming_uninspectable_route",
|
|
686
792
|
message: `streaming.requestMode is "inspect" but route ${routeContext.routeId} has no known streaming format`
|
|
687
793
|
});
|
|
688
|
-
return;
|
|
794
|
+
return { decision: "streaming_uninspectable_route", blocked: true, upstreamReached: false, upstreamStatusCode: null };
|
|
689
795
|
}
|
|
690
796
|
|
|
691
797
|
// The request body is ordinary JSON even when the response streams, so it is
|
|
@@ -708,7 +814,7 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
708
814
|
summary: requestResult.summary,
|
|
709
815
|
auditId: requestResult.auditEvent.id
|
|
710
816
|
});
|
|
711
|
-
return;
|
|
817
|
+
return { decision: "blocked", blocked: true, upstreamReached: false, upstreamStatusCode: null };
|
|
712
818
|
}
|
|
713
819
|
|
|
714
820
|
const upstreamResponse = await forward({
|
|
@@ -749,6 +855,12 @@ async function handleInspectedStream({ runtime, request, response, routeContext,
|
|
|
749
855
|
metrics?.increment("haechi_blocks_total");
|
|
750
856
|
}
|
|
751
857
|
response.end();
|
|
858
|
+
return {
|
|
859
|
+
decision: blocked ? "stream_blocked" : "stream_inspected",
|
|
860
|
+
blocked,
|
|
861
|
+
upstreamReached: true,
|
|
862
|
+
upstreamStatusCode: upstreamResponse.status
|
|
863
|
+
};
|
|
752
864
|
}
|
|
753
865
|
|
|
754
866
|
// P1-CR-003 — the SINGLE centralized response-header sanitizer used on EVERY
|
|
@@ -229,6 +229,7 @@ export function createTokenVault({
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
const createdAt = new Date();
|
|
232
|
+
const expiresAt = addDays(createdAt, retentionDays).toISOString();
|
|
232
233
|
const aad = {
|
|
233
234
|
purpose: "token-vault",
|
|
234
235
|
token,
|
|
@@ -238,9 +239,9 @@ export function createTokenVault({
|
|
|
238
239
|
view.set(token, {
|
|
239
240
|
type,
|
|
240
241
|
createdAt: createdAt.toISOString(),
|
|
241
|
-
expiresAt
|
|
242
|
+
expiresAt,
|
|
242
243
|
metadata: sanitizeMetadata(metadata),
|
|
243
|
-
envelope: await cryptoProvider.encrypt({ plaintext, aad }),
|
|
244
|
+
envelope: await cryptoProvider.encrypt({ plaintext, aad, expiresAt }),
|
|
244
245
|
aad
|
|
245
246
|
});
|
|
246
247
|
return { token, type };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const USAGE_EVENT_SCHEMA_VERSION = "1";
|
|
4
|
+
|
|
5
|
+
const SAFE_TOKEN = /^[A-Za-z0-9_.:/-]{1,64}$/;
|
|
6
|
+
|
|
7
|
+
export function createNoopUsageRecorder() {
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
id: "haechi.usage.noop",
|
|
10
|
+
async record() {
|
|
11
|
+
return { recorded: false };
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createNoopQuotaProvider() {
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
id: "haechi.quota.noop",
|
|
19
|
+
async check() {
|
|
20
|
+
return { allowed: true, reason: "not_configured" };
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function coerceUsageRecorder(provider) {
|
|
26
|
+
if (typeof provider === "function") {
|
|
27
|
+
return { id: "haechi.usage.function", record: provider };
|
|
28
|
+
}
|
|
29
|
+
if (provider && typeof provider.record === "function") {
|
|
30
|
+
return provider;
|
|
31
|
+
}
|
|
32
|
+
throw new Error("usageRecorder must be a function or an object with record(event)");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function coerceQuotaProvider(provider) {
|
|
36
|
+
if (typeof provider === "function") {
|
|
37
|
+
return { id: "haechi.quota.function", check: provider };
|
|
38
|
+
}
|
|
39
|
+
if (provider && typeof provider.check === "function") {
|
|
40
|
+
return provider;
|
|
41
|
+
}
|
|
42
|
+
throw new Error("quotaProvider must be a function or an object with check(context)");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizeQuotaDecision(result) {
|
|
46
|
+
if (result === true) {
|
|
47
|
+
return { allowed: true, reason: "allowed" };
|
|
48
|
+
}
|
|
49
|
+
if (result === false) {
|
|
50
|
+
return { allowed: false, reason: "denied" };
|
|
51
|
+
}
|
|
52
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
53
|
+
throw new Error("quotaProvider.check() must return boolean or { allowed, reason? }");
|
|
54
|
+
}
|
|
55
|
+
if (typeof result.allowed !== "boolean") {
|
|
56
|
+
throw new Error("quotaProvider.check() result.allowed must be boolean");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
allowed: result.allowed,
|
|
60
|
+
reason: safeToken(result.reason, result.allowed ? "allowed" : "denied"),
|
|
61
|
+
retryAfterSeconds: safePositiveInteger(result.retryAfterSeconds)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function projectUsageIdentity(identity) {
|
|
66
|
+
if (!identity) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
id: safeToken(identity.id, "unknown"),
|
|
71
|
+
type: safeToken(identity.type, "unknown"),
|
|
72
|
+
subjectHash: safeHash(identity.subjectHash),
|
|
73
|
+
issuerHash: safeHash(identity.issuerHash),
|
|
74
|
+
provider: safeToken(identity.provider, "unknown")
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildUsageEvent({
|
|
79
|
+
correlationId = null,
|
|
80
|
+
timestamp = new Date().toISOString(),
|
|
81
|
+
routeContext = null,
|
|
82
|
+
identity = null,
|
|
83
|
+
profile = null,
|
|
84
|
+
method = null,
|
|
85
|
+
path = null,
|
|
86
|
+
requestBytes = null,
|
|
87
|
+
responseBytes = null,
|
|
88
|
+
statusCode = null,
|
|
89
|
+
outcomeDecision = "unknown",
|
|
90
|
+
blocked = false,
|
|
91
|
+
upstreamReached = false,
|
|
92
|
+
upstreamStatusCode = null,
|
|
93
|
+
model = null,
|
|
94
|
+
durationMs = null
|
|
95
|
+
} = {}) {
|
|
96
|
+
const routeId = safeToken(routeContext?.routeId, "unknown");
|
|
97
|
+
const outcome = safeToken(outcomeDecision, "unknown");
|
|
98
|
+
const modelText = typeof model === "string" && model.length > 0 ? model : null;
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion: USAGE_EVENT_SCHEMA_VERSION,
|
|
101
|
+
id: randomUUID(),
|
|
102
|
+
eventType: "usage_recorded",
|
|
103
|
+
correlationId: typeof correlationId === "string" ? correlationId : null,
|
|
104
|
+
timestamp,
|
|
105
|
+
protocol: safeToken(routeContext?.protocol, "proxy"),
|
|
106
|
+
operation: `usage:${safeToken(routeContext?.protocol, "proxy")}:${routeId}`,
|
|
107
|
+
identity: projectUsageIdentity(identity),
|
|
108
|
+
profile: profile === null || profile === undefined ? null : safeToken(profile, "unknown"),
|
|
109
|
+
decision: "usage_recorded",
|
|
110
|
+
reason: "request_completed",
|
|
111
|
+
routeId,
|
|
112
|
+
method: safeToken(method, "UNKNOWN"),
|
|
113
|
+
pathHash: typeof path === "string" ? shortHash(path) : null,
|
|
114
|
+
outcome: {
|
|
115
|
+
decision: outcome,
|
|
116
|
+
blocked: Boolean(blocked),
|
|
117
|
+
statusCode: safeStatusCode(statusCode)
|
|
118
|
+
},
|
|
119
|
+
upstream: {
|
|
120
|
+
reached: Boolean(upstreamReached),
|
|
121
|
+
statusCode: safeStatusCode(upstreamStatusCode)
|
|
122
|
+
},
|
|
123
|
+
request: {
|
|
124
|
+
bytes: safeNonNegativeInteger(requestBytes),
|
|
125
|
+
modelHash: modelText ? shortHash(modelText) : null,
|
|
126
|
+
modelPresent: Boolean(modelText)
|
|
127
|
+
},
|
|
128
|
+
response: {
|
|
129
|
+
bytes: safeNonNegativeInteger(responseBytes)
|
|
130
|
+
},
|
|
131
|
+
durationMs: safeNonNegativeInteger(durationMs),
|
|
132
|
+
summary: {
|
|
133
|
+
detectionCount: 0,
|
|
134
|
+
byType: {},
|
|
135
|
+
byAction: {
|
|
136
|
+
usage_recorded: 1
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function recordUsageEvent({ usageRecorder, auditSink = null, audit = true, event }) {
|
|
143
|
+
const normalized = buildUsageEvent(event);
|
|
144
|
+
await usageRecorder.record(normalized);
|
|
145
|
+
if (audit && typeof auditSink?.record === "function") {
|
|
146
|
+
await auditSink.record(normalized);
|
|
147
|
+
}
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function safeToken(value, fallback) {
|
|
152
|
+
if (typeof value !== "string") {
|
|
153
|
+
return fallback;
|
|
154
|
+
}
|
|
155
|
+
const trimmed = value.trim();
|
|
156
|
+
return SAFE_TOKEN.test(trimmed) ? trimmed : fallback;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function safeHash(value) {
|
|
160
|
+
return typeof value === "string" && /^[a-f0-9]{16,128}$/i.test(value) ? value : "unknown";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function safeStatusCode(value) {
|
|
164
|
+
return Number.isInteger(value) && value >= 100 && value <= 599 ? value : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function safePositiveInteger(value) {
|
|
168
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function safeNonNegativeInteger(value) {
|
|
172
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function shortHash(value) {
|
|
176
|
+
return createHash("sha256").update(String(value)).digest("hex").slice(0, 12);
|
|
177
|
+
}
|