@genn-inc/cluebase-frontend-sdk 0.0.6 → 0.0.9

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.
Files changed (69) hide show
  1. package/dist/_generated/correlation-attribute-keys.d.ts +3 -0
  2. package/dist/_generated/correlation-attribute-keys.js +35 -0
  3. package/dist/_generated/diagnostic-codes.d.ts +4 -0
  4. package/dist/_generated/diagnostic-codes.js +48 -0
  5. package/dist/_generated/drop-reasons.d.ts +2 -0
  6. package/dist/_generated/drop-reasons.js +25 -0
  7. package/dist/_generated/ingest-routes.d.ts +5 -0
  8. package/dist/_generated/ingest-routes.js +12 -0
  9. package/dist/context/trace.js +2 -4
  10. package/dist/exporter/batch.d.ts +23 -26
  11. package/dist/exporter/batch.js +254 -289
  12. package/dist/exporter/browser-outbox-records.d.ts +7 -24
  13. package/dist/exporter/browser-outbox-records.js +6 -94
  14. package/dist/exporter/browser-outbox.d.ts +23 -28
  15. package/dist/exporter/browser-outbox.js +99 -211
  16. package/dist/exporter/client.d.ts +24 -34
  17. package/dist/exporter/client.js +107 -318
  18. package/dist/exporter/delivery-pipeline.d.ts +72 -0
  19. package/dist/exporter/delivery-pipeline.js +199 -0
  20. package/dist/exporter/delivery-response-contract.d.ts +5 -81
  21. package/dist/exporter/delivery-response-contract.js +4 -166
  22. package/dist/exporter/delivery-response.d.ts +8 -4
  23. package/dist/exporter/delivery-response.js +62 -133
  24. package/dist/exporter/diagnostics.d.ts +2 -1
  25. package/dist/exporter/diagnostics.js +1 -25
  26. package/dist/exporter/event-id.d.ts +1 -0
  27. package/dist/exporter/event-id.js +3 -0
  28. package/dist/exporter/observation-source-event-exporter.d.ts +1 -1
  29. package/dist/exporter/observation-source-event-exporter.js +2 -2
  30. package/dist/exporter/parameter-encryption.d.ts +10 -0
  31. package/dist/exporter/parameter-encryption.js +219 -0
  32. package/dist/exporter/parameter-snapshot.js +25 -3
  33. package/dist/exporter/sdk-core-delivery.d.ts +226 -0
  34. package/dist/exporter/sdk-core-delivery.js +97 -0
  35. package/dist/exporter/sdk-core-wasm.d.ts +24 -1
  36. package/dist/exporter/sdk-core-wasm.js +31 -3
  37. package/dist/exporter/source-event.d.ts +2 -0
  38. package/dist/exporter/source-event.js +17 -0
  39. package/dist/exporter/span-to-source-signal.d.ts +1 -1
  40. package/dist/exporter/span-to-source-signal.js +20 -140
  41. package/dist/index.d.ts +3 -3
  42. package/dist/index.js +5 -3
  43. package/dist/instrumentation/network.d.ts +1 -1
  44. package/dist/instrumentation/network.js +17 -0
  45. package/dist/provider/_version.d.ts +1 -1
  46. package/dist/provider/_version.js +1 -1
  47. package/dist/provider/config.js +16 -3
  48. package/dist/provider/constants.js +0 -1
  49. package/dist/provider/contracts.d.ts +2 -0
  50. package/dist/provider/contracts.js +2 -0
  51. package/dist/provider/lifecycle-event-dedup.d.ts +7 -0
  52. package/dist/provider/lifecycle-event-dedup.js +31 -0
  53. package/dist/provider/sdk-encryption-store.d.ts +7 -0
  54. package/dist/provider/sdk-encryption-store.js +15 -0
  55. package/dist/provider/sdk-event-pipeline.d.ts +1 -3
  56. package/dist/provider/sdk-event-pipeline.js +0 -15
  57. package/dist/provider/sdk-otel-pipeline.d.ts +2 -1
  58. package/dist/provider/sdk-otel-pipeline.js +2 -11
  59. package/dist/provider/sdk-runtime-surfaces.d.ts +3 -2
  60. package/dist/provider/sdk-runtime-surfaces.js +7 -6
  61. package/dist/provider/sdk.js +88 -54
  62. package/dist/provider/types.d.ts +14 -19
  63. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm.d.ts +18 -9
  64. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm.js +170 -29
  65. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm_bg.wasm +0 -0
  66. package/dist/wasm/pkg-web/cluebase_sdk_core_wasm_bg.wasm.d.ts +10 -9
  67. package/package.json +1 -1
  68. package/dist/provider/usage-governor.d.ts +0 -7
  69. package/dist/provider/usage-governor.js +0 -114
@@ -1,143 +1,72 @@
1
- import { parseGovernorFeedbackEnvelope } from "../provider/usage-governor.js";
2
- import { observationSourceEventBatchResponseSchema, } from "./delivery-response-contract.js";
3
- export const BROWSER_REJECTION_STRATEGIES = {
4
- invalid_request: "stop_and_report",
5
- unsupported_source_schema_version: "stop_and_report",
6
- missing_project_key: "stop_and_report",
7
- project_key_mismatch: "stop_and_report",
8
- unresolved_project_or_environment: "stop_and_report",
9
- missing_browser_token: "stop_and_report",
10
- invalid_browser_token: "stop_and_report",
11
- expired_browser_token: "retry_same_batch",
12
- stale_browser_token: "retry_same_batch",
13
- origin_not_allowed: "stop_and_report",
14
- authorization_denied: "stop_and_report",
15
- idempotency_conflict: "mark_failed",
16
- batch_too_large: "split_batch",
17
- all_events_dropped: "drop_event",
18
- usage_governor_denied: "retry_same_batch",
19
- all_events_rejected_by_ingest_controls: "retry_same_batch",
20
- route_throttled: "retry_same_batch",
21
- raw_ingest_produce_failed: "retry_same_batch",
22
- raw_ingest_ack_unknown: "retry_same_batch",
23
- raw_ingest_receipt_metadata_failed: "retry_same_batch",
24
- raw_ingest_receipt_unprocessable: "stop_and_report",
25
- ingest_config_changed_retryable: "retry_same_batch",
26
- usage_meter_unavailable: "retry_same_batch",
1
+ import { evaluateResponse, } from "./sdk-core-delivery.js";
2
+ const RETRY_STRATEGY_BY_OUTCOME = {
3
+ accepted_complete: null,
4
+ accepted_filtered: null,
5
+ accepted_partial: null,
6
+ retry_same_batch: "retry_same_batch",
7
+ split_batch: "split_batch",
8
+ terminal_stop: "stop_and_report",
9
+ terminal_failed: "mark_failed",
10
+ terminal_drop: "drop_event",
11
+ invalid_response: "retry_same_batch",
27
12
  };
28
- export const evaluateBrowserDeliveryResponse = (input) => {
29
- const decoded = decode(input.bodyText);
30
- if (decoded !== null && hasAcceptedDiscriminator(decoded)) {
31
- const parsed = observationSourceEventBatchResponseSchema.safeParse(decoded);
32
- if (parsed.success &&
33
- submittedCountMatches(parsed.data, input.localSubmittedCount) &&
34
- hasPublicDropDetails(parsed.data)) {
35
- if (parsed.data.accepted) {
36
- if (input.status < 200 || input.status >= 300) {
37
- return invalidResponse();
38
- }
39
- const partial = parsed.data.dropped_event_count > 0;
40
- const diagnosticCodes = [];
41
- if (partial) {
42
- diagnosticCodes.push(parsed.data.dropped_events.length > 0
43
- ? "intentional_drop"
44
- : "duplicate_drop_details_unavailable");
45
- }
46
- if (input.status !== 202) {
47
- diagnosticCodes.push("non_canonical_success_status");
48
- }
49
- return {
50
- ok: true,
51
- retryStrategy: null,
52
- privacyPolicy: parsed.data.privacy_policy,
53
- governor: parseGovernorFeedbackEnvelope(parsed.data),
54
- observationIngestBatchId: parsed.data.observation_ingest_batch_id,
55
- droppedEventCount: parsed.data.dropped_event_count,
56
- acceptedEventCount: parsed.data.accepted_event_count,
57
- acceptedPartial: partial,
58
- reason: partial ? "accepted_partial" : null,
59
- diagnosticCodes,
60
- };
61
- }
62
- if (BROWSER_REJECTION_STRATEGIES[parsed.data.reason] !==
63
- parsed.data.retry_strategy) {
64
- return invalidResponse();
65
- }
66
- return {
67
- ok: false,
68
- retryStrategy: parsed.data.retry_strategy,
69
- governor: null,
70
- droppedEventCount: parsed.data.dropped_event_count ?? 0,
71
- acceptedEventCount: parsed.data.accepted_event_count ?? 0,
72
- acceptedPartial: false,
73
- reason: parsed.data.reason,
74
- diagnosticCodes: [],
75
- };
76
- }
77
- return invalidResponse();
13
+ const ACCEPTED_OUTCOMES = new Set([
14
+ "accepted_complete",
15
+ "accepted_filtered",
16
+ "accepted_partial",
17
+ ]);
18
+ export const evaluateBrowserDeliveryResponse = (input) => translate({ kind: "response", status: input.status, body: input.bodyText }, input.localSubmittedCount);
19
+ export const evaluateBrowserTransportOutcome = (outcome) => translate(outcome, 1);
20
+ export const classifyIngestHttpStatus = (status) => translate({ kind: "response", status, body: "" }, 1).retryStrategy ??
21
+ "retry_same_batch";
22
+ const translate = (outcome, localSubmittedCount) => {
23
+ const evaluation = evaluateResponse(outcome, localSubmittedCount);
24
+ if (evaluation.kind !== "decision") {
25
+ return consentRevoked();
78
26
  }
79
- return fallback(input.status);
27
+ return project(evaluation.decision);
80
28
  };
81
- const submittedCountMatches = (value, localSubmittedCount) => value.submitted_event_count === undefined ||
82
- value.submitted_event_count === localSubmittedCount;
83
- const hasPublicDropDetails = (value) => (value.dropped_events ?? []).every((detail) => detail.source_event_payload_fingerprint === null);
84
- const decode = (bodyText) => {
85
- if (!bodyText.trim())
86
- return null;
87
- try {
88
- return JSON.parse(bodyText);
89
- }
90
- catch {
91
- return null;
92
- }
29
+ const project = (decision) => {
30
+ const accepted = ACCEPTED_OUTCOMES.has(decision.outcome);
31
+ const batchId = decision.result.observation_ingest_batch_id;
32
+ return {
33
+ ok: accepted,
34
+ retryStrategy: accepted
35
+ ? null
36
+ : (RETRY_STRATEGY_BY_OUTCOME[decision.outcome] ?? "retry_same_batch"),
37
+ ...(decision.privacy_policy === undefined
38
+ ? {}
39
+ : { privacyPolicy: decision.privacy_policy }),
40
+ ...(decision.sdk_encryption === undefined
41
+ ? {}
42
+ : { sdkEncryption: decision.sdk_encryption }),
43
+ directiveUnreadable: decision.directive_unreadable === true,
44
+ ...(batchId === undefined ? {} : { observationIngestBatchId: batchId }),
45
+ droppedEventCount: decision.result.dropped_event_count ?? 0,
46
+ acceptedEventCount: decision.result.accepted_event_count ?? 0,
47
+ acceptedPartial: decision.outcome === "accepted_partial",
48
+ acceptedFiltered: decision.outcome === "accepted_filtered",
49
+ reason: reasonOf(decision),
50
+ diagnosticCodes: decision.diagnostic_codes,
51
+ };
93
52
  };
94
- const hasAcceptedDiscriminator = (value) => value !== null &&
95
- typeof value === "object" &&
96
- !Array.isArray(value) &&
97
- Object.prototype.hasOwnProperty.call(value, "accepted");
98
- const invalidResponse = () => ({
99
- ok: false,
100
- retryStrategy: "retry_same_batch",
101
- governor: null,
102
- droppedEventCount: 0,
103
- acceptedEventCount: 0,
104
- acceptedPartial: false,
105
- reason: "invalid_response",
106
- diagnosticCodes: ["invalid_response"],
107
- });
108
- export const classifyIngestHttpStatus = (status) => {
109
- if (status >= 200 && status < 300)
110
- return "retry_same_batch";
111
- if (status === 409)
112
- return "mark_failed";
113
- if (status === 413)
114
- return "split_batch";
115
- if ([400, 401, 403, 404, 405, 406, 410, 415].includes(status)) {
116
- return "stop_and_report";
117
- }
118
- return "retry_same_batch";
53
+ const reasonOf = (decision) => {
54
+ if (decision.outcome === "accepted_complete")
55
+ return null;
56
+ if (decision.outcome === "accepted_partial")
57
+ return "accepted_partial";
58
+ if (decision.outcome === "accepted_filtered")
59
+ return "accepted_filtered";
60
+ return decision.result.reason ?? decision.outcome;
119
61
  };
120
- const RETRYABLE_CLIENT_STATUSES = [408, 422, 425, 429];
121
- const fallback = (status) => ({
62
+ const consentRevoked = () => ({
122
63
  ok: false,
123
- retryStrategy: classifyIngestHttpStatus(status),
124
- governor: null,
64
+ retryStrategy: "drop_event",
65
+ directiveUnreadable: false,
125
66
  droppedEventCount: 0,
126
67
  acceptedEventCount: 0,
127
68
  acceptedPartial: false,
128
- reason: `http_${status}`,
129
- diagnosticCodes: fallbackDiagnosticCodes(status),
69
+ acceptedFiltered: false,
70
+ reason: "consent_denied",
71
+ diagnosticCodes: [],
130
72
  });
131
- const fallbackDiagnosticCodes = (status) => {
132
- if (classifyIngestHttpStatus(status) !== "retry_same_batch")
133
- return [];
134
- if (status >= 200 && status <= 299)
135
- return ["invalid_response"];
136
- if (status >= 300 && status <= 399)
137
- return ["protocol_redirect"];
138
- if (status >= 500 && status <= 599)
139
- return [];
140
- if (RETRYABLE_CLIENT_STATUSES.includes(status))
141
- return ["invalid_response"];
142
- return ["unknown_status"];
143
- };
@@ -1,5 +1,6 @@
1
+ import { type DiagnosticCode } from "../_generated/diagnostic-codes.js";
1
2
  import type { IngestRetryStrategy } from "./delivery-response-contract.js";
2
- export type DiagnosticCode = "batch_split" | "consent_persistence_failed" | "consent_purge" | "consent_purge_failed" | "consent_state_unavailable" | "durable_outbox_unavailable" | "duplicate_drop_details_unavailable" | "event_oversize" | "intentional_drop" | "invalid_response" | "legacy_pre_revocation_event_blocked" | "legacy_bridge_capacity_reached" | "legacy_writer_detected" | "non_canonical_success_status" | "outbox_key_collision" | "outbox_record_corrupt" | "protocol_redirect" | "queue_overflow" | "retry_exhausted" | "retry_scheduled" | "shutdown_failure" | "terminal_rejection" | "unknown_status";
3
+ export type { DiagnosticCode };
3
4
  export type DiagnosticLifecycle = "open" | "closing" | "closing_failed" | "closed";
4
5
  export type DiagnosticFlushOutcome = "clean" | "failed" | null;
5
6
  export type DiagnosticCloseOutcome = "clean" | "retryable_failure" | "terminal_failure" | null;
@@ -1,31 +1,7 @@
1
1
  import { hmac } from "@noble/hashes/hmac";
2
2
  import { sha256 } from "@noble/hashes/sha256";
3
3
  import { bytesToHex, randomBytes, utf8ToBytes } from "@noble/hashes/utils";
4
- const CODES = [
5
- "batch_split",
6
- "consent_persistence_failed",
7
- "consent_purge",
8
- "consent_purge_failed",
9
- "consent_state_unavailable",
10
- "durable_outbox_unavailable",
11
- "duplicate_drop_details_unavailable",
12
- "event_oversize",
13
- "intentional_drop",
14
- "invalid_response",
15
- "legacy_pre_revocation_event_blocked",
16
- "legacy_bridge_capacity_reached",
17
- "legacy_writer_detected",
18
- "non_canonical_success_status",
19
- "outbox_key_collision",
20
- "outbox_record_corrupt",
21
- "protocol_redirect",
22
- "queue_overflow",
23
- "retry_exhausted",
24
- "retry_scheduled",
25
- "shutdown_failure",
26
- "terminal_rejection",
27
- "unknown_status",
28
- ];
4
+ import { DIAGNOSTIC_CODES as CODES, } from "../_generated/diagnostic-codes.js";
29
5
  const CODE_SET = new Set(CODES);
30
6
  const STRATEGIES = new Set([
31
7
  "split_batch",
@@ -1,6 +1,7 @@
1
1
  export declare const generateMonotonicId: (prefix?: string) => string;
2
2
  export declare const generateTraceId: () => string;
3
3
  export declare const generateEventId: () => string;
4
+ export declare const generateBatchId: () => string;
4
5
  export declare const generateAnonymousId: () => string;
5
6
  export declare const generateSessionId: () => string;
6
7
  export declare const generateTabId: () => string;
@@ -49,6 +49,9 @@ export const generateTraceId = () => {
49
49
  export const generateEventId = () => {
50
50
  return generateMonotonicId();
51
51
  };
52
+ export const generateBatchId = () => {
53
+ return toHex(randomBytes(16));
54
+ };
52
55
  export const generateAnonymousId = () => {
53
56
  return generateMonotonicId("anon");
54
57
  };
@@ -1,7 +1,7 @@
1
1
  import { type ExportResult, ExportResultCode } from "@opentelemetry/core";
2
2
  import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
3
3
  import type { CluebaseContext } from "../provider/types.js";
4
- import type { CollectionPermit } from "./browser-outbox.js";
4
+ import type { CollectionPermit } from "./sdk-core-delivery.js";
5
5
  import type { BrowserSourceEvent, BrowserSourceSignalEvent } from "./source-event.js";
6
6
  export declare const EXPORT_RESULT_SUCCESS: ExportResultCode;
7
7
  export declare const EXPORT_RESULT_FAILED: ExportResultCode;
@@ -15,7 +15,7 @@ export class ObservationSourceEventExporter {
15
15
  this.shutdownImpl = options.shutdownImpl;
16
16
  this.getContext = options.getContext;
17
17
  this.deniedKeys = options.deniedKeys ?? [];
18
- this.ignoreUrlPrefixes = (options.ignoreUrlPrefixes ?? []).filter((p) => typeof p === "string" && p.length > 0);
18
+ this.ignoreUrlPrefixes = options.ignoreUrlPrefixes ?? [];
19
19
  }
20
20
  ignoreSpanReason(span) {
21
21
  const spanFields = span;
@@ -30,7 +30,7 @@ export class ObservationSourceEventExporter {
30
30
  ? fullUrl
31
31
  : undefined;
32
32
  if (url &&
33
- this.ignoreUrlPrefixes.some((prefix) => url.startsWith(prefix))) {
33
+ this.ignoreUrlPrefixes.some((prefix) => prefix.length > 0 && url.startsWith(prefix))) {
34
34
  return "loop";
35
35
  }
36
36
  if (!Object.keys(attrs).some((key) => key.startsWith("cluebase."))) {
@@ -0,0 +1,10 @@
1
+ import { type ParameterValueFact } from "./sdk-core-wasm.js";
2
+ export declare const parameterValueFact: (value: unknown) => ParameterValueFact;
3
+ export declare const beginParameterCaptureDecision: (key: string, value: unknown) => string;
4
+ export declare const canEncryptParameters: () => boolean;
5
+ export declare const beginParameterEncryption: (plaintext: string) => {
6
+ handle: string;
7
+ keyVersion: number;
8
+ } | null;
9
+ export declare const settleParameterCaptureDecisions: (node: unknown) => void;
10
+ export declare const settlePendingEncryptions: <T>(value: T) => Promise<T>;
@@ -0,0 +1,219 @@
1
+ import { currentSdkEncryption, } from "../provider/sdk-encryption-store.js";
2
+ import { decideParameterCaptures, } from "./sdk-core-wasm.js";
3
+ const PENDING_PREFIX = "cbenc~";
4
+ const PENDING_DECISION_PREFIX = "cbdec~";
5
+ const MAX_PENDING_ENCRYPTIONS = 1000;
6
+ const pending = new Map();
7
+ const pendingDecisions = new Map();
8
+ const importedKeys = new Map();
9
+ const toBase64 = (bytes) => {
10
+ let binary = "";
11
+ for (let index = 0; index < bytes.length; index += 1) {
12
+ binary += String.fromCharCode(bytes[index] ?? 0);
13
+ }
14
+ return btoa(binary);
15
+ };
16
+ const fromBase64 = (text) => {
17
+ const binary = atob(text);
18
+ const bytes = new Uint8Array(new ArrayBuffer(binary.length));
19
+ for (let index = 0; index < binary.length; index += 1) {
20
+ bytes[index] = binary.charCodeAt(index);
21
+ }
22
+ return bytes;
23
+ };
24
+ const subtle = () => typeof crypto !== "undefined" && crypto.subtle ? crypto.subtle : null;
25
+ const importPublicKey = (config) => {
26
+ const cacheKey = `${config.keyVersion}:${config.publicKey}`;
27
+ const cached = importedKeys.get(cacheKey);
28
+ if (cached)
29
+ return cached;
30
+ const api = subtle();
31
+ const imported = api
32
+ ? api
33
+ .importKey("spki", fromBase64(config.publicKey), { name: "RSA-OAEP", hash: "SHA-256" }, false, ["encrypt"])
34
+ .catch(() => null)
35
+ : Promise.resolve(null);
36
+ importedKeys.set(cacheKey, imported);
37
+ return imported;
38
+ };
39
+ const encryptNow = async (config, plaintext) => {
40
+ const api = subtle();
41
+ const bytes = new TextEncoder().encode(plaintext);
42
+ try {
43
+ const key = await importPublicKey(config);
44
+ if (!api || !key)
45
+ return null;
46
+ const encrypted = await api.encrypt({ name: "RSA-OAEP" }, key, bytes);
47
+ return {
48
+ keyVersion: config.keyVersion,
49
+ ciphertext: toBase64(new Uint8Array(encrypted)),
50
+ };
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ finally {
56
+ bytes.fill(0);
57
+ }
58
+ };
59
+ const newHandle = (prefix) => {
60
+ const random = new Uint8Array(12);
61
+ crypto.getRandomValues(random);
62
+ return `${prefix}${toBase64(random).replace(/[^A-Za-z0-9]/g, "")}`;
63
+ };
64
+ export const parameterValueFact = (value) => {
65
+ if (typeof value === "boolean")
66
+ return { type: "boolean" };
67
+ if (typeof value === "number")
68
+ return { type: "number", number: value };
69
+ if (typeof value === "string")
70
+ return { type: "text", text: value };
71
+ return { type: "other" };
72
+ };
73
+ export const beginParameterCaptureDecision = (key, value) => {
74
+ const handle = newHandle(PENDING_DECISION_PREFIX);
75
+ if (pendingDecisions.size >= MAX_PENDING_ENCRYPTIONS) {
76
+ const oldest = pendingDecisions.keys().next().value;
77
+ if (oldest !== undefined)
78
+ pendingDecisions.delete(oldest);
79
+ }
80
+ pendingDecisions.set(handle, {
81
+ key,
82
+ value,
83
+ fact: parameterValueFact(value),
84
+ });
85
+ return handle;
86
+ };
87
+ export const canEncryptParameters = () => currentSdkEncryption() !== null && subtle() !== null;
88
+ export const beginParameterEncryption = (plaintext) => {
89
+ const config = currentSdkEncryption();
90
+ if (!config || !subtle())
91
+ return null;
92
+ const handle = newHandle(PENDING_PREFIX);
93
+ if (pending.size >= MAX_PENDING_ENCRYPTIONS) {
94
+ const oldest = pending.keys().next().value;
95
+ if (oldest !== undefined)
96
+ pending.delete(oldest);
97
+ }
98
+ pending.set(handle, encryptNow(config, plaintext));
99
+ return { handle, keyVersion: config.keyVersion };
100
+ };
101
+ const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
102
+ const MASKED_FALLBACK = "__cluebase_masked__";
103
+ const settleLeaf = async (leaf) => {
104
+ const handle = leaf.ciphertext;
105
+ if (typeof handle !== "string" || !handle.startsWith(PENDING_PREFIX))
106
+ return;
107
+ const result = await pending.get(handle);
108
+ pending.delete(handle);
109
+ if (result) {
110
+ leaf.key_version = result.keyVersion;
111
+ leaf.ciphertext = result.ciphertext;
112
+ return;
113
+ }
114
+ leaf.capture_mode = "masked_fingerprint";
115
+ leaf.value = MASKED_FALLBACK;
116
+ delete leaf.key_version;
117
+ delete leaf.ciphertext;
118
+ };
119
+ const collectPendingDecisionLeaves = (node, found) => {
120
+ if (Array.isArray(node)) {
121
+ for (const child of node)
122
+ collectPendingDecisionLeaves(child, found);
123
+ return;
124
+ }
125
+ if (!isRecord(node))
126
+ return;
127
+ const handle = node.ciphertext;
128
+ if (typeof handle === "string" &&
129
+ handle.startsWith(PENDING_DECISION_PREFIX)) {
130
+ found.push({ leaf: node, handle });
131
+ return;
132
+ }
133
+ for (const child of Object.values(node)) {
134
+ collectPendingDecisionLeaves(child, found);
135
+ }
136
+ };
137
+ const applyCaptureDecision = (leaf, value, decision) => {
138
+ if (decision === "plain") {
139
+ leaf.value = value;
140
+ leaf.capture_mode = "plain";
141
+ return;
142
+ }
143
+ if (decision !== "encrypt" || typeof value !== "string")
144
+ return;
145
+ const encryption = beginParameterEncryption(value);
146
+ if (!encryption)
147
+ return;
148
+ leaf.capture_mode = "encrypted";
149
+ leaf.key_version = encryption.keyVersion;
150
+ leaf.ciphertext = encryption.handle;
151
+ };
152
+ export const settleParameterCaptureDecisions = (node) => {
153
+ const leaves = [];
154
+ collectPendingDecisionLeaves(node, leaves);
155
+ if (leaves.length === 0)
156
+ return;
157
+ const requested = leaves.map(({ handle }) => pendingDecisions.get(handle));
158
+ const decisions = decideParameterCaptures(requested
159
+ .filter((entry) => entry !== undefined)
160
+ .map(({ key, fact }) => ({ key, value: fact })));
161
+ let answered = 0;
162
+ for (const [index, { leaf, handle }] of leaves.entries()) {
163
+ delete leaf.ciphertext;
164
+ pendingDecisions.delete(handle);
165
+ const entry = requested[index];
166
+ if (entry === undefined)
167
+ continue;
168
+ applyCaptureDecision(leaf, entry.value, decisions[answered]);
169
+ answered += 1;
170
+ }
171
+ };
172
+ const settleTree = async (node) => {
173
+ if (Array.isArray(node)) {
174
+ for (const child of node)
175
+ await settleTree(child);
176
+ return;
177
+ }
178
+ if (!isRecord(node))
179
+ return;
180
+ if (node.capture_mode === "encrypted") {
181
+ await settleLeaf(node);
182
+ return;
183
+ }
184
+ for (const child of Object.values(node))
185
+ await settleTree(child);
186
+ };
187
+ const settleText = async (text) => {
188
+ if (!text.includes(PENDING_PREFIX) &&
189
+ !text.includes(PENDING_DECISION_PREFIX)) {
190
+ return text;
191
+ }
192
+ try {
193
+ const parsed = JSON.parse(text);
194
+ settleParameterCaptureDecisions(parsed);
195
+ await settleTree(parsed);
196
+ return JSON.stringify(parsed);
197
+ }
198
+ catch {
199
+ return text;
200
+ }
201
+ };
202
+ export const settlePendingEncryptions = async (value) => {
203
+ if (typeof value === "string")
204
+ return (await settleText(value));
205
+ if (Array.isArray(value)) {
206
+ const settled = [];
207
+ for (const child of value)
208
+ settled.push(await settlePendingEncryptions(child));
209
+ return settled;
210
+ }
211
+ if (isRecord(value)) {
212
+ const settled = {};
213
+ for (const [key, child] of Object.entries(value)) {
214
+ settled[key] = await settlePendingEncryptions(child);
215
+ }
216
+ return settled;
217
+ }
218
+ return value;
219
+ };
@@ -1,7 +1,9 @@
1
1
  import { hasParameterSnapshotLeafShape } from "../_generated/parameter-snapshot-leaf.js";
2
2
  import { FORBIDDEN_PARAMETER_VALUE, MASKED_VALUE, MAX_SANITIZE_DEPTH, TRUNCATED_PARAMETER_VALUE, } from "../provider/constants.js";
3
3
  import { PARAMETER_CAPTURE_MODE } from "../provider/contracts.js";
4
+ import { beginParameterCaptureDecision, canEncryptParameters, settleParameterCaptureDecisions, } from "./parameter-encryption.js";
4
5
  import { isDeniedKey } from "./sanitize.js";
6
+ import { isSdkCoreWasmReady } from "./sdk-core-wasm.js";
5
7
  const SNAPSHOT_UNAVAILABLE_KEY = "_cluebase_snapshot_unavailable";
6
8
  const normalizeParameterLookupPath = (path) => {
7
9
  return path.replace(/\[\d+\]/g, "[]");
@@ -60,6 +62,19 @@ const buildLeaf = (value, captureMode, released = false) => {
60
62
  : {}),
61
63
  };
62
64
  };
65
+ const leafKeyOf = (path) => {
66
+ const trimmed = path.replace(/(?:\[\])+$/, "");
67
+ return trimmed.slice(trimmed.lastIndexOf(".") + 1);
68
+ };
69
+ const buildClassifiedLeaf = (path, value) => {
70
+ const masked = buildLeaf(value, PARAMETER_CAPTURE_MODE.maskedFingerprint);
71
+ if (!path)
72
+ return masked;
73
+ return {
74
+ ...masked,
75
+ ciphertext: beginParameterCaptureDecision(leafKeyOf(path), value),
76
+ };
77
+ };
63
78
  export const buildUnavailableParameterSnapshot = () => ({
64
79
  [SNAPSHOT_UNAVAILABLE_KEY]: buildLeaf("snapshot_unavailable", PARAMETER_CAPTURE_MODE.maskedFingerprint),
65
80
  });
@@ -173,10 +188,15 @@ export const buildParameterSnapshotBundle = (value, options) => {
173
188
  const matchedAllowedPath = path
174
189
  ? (allowedPathLookup.get(normalizeParameterLookupPath(path)) ?? null)
175
190
  : null;
176
- const captureMode = PARAMETER_CAPTURE_MODE.maskedFingerprint;
191
+ if (matchedAllowedPath !== null && !canEncryptParameters()) {
192
+ return {
193
+ snapshot: buildLeaf(current, PARAMETER_CAPTURE_MODE.maskedFingerprint, true),
194
+ allowedValueKeys: [matchedAllowedPath],
195
+ };
196
+ }
177
197
  return {
178
- snapshot: buildLeaf(current, captureMode, matchedAllowedPath !== null),
179
- allowedValueKeys: matchedAllowedPath ? [matchedAllowedPath] : [],
198
+ snapshot: buildClassifiedLeaf(path, current),
199
+ allowedValueKeys: [],
180
200
  };
181
201
  };
182
202
  const rootBundle = walk(value, rootPathPrefix, 0);
@@ -185,6 +205,8 @@ export const buildParameterSnapshotBundle = (value, options) => {
185
205
  !Array.isArray(rootBundle.snapshot)
186
206
  ? rootBundle.snapshot
187
207
  : {};
208
+ if (isSdkCoreWasmReady())
209
+ settleParameterCaptureDecisions(snapshot);
188
210
  return {
189
211
  snapshot,
190
212
  allowedValueKeys: [...new Set(rootBundle.allowedValueKeys)].sort(),