@opengeni/db 0.9.3 → 0.10.7
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/dist/{chunk-4LG5NBTC.js → chunk-P6PKXY5W.js} +93 -1
- package/dist/chunk-P6PKXY5W.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1332 -178
- package/dist/index.js.map +1 -1
- package/dist/provision-roles.d.ts +406 -32
- package/dist/{schema-CdPGTHlD.d.ts → schema-CqkzrBRS.d.ts} +513 -2
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +3 -1
- package/drizzle/0053_codex_credential_leases.sql +2 -2
- package/drizzle/0057_durable_queue_control.sql +1 -1
- package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
- package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
- package/drizzle/0063_session_control_mega_foundation.sql +1 -1
- package/drizzle/0064_rotation_strategy_sharded_backfill.sql +1 -1
- package/drizzle/0065_codex_subscription_overview.sql +168 -0
- package/drizzle/0065_session_tool_policy.sql +38 -0
- package/drizzle/0067_session_event_payload_bounds.sql +2 -2
- package/drizzle/0068_workspace_control_event_bounds.sql +2 -2
- package/drizzle/0069_session_event_history_backfill.sql +2 -2
- package/drizzle/0074_session_activity_revisions.sql +2 -2
- package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
- package/drizzle/0107_host_export_lineage_contract.sql +381 -0
- package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
- package/package.json +5 -4
- package/src/codex-token-resolver.ts +175 -14
- package/src/connection-token-resolver.ts +143 -120
- package/src/event-payload-sanitizer.ts +32 -2
- package/src/index.ts +1888 -205
- package/src/schema.ts +107 -1
- package/src/session-control.ts +2 -0
- package/src/session-queue-commands.ts +94 -21
- package/dist/chunk-4LG5NBTC.js.map +0 -1
|
@@ -11,8 +11,17 @@ import type {
|
|
|
11
11
|
TurnInitiator,
|
|
12
12
|
TurnInitiatorContext,
|
|
13
13
|
} from "@opengeni/contracts";
|
|
14
|
+
import {
|
|
15
|
+
OAUTH_MAX_RESPONSE_BYTES,
|
|
16
|
+
pinnedFetch,
|
|
17
|
+
readResponseJsonBounded,
|
|
18
|
+
undiciFetch,
|
|
19
|
+
validateHttpUrl,
|
|
20
|
+
type DnsLookup,
|
|
21
|
+
type FetchLike,
|
|
22
|
+
} from "@opengeni/network";
|
|
23
|
+
export { isPrivateAddress } from "@opengeni/network";
|
|
14
24
|
import { Buffer } from "node:buffer";
|
|
15
|
-
import { lookup } from "node:dns/promises";
|
|
16
25
|
import { isIP } from "node:net";
|
|
17
26
|
import { encryptEnvironmentValue } from "./environment-crypto";
|
|
18
27
|
import {
|
|
@@ -50,6 +59,8 @@ export type ResolveConnectionCredentialInput = {
|
|
|
50
59
|
/** @deprecated Use toolName. Retained for the API's pre-existing broker call shape. */
|
|
51
60
|
toolId?: string;
|
|
52
61
|
connectionRef: McpServerConnectionRef;
|
|
62
|
+
/** Exact MCP destination whose request would receive the resolved headers. */
|
|
63
|
+
destinationUrl: string;
|
|
53
64
|
forceRefresh?: boolean;
|
|
54
65
|
};
|
|
55
66
|
|
|
@@ -81,7 +92,8 @@ export class HostMcpCredentialBindingError extends Error {
|
|
|
81
92
|
| "connectionId"
|
|
82
93
|
| "scopes"
|
|
83
94
|
| "resource"
|
|
84
|
-
| "selectedResources"
|
|
95
|
+
| "selectedResources"
|
|
96
|
+
| "destinationUrl",
|
|
85
97
|
) {
|
|
86
98
|
super(`host MCP credential ${field} binding mismatch`);
|
|
87
99
|
this.name = "HostMcpCredentialBindingError";
|
|
@@ -102,6 +114,13 @@ export function buildHostConnectionTokenResolver(
|
|
|
102
114
|
if (input.workspaceId !== context.workspaceId) {
|
|
103
115
|
throw new HostMcpCredentialScopeError("workspaceId");
|
|
104
116
|
}
|
|
117
|
+
const destinationUrl = canonicalHttpUrl(input.destinationUrl);
|
|
118
|
+
if (
|
|
119
|
+
!destinationUrl ||
|
|
120
|
+
!destinationHostMatchesProvider(destinationUrl, input.connectionRef.providerDomain)
|
|
121
|
+
) {
|
|
122
|
+
throw new HostMcpCredentialBindingError("destinationUrl");
|
|
123
|
+
}
|
|
105
124
|
const toolName = input.toolName ?? input.toolId;
|
|
106
125
|
const request: McpCredentialsRequest = {
|
|
107
126
|
accountId: context.accountId,
|
|
@@ -114,6 +133,7 @@ export function buildHostConnectionTokenResolver(
|
|
|
114
133
|
initiator: context.initiator,
|
|
115
134
|
initiatorContext: { ...context.initiatorContext },
|
|
116
135
|
surface: context.surface,
|
|
136
|
+
destinationUrl,
|
|
117
137
|
serverId: input.serverId,
|
|
118
138
|
connectionRef: {
|
|
119
139
|
providerDomain: input.connectionRef.providerDomain,
|
|
@@ -317,6 +337,11 @@ export type ConnectionBrokerDeps = {
|
|
|
317
337
|
now: () => Date;
|
|
318
338
|
};
|
|
319
339
|
|
|
340
|
+
export type RefreshTransportOptions = {
|
|
341
|
+
fetchImpl?: FetchLike;
|
|
342
|
+
dnsLookup?: DnsLookup;
|
|
343
|
+
};
|
|
344
|
+
|
|
320
345
|
const defaultDeps: ConnectionBrokerDeps = {
|
|
321
346
|
loadCredential: loadConnectionCredentialForBroker,
|
|
322
347
|
recordRefresh: recordConnectionTokenRefresh,
|
|
@@ -337,8 +362,12 @@ export function buildConnectionTokenResolver(
|
|
|
337
362
|
settings: Settings,
|
|
338
363
|
deps: ConnectionBrokerDeps = defaultDeps,
|
|
339
364
|
): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
|
|
365
|
+
type CredentialLookupInput = Pick<
|
|
366
|
+
ResolveConnectionCredentialInput,
|
|
367
|
+
"workspaceId" | "connectionRef" | "subjectId"
|
|
368
|
+
>;
|
|
340
369
|
const load = async (
|
|
341
|
-
input:
|
|
370
|
+
input: CredentialLookupInput,
|
|
342
371
|
): Promise<ConnectionCredentialForBroker | null> => {
|
|
343
372
|
const request: Parameters<typeof loadConnectionCredentialForBroker>[2] = {
|
|
344
373
|
workspaceId: input.workspaceId,
|
|
@@ -361,10 +390,14 @@ export function buildConnectionTokenResolver(
|
|
|
361
390
|
const snapshot = async (
|
|
362
391
|
cred: ConnectionCredentialForBroker,
|
|
363
392
|
ref: McpServerConnectionRef,
|
|
393
|
+
destinationUrl: string,
|
|
364
394
|
): Promise<ResolveConnectionCredentialResult> => {
|
|
365
395
|
if (cred.status !== "active") {
|
|
366
396
|
return authNeededForStatus(cred, ref);
|
|
367
397
|
}
|
|
398
|
+
if (!connectionBindingMatches(cred, ref, destinationUrl)) {
|
|
399
|
+
return authNeeded(ref, "missing_connection", cred.id);
|
|
400
|
+
}
|
|
368
401
|
const missingScopes = missingRequestedScopes(ref.scopes, cred.grantedScopes);
|
|
369
402
|
if (missingScopes.length > 0) {
|
|
370
403
|
return {
|
|
@@ -428,7 +461,6 @@ export function buildConnectionTokenResolver(
|
|
|
428
461
|
if (persisted) {
|
|
429
462
|
const current = await load({
|
|
430
463
|
workspaceId: cred.workspaceId,
|
|
431
|
-
serverId: "",
|
|
432
464
|
connectionRef: { ...ref, connectionId: cred.id },
|
|
433
465
|
});
|
|
434
466
|
if (current) {
|
|
@@ -437,7 +469,6 @@ export function buildConnectionTokenResolver(
|
|
|
437
469
|
}
|
|
438
470
|
const winner = await load({
|
|
439
471
|
workspaceId: cred.workspaceId,
|
|
440
|
-
serverId: "",
|
|
441
472
|
connectionRef: { ...ref, connectionId: cred.id },
|
|
442
473
|
});
|
|
443
474
|
if (winner?.status === "active") {
|
|
@@ -485,6 +516,12 @@ export function buildConnectionTokenResolver(
|
|
|
485
516
|
if (cred.status !== "active") {
|
|
486
517
|
return authNeededForStatus(cred, ref);
|
|
487
518
|
}
|
|
519
|
+
// Reject an audience/destination mismatch before any provider-side refresh
|
|
520
|
+
// or usage update. Refreshing first would still create an unauthorized
|
|
521
|
+
// external side effect even though the token was never sent to the target.
|
|
522
|
+
if (!connectionBindingMatches(cred, ref, input.destinationUrl)) {
|
|
523
|
+
return authNeeded(ref, "missing_connection", cred.id);
|
|
524
|
+
}
|
|
488
525
|
if (shouldRefresh(cred, input.forceRefresh === true, deps.now())) {
|
|
489
526
|
try {
|
|
490
527
|
cred = await refreshSingleFlight(cred, ref);
|
|
@@ -508,10 +545,75 @@ export function buildConnectionTokenResolver(
|
|
|
508
545
|
return authNeeded(ref, "refresh_failed", cred.id);
|
|
509
546
|
}
|
|
510
547
|
}
|
|
511
|
-
return await snapshot(cred, ref);
|
|
548
|
+
return await snapshot(cred, ref, input.destinationUrl);
|
|
512
549
|
};
|
|
513
550
|
}
|
|
514
551
|
|
|
552
|
+
function connectionBindingMatches(
|
|
553
|
+
cred: ConnectionCredentialForBroker,
|
|
554
|
+
ref: McpServerConnectionRef,
|
|
555
|
+
destinationUrl: string,
|
|
556
|
+
): boolean {
|
|
557
|
+
if (cred.providerDomain.toLowerCase() !== ref.providerDomain.toLowerCase()) return false;
|
|
558
|
+
if (ref.kind && cred.kind !== ref.kind) return false;
|
|
559
|
+
|
|
560
|
+
const credential = cred.credential as Record<string, unknown>;
|
|
561
|
+
const metadata = cred.metadata as Record<string, unknown>;
|
|
562
|
+
const boundMcpUrl = stringValue(credential.mcp_url) ?? stringValue(metadata.mcpUrl);
|
|
563
|
+
const destination = canonicalHttpUrl(destinationUrl);
|
|
564
|
+
if (!destination) return false;
|
|
565
|
+
if (boundMcpUrl) {
|
|
566
|
+
const binding = canonicalHttpUrl(boundMcpUrl);
|
|
567
|
+
if (!binding || destination !== binding) return false;
|
|
568
|
+
} else if (!destinationHostMatchesProvider(destination, cred.providerDomain)) {
|
|
569
|
+
// Legacy/manual API-key rows may predate mcpUrl metadata. They are still
|
|
570
|
+
// host-bound to their canonical provider domain, never usable as an
|
|
571
|
+
// arbitrary bearer/header source for an unrelated MCP destination.
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
if (cred.kind !== "oauth2") return true;
|
|
575
|
+
const boundResource = stringValue(credential.resource) ?? stringValue(metadata.resource);
|
|
576
|
+
if (ref.resource) {
|
|
577
|
+
if (!boundResource) return false;
|
|
578
|
+
if (canonicalResource(ref.resource) !== canonicalResource(boundResource)) return false;
|
|
579
|
+
}
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function destinationHostMatchesProvider(destinationUrl: string, providerDomain: string): boolean {
|
|
584
|
+
const destinationHost = new URL(destinationUrl).hostname.toLowerCase();
|
|
585
|
+
const provider = providerDomain
|
|
586
|
+
.trim()
|
|
587
|
+
.toLowerCase()
|
|
588
|
+
.replace(/^\.+|\.+$/g, "");
|
|
589
|
+
return (
|
|
590
|
+
Boolean(provider) && (destinationHost === provider || destinationHost.endsWith(`.${provider}`))
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function canonicalHttpUrl(value: string): string | null {
|
|
595
|
+
try {
|
|
596
|
+
const url = new URL(value);
|
|
597
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
598
|
+
url.hash = "";
|
|
599
|
+
url.hostname = url.hostname.toLowerCase();
|
|
600
|
+
if (
|
|
601
|
+
(url.protocol === "https:" && url.port === "443") ||
|
|
602
|
+
(url.protocol === "http:" && url.port === "80")
|
|
603
|
+
) {
|
|
604
|
+
url.port = "";
|
|
605
|
+
}
|
|
606
|
+
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
607
|
+
return url.toString();
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function canonicalResource(value: string): string {
|
|
614
|
+
return canonicalHttpUrl(value) ?? value.trim();
|
|
615
|
+
}
|
|
616
|
+
|
|
515
617
|
export class ConnectionRefreshHttpError extends Error {
|
|
516
618
|
readonly httpStatus: number;
|
|
517
619
|
|
|
@@ -618,7 +720,8 @@ function headersForCredential(cred: ConnectionCredentialForBroker): Record<strin
|
|
|
618
720
|
export async function refreshOAuthConnectionCredential(
|
|
619
721
|
cred: ConnectionCredentialForBroker,
|
|
620
722
|
ref: McpServerConnectionRef,
|
|
621
|
-
settings
|
|
723
|
+
settings: Settings,
|
|
724
|
+
transportOptions: RefreshTransportOptions = {},
|
|
622
725
|
): Promise<{
|
|
623
726
|
credential: Record<string, unknown>;
|
|
624
727
|
expiresAt: Date | null;
|
|
@@ -639,8 +742,14 @@ export async function refreshOAuthConnectionCredential(
|
|
|
639
742
|
if (!refreshToken || !tokenEndpoint) {
|
|
640
743
|
throw new Error("connection has no refresh token endpoint");
|
|
641
744
|
}
|
|
642
|
-
|
|
643
|
-
|
|
745
|
+
let validatedTokenEndpoint: string;
|
|
746
|
+
try {
|
|
747
|
+
validatedTokenEndpoint = validateHttpUrl(tokenEndpoint, {
|
|
748
|
+
label: "OAuth refresh token endpoint",
|
|
749
|
+
allowLoopbackHttp: settings.environment === "local" || settings.environment === "test",
|
|
750
|
+
});
|
|
751
|
+
} catch {
|
|
752
|
+
throw new Error("connection has an invalid refresh token endpoint");
|
|
644
753
|
}
|
|
645
754
|
const body = new URLSearchParams();
|
|
646
755
|
body.set("grant_type", "refresh_token");
|
|
@@ -674,20 +783,35 @@ export async function refreshOAuthConnectionCredential(
|
|
|
674
783
|
if (ref.scopes?.length) {
|
|
675
784
|
body.set("scope", ref.scopes.join(" "));
|
|
676
785
|
}
|
|
677
|
-
const response = await
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
786
|
+
const response = await pinnedFetch(
|
|
787
|
+
validatedTokenEndpoint,
|
|
788
|
+
{
|
|
789
|
+
method: "POST",
|
|
790
|
+
headers,
|
|
791
|
+
body,
|
|
792
|
+
signal: AbortSignal.timeout(CONNECTION_REFRESH_TIMEOUT_MS),
|
|
793
|
+
},
|
|
794
|
+
settings,
|
|
795
|
+
{
|
|
796
|
+
fetchImpl: transportOptions.fetchImpl ?? undiciFetch,
|
|
797
|
+
...(transportOptions.dnsLookup ? { dnsLookup: transportOptions.dnsLookup } : {}),
|
|
798
|
+
label: "OAuth token endpoint",
|
|
799
|
+
requireHttpsOutsideLocalTest: true,
|
|
800
|
+
},
|
|
801
|
+
);
|
|
684
802
|
if (response.status >= 300 && response.status < 400) {
|
|
803
|
+
await cancelResponseBody(response);
|
|
685
804
|
throw new ConnectionRefreshHttpError(response.status);
|
|
686
805
|
}
|
|
687
806
|
if (!response.ok) {
|
|
807
|
+
await cancelResponseBody(response);
|
|
688
808
|
throw new ConnectionRefreshHttpError(response.status);
|
|
689
809
|
}
|
|
690
|
-
const payload =
|
|
810
|
+
const payload = await readResponseJsonBounded<Record<string, unknown>>(
|
|
811
|
+
response,
|
|
812
|
+
OAUTH_MAX_RESPONSE_BYTES,
|
|
813
|
+
"OAuth refresh token response",
|
|
814
|
+
);
|
|
691
815
|
const accessToken = stringValue(payload.access_token);
|
|
692
816
|
if (!accessToken) {
|
|
693
817
|
throw new Error("connection refresh response did not include access_token");
|
|
@@ -750,107 +874,6 @@ function stringValue(value: unknown): string | undefined {
|
|
|
750
874
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
751
875
|
}
|
|
752
876
|
|
|
753
|
-
async function
|
|
754
|
-
|
|
755
|
-
settings.integrationsAllowPrivateNetworkTargets ||
|
|
756
|
-
["local", "test"].includes(settings.environment)
|
|
757
|
-
) {
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
const url = new URL(rawUrl);
|
|
761
|
-
if (url.protocol !== "https:") {
|
|
762
|
-
throw new Error("OAuth token endpoint must use https outside local/test");
|
|
763
|
-
}
|
|
764
|
-
const hostname = url.hostname.toLowerCase();
|
|
765
|
-
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
766
|
-
throw new Error("OAuth token endpoint may not target localhost");
|
|
767
|
-
}
|
|
768
|
-
const literal = isIP(hostname);
|
|
769
|
-
const addresses = literal
|
|
770
|
-
? [hostname]
|
|
771
|
-
: (await lookup(hostname, { all: true })).map((entry) => entry.address);
|
|
772
|
-
if (addresses.some(isPrivateAddress)) {
|
|
773
|
-
throw new Error("OAuth token endpoint may not target a private network address");
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
export function isPrivateAddress(address: string): boolean {
|
|
778
|
-
const normalized = normalizeAddress(address);
|
|
779
|
-
const mapped = ipv4FromMappedIpv6(normalized);
|
|
780
|
-
if (mapped) {
|
|
781
|
-
return isPrivateIpv4Address(mapped);
|
|
782
|
-
}
|
|
783
|
-
if (normalized.includes(":")) {
|
|
784
|
-
if (isIP(normalized) !== 6) {
|
|
785
|
-
return true;
|
|
786
|
-
}
|
|
787
|
-
return (
|
|
788
|
-
normalized === "::1" ||
|
|
789
|
-
normalized === "::" ||
|
|
790
|
-
normalized.startsWith("fc") ||
|
|
791
|
-
normalized.startsWith("fd") ||
|
|
792
|
-
normalized.startsWith("fe8") ||
|
|
793
|
-
normalized.startsWith("fe9") ||
|
|
794
|
-
normalized.startsWith("fea") ||
|
|
795
|
-
normalized.startsWith("feb")
|
|
796
|
-
);
|
|
797
|
-
}
|
|
798
|
-
return isPrivateIpv4Address(normalized);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
function normalizeAddress(address: string): string {
|
|
802
|
-
const trimmed = address.trim().toLowerCase();
|
|
803
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
804
|
-
return trimmed.slice(1, -1);
|
|
805
|
-
}
|
|
806
|
-
return trimmed;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function ipv4FromMappedIpv6(address: string): string | null {
|
|
810
|
-
if (!address.startsWith("::ffff:")) {
|
|
811
|
-
return null;
|
|
812
|
-
}
|
|
813
|
-
const embedded = address.slice("::ffff:".length);
|
|
814
|
-
if (embedded.includes(".")) {
|
|
815
|
-
return embedded;
|
|
816
|
-
}
|
|
817
|
-
const parts = embedded.split(":");
|
|
818
|
-
if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
|
|
819
|
-
return null;
|
|
820
|
-
}
|
|
821
|
-
const high = Number.parseInt(parts[0]!, 16);
|
|
822
|
-
const low = Number.parseInt(parts[1]!, 16);
|
|
823
|
-
if (
|
|
824
|
-
!Number.isInteger(high) ||
|
|
825
|
-
!Number.isInteger(low) ||
|
|
826
|
-
high < 0 ||
|
|
827
|
-
high > 0xffff ||
|
|
828
|
-
low < 0 ||
|
|
829
|
-
low > 0xffff
|
|
830
|
-
) {
|
|
831
|
-
return null;
|
|
832
|
-
}
|
|
833
|
-
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function isPrivateIpv4Address(address: string): boolean {
|
|
837
|
-
if (isIP(address) !== 4) {
|
|
838
|
-
return true;
|
|
839
|
-
}
|
|
840
|
-
const parts = address.split(".").map((part) => Number(part));
|
|
841
|
-
if (
|
|
842
|
-
parts.length !== 4 ||
|
|
843
|
-
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
|
844
|
-
) {
|
|
845
|
-
return true;
|
|
846
|
-
}
|
|
847
|
-
const [a, b] = parts as [number, number, number, number];
|
|
848
|
-
return (
|
|
849
|
-
a === 0 ||
|
|
850
|
-
a === 10 ||
|
|
851
|
-
a === 127 ||
|
|
852
|
-
(a === 169 && b === 254) ||
|
|
853
|
-
(a === 172 && b >= 16 && b <= 31) ||
|
|
854
|
-
(a === 192 && b === 168)
|
|
855
|
-
);
|
|
877
|
+
async function cancelResponseBody(response: Response): Promise<void> {
|
|
878
|
+
await response.body?.cancel().catch(() => undefined);
|
|
856
879
|
}
|
|
@@ -88,12 +88,42 @@ export function sanitizeEventString(value: string): string {
|
|
|
88
88
|
* combinations are traversed; non-string leaves pass through untouched. Object
|
|
89
89
|
* keys are sanitized too -- they are jsonb-constrained the same as values.
|
|
90
90
|
*/
|
|
91
|
-
export
|
|
91
|
+
export type SanitizeEventPayloadOptions = {
|
|
92
|
+
/**
|
|
93
|
+
* Separately trusted, server-created retained-output evidence. Never populate
|
|
94
|
+
* this from a producer-controlled payload field.
|
|
95
|
+
*/
|
|
96
|
+
fullEvidence?: unknown;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function sanitizeEventPayload<T>(payload: T, options: SanitizeEventPayloadOptions = {}): T {
|
|
92
100
|
// Bound first. The preview walker caps depth/container fan-out and replaces
|
|
93
101
|
// inline media before this sanitizer allocates a deep clone. Reversing this
|
|
94
102
|
// order lets a cyclic, deeply nested, or multi-megabyte tool result exhaust
|
|
95
103
|
// the stack/heap before the durable 64 KiB event boundary can protect it.
|
|
96
|
-
|
|
104
|
+
const bounded = boundSessionEventPayload(payload, {
|
|
105
|
+
fullEvidence: options.fullEvidence,
|
|
106
|
+
});
|
|
107
|
+
return sanitizeEventPayloadDeep(
|
|
108
|
+
bounded === payload ? removeProducerTruncationMetadata(bounded) : bounded,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `truncation` is reserved durable-boundary metadata. An ordinary payload that
|
|
114
|
+
* already fits the envelope otherwise returns by reference, so remove a
|
|
115
|
+
* producer-supplied value before persistence rather than allowing it to forge
|
|
116
|
+
* byte accounting or an available retained-artifact receipt. A payload changed
|
|
117
|
+
* by `boundSessionEventPayload` already carries freshly computed metadata and
|
|
118
|
+
* never reaches this helper.
|
|
119
|
+
*/
|
|
120
|
+
function removeProducerTruncationMetadata<T>(payload: T): T {
|
|
121
|
+
if (!isPlainObject(payload)) return payload;
|
|
122
|
+
const descriptor = Object.getOwnPropertyDescriptor(payload, "truncation");
|
|
123
|
+
if (!descriptor?.enumerable) return payload;
|
|
124
|
+
const cleaned = { ...payload };
|
|
125
|
+
delete cleaned.truncation;
|
|
126
|
+
return cleaned as T;
|
|
97
127
|
}
|
|
98
128
|
|
|
99
129
|
function sanitizeEventPayloadDeep<T>(payload: T): T {
|