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.
@@ -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
- const aadBytes = Buffer.from(canonicalize(aad), "utf8");
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
- return {
166
- v: 1,
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(canonicalize(aad), "utf8");
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 = {
@@ -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
- const routeContext = protocolAdapter.classifyRequest(request);
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: addDays(createdAt, retentionDays).toISOString(),
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
+ }