haechi 1.7.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 +5 -1
- package/README.md +7 -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 +1 -1
- package/docs/current/config-version.md +1 -1
- package/docs/current/configuration.ko.md +16 -1
- package/docs/current/configuration.md +50 -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/risk-register-release-gate.ko.md +11 -8
- package/docs/current/risk-register-release-gate.md +11 -8
- package/docs/current/threat-model.ko.md +3 -3
- package/docs/current/threat-model.md +3 -3
- package/haechi.config.example.json +9 -0
- package/package.json +3 -2
- package/packages/cli/runtime.mjs +67 -0
- package/packages/crypto/index.mjs +39 -7
- package/packages/metrics/index.mjs +2 -1
- package/packages/proxy/index.mjs +116 -4
- package/packages/usage/index.mjs +177 -0
|
@@ -4,6 +4,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
const ALG = "AES-256-GCM";
|
|
6
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";
|
|
7
13
|
|
|
8
14
|
// Random 96-bit GCM IVs are only safe up to a bounded number of invocations per
|
|
9
15
|
// key: by the birthday bound the IV-collision probability stays negligible only
|
|
@@ -159,23 +165,33 @@ export function createLocalCryptoProvider({ keyFile }) {
|
|
|
159
165
|
await consumeNonceBudget(kid);
|
|
160
166
|
const iv = randomBytes(12);
|
|
161
167
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
162
|
-
|
|
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
|
+
);
|
|
163
179
|
cipher.setAAD(aadBytes);
|
|
164
180
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
165
181
|
const tag = cipher.getAuthTag();
|
|
166
182
|
const envelope = {
|
|
167
|
-
v:
|
|
183
|
+
v: 3,
|
|
168
184
|
alg: ALG,
|
|
169
185
|
kid,
|
|
170
186
|
iv: iv.toString("base64url"),
|
|
171
187
|
ct: ciphertext.toString("base64url"),
|
|
172
188
|
tag: tag.toString("base64url"),
|
|
173
189
|
aadHash: sha256(aadBytes),
|
|
174
|
-
aadEncoding:
|
|
175
|
-
createdAt
|
|
190
|
+
aadEncoding: CRYPTO_AAD_ENCODING_V3,
|
|
191
|
+
createdAt
|
|
176
192
|
};
|
|
177
|
-
if (
|
|
178
|
-
envelope.expiresAt =
|
|
193
|
+
if (normalizedExpiresAt !== null) {
|
|
194
|
+
envelope.expiresAt = normalizedExpiresAt;
|
|
179
195
|
}
|
|
180
196
|
return envelope;
|
|
181
197
|
},
|
|
@@ -429,10 +445,26 @@ export function canonicalizeCryptoAad(value) {
|
|
|
429
445
|
}
|
|
430
446
|
|
|
431
447
|
function canonicalizeAadForEnvelope(envelope, aad) {
|
|
432
|
-
if (
|
|
448
|
+
if (
|
|
449
|
+
envelope.aadEncoding &&
|
|
450
|
+
envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V2 &&
|
|
451
|
+
envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V3
|
|
452
|
+
) {
|
|
433
453
|
throw new Error(`Unsupported crypto AAD encoding: ${envelope.aadEncoding}`);
|
|
434
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
|
+
}
|
|
435
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.
|
|
436
468
|
return canonicalizeCryptoAad(aad);
|
|
437
469
|
}
|
|
438
470
|
return canonicalize(aad);
|
|
@@ -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
|
|
@@ -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
|
+
}
|