@capxul/observability 4.1.3 → 4.2.0-rc.1

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.md CHANGED
@@ -7,6 +7,10 @@ This public package defines event names and journeys, validates
7
7
  event properties, marks and redacts PII, and provides recording, in-memory,
8
8
  and PostHog-compatible adapters.
9
9
 
10
+ Activity action events accept the implemented `unresolved` Payment status in
11
+ committed filters and export filters. The event records the selected status;
12
+ it does not infer settlement from an unresolved result.
13
+
10
14
  ## The required envelope (ADR-0020 A1 amendment)
11
15
 
12
16
  One PostHog project holds every environment, so `capxul_env` — not project
@@ -22,23 +26,28 @@ optional and snake_case; the camelCase spellings are retired.
22
26
  One boundary, two named postures, both built on the same sanitizer, token
23
27
  guard and missing-key opt-out:
24
28
 
25
- | | `utilityAppAnalytics` | `productAppAnalytics` |
26
- | --------------- | ------------------------------------------ | --------------------------------------- |
27
- | For | capability-URL apps (approval links, docs) | the Capxul product web app |
28
- | Persistence | off | **on** — J1 spans an OTP reload |
29
- | Person profiles | identified-only, nothing identifies | identified-only, identify-driven |
30
- | Autocapture | off | off |
31
- | Session replay | off | **on in every environment**, OTP masked |
32
- | Exceptions | off | on |
33
-
34
- Both stamp `capxul_env` and `producer: "browser"` through `before_send`, which
35
- also recursively removes URL query strings/fragments (including exception-frame
36
- filenames) and promoted campaign/search values. Pair either with
37
- `createPostHogBrowserPageviewTracker`, which reports pathname-only initial
38
- loads and SPA route changes. Vendor initialization and pageview capture
39
- failures degrade to disabled analytics. The product profile records ordinary
40
- text and input values. It masks inputs whose `autocomplete` attribute is
41
- `one-time-code`.
29
+ | | `utilityAppAnalytics` | `productAppAnalytics` |
30
+ | --------------- | ------------------------------------------ | ----------------------------------------------- |
31
+ | For | capability-URL apps (approval links, docs) | the Capxul product web app |
32
+ | Persistence | off | **on** — J1 spans an OTP reload |
33
+ | Person profiles | identified-only, nothing identifies | identified-only, identify-driven |
34
+ | Autocapture | off | off |
35
+ | Session replay | off | **on in every environment**, credentials masked |
36
+ | Exceptions | off | on |
37
+
38
+ Both stamp `capxul_env` and `producer: "browser"` through `before_send`.
39
+ Utility analytics removes URL queries, fragments, and promoted campaign/search
40
+ values. Product analytics preserves safe URL components and masks credentials.
41
+ Both profiles preserve the PostHog routing token required for transport.
42
+ `createPostHogBrowserPageviewTracker` reports pathname-only initial loads and
43
+ SPA route changes. Vendor initialization and pageview capture failures degrade
44
+ to disabled analytics.
45
+
46
+ [The redaction rule](../../docs/rules/redaction.md) defines the data classes.
47
+ Product replay masks password, OTP, and credential controls. Ordinary input
48
+ remains visible. Network copies mask credential fields, headers, and URL parts
49
+ while retaining safe codes, public chain evidence, and replay identifiers.
50
+ Existing event schemas and the four auth-event email hashes remain unchanged.
42
51
 
43
52
  ### Import the standard `posthog-js` build
44
53
 
@@ -0,0 +1,11 @@
1
+ {
2
+ "source": {
3
+ "commit": "efa1ae30674425e4db92020bfad513fb6cf36011",
4
+ "tree": "91f805524a7c7b53e6c28cf484b549b0c1dbd9dd",
5
+ "branch": "codex/programme-rc",
6
+ "repository": "https://github.com/Xelmar-tech/infrastructure",
7
+ "lockfileSha256": "5870f53b72706d103220c1bcf733cb4fb71196aea32a9cfbdc277d73cc959264"
8
+ },
9
+ "name": "@capxul/observability",
10
+ "version": "4.2.0-rc.1"
11
+ }
@@ -12,7 +12,7 @@ interface EngineeringTelemetryConfig {
12
12
  readonly sdkVersion: string;
13
13
  readonly serviceName?: string;
14
14
  }
15
- declare const traceHeaderFilter: (name: string) => boolean;
15
+ declare const traceHeaderFilter: (_name: string) => boolean;
16
16
  declare const postHogOtlpEndpoints: (host: string) => {
17
17
  readonly logs: `${string}/i/v1/logs`;
18
18
  readonly traces: `${string}/i/v1/traces`;
@@ -1,4 +1,5 @@
1
- import { Cause, Effect, Exit, Layer, Tracer } from "effect";
1
+ import { i as redactUrlSecrets, n as isCredentialField, r as redactSecrets } from "./src-CWfRYsBI.mjs";
2
+ import { Cause, Effect, Exit, FiberSet, Layer, Stream, Tracer } from "effect";
2
3
  import { FetchHttpClient, Headers, HttpClient } from "effect/unstable/http";
3
4
  import { OtlpExporter, OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
4
5
  //#region src/engineering.ts
@@ -8,21 +9,8 @@ const ENGINEERING_CAPXUL_ENVS = [
8
9
  "production",
9
10
  "local"
10
11
  ];
11
- const SAFE_TRACED_HEADER_NAMES = [
12
- "content-length",
13
- "content-type",
14
- "traceparent",
15
- "tracestate",
16
- "x-request-id"
17
- ];
18
- const ENGINEERING_REDACTED_HEADER_NAMES = Object.freeze([
19
- "authorization",
20
- "cookie",
21
- "set-cookie",
22
- "x-api-key",
23
- /auth|email|key|otp|secret|session|token|wallet/i
24
- ]);
25
- const traceHeaderFilter = (name) => SAFE_TRACED_HEADER_NAMES.includes(name.toLowerCase());
12
+ const traceHeaderFilter = (_name) => true;
13
+ const credentialHeaderPattern = Object.assign(/./u, { test: (name) => isCredentialField(name, "header") });
26
14
  const postHogOtlpEndpoints = (host) => {
27
15
  const base = host.replace(/\/+$/, "");
28
16
  return {
@@ -54,23 +42,79 @@ const validateEngineeringTelemetryConfig = (config) => {
54
42
  headers: Object.freeze({ authorization: headerEntries[0][1] })
55
43
  };
56
44
  };
57
- var RedactedEngineeringSpanFailure = class extends Error {
58
- constructor() {
59
- super("Engineering operation failed");
60
- this.name = "RedactedEngineeringSpanFailure";
61
- delete this.stack;
45
+ const failureText = (failure, key, limit, source) => {
46
+ if (failure === null || typeof failure !== "object") return void 0;
47
+ try {
48
+ let owner = failure;
49
+ let descriptor;
50
+ for (let depth = 0; owner !== null && depth < 4; depth += 1) {
51
+ descriptor = Object.getOwnPropertyDescriptor(owner, key);
52
+ if (descriptor !== void 0 || key !== "name") break;
53
+ owner = Object.getPrototypeOf(owner);
54
+ }
55
+ const value = descriptor?.value;
56
+ return typeof value === "string" || key === "code" && typeof value === "number" ? redactSecrets(String(value), source).slice(0, limit) : void 0;
57
+ } catch {
58
+ return;
59
+ }
60
+ };
61
+ const exportFailure = (failure, source) => {
62
+ const message = failureText(failure, "message", 2048, source) ?? (typeof failure === "string" ? redactSecrets(failure, source).slice(0, 2048) : "Engineering operation failed");
63
+ const code = failureText(failure, "code", 128);
64
+ const error = new Error((code === void 0 ? message : `${message} [code=${code}]`).slice(0, 2048));
65
+ error.name = failureText(failure, "_tag", 128) ?? failureText(failure, "name", 128) ?? "Error";
66
+ error.stack = failureText(failure, "stack", 8192, source) ?? `${error.name}: ${error.message}`;
67
+ return error;
68
+ };
69
+ const exportFailureExit = (cause, source) => Exit.failCause(Cause.fromReasons(cause.reasons.filter((reason) => !Cause.isInterruptReason(reason)).slice(0, 8).map((reason) => Cause.isFailReason(reason) ? Cause.makeFailReason(exportFailure(reason.error, source)) : Cause.makeDieReason(exportFailure(reason.defect, source)))));
70
+ const BROWSER_KEEPALIVE_BYTES = 65536;
71
+ let unfinishedBrowserExportBytes = 0;
72
+ const hasBrowserDocument = () => typeof window !== "undefined" && typeof document !== "undefined";
73
+ const browserExportClient = (client) => HttpClient.transform(client, (response, request) => Effect.acquireUseRelease(Effect.sync(() => {
74
+ const bytes = "contentLength" in request.body ? request.body.contentLength : void 0;
75
+ if (request.body._tag === "Stream" || request.body._tag === "FormData" || bytes === void 0 || !Number.isSafeInteger(bytes) || bytes < 0 || unfinishedBrowserExportBytes + bytes > BROWSER_KEEPALIVE_BYTES) return;
76
+ unfinishedBrowserExportBytes += bytes;
77
+ return bytes;
78
+ }), (bytes) => response.pipe(Effect.provideService(FetchHttpClient.RequestInit, { keepalive: bytes !== void 0 }), Effect.tap((result) => bytes === void 0 ? Effect.void : Stream.runDrain(result.stream).pipe(Effect.catch((error) => error.reason._tag === "EmptyBodyError" ? Effect.void : Effect.fail(error))))), (bytes) => Effect.sync(() => {
79
+ if (bytes !== void 0) unfinishedBrowserExportBytes -= bytes;
80
+ })));
81
+ const spanUpstream = (span) => {
82
+ let current = span;
83
+ while (current?._tag === "Span") {
84
+ const upstream = current.attributes.get("chain.upstream");
85
+ if (upstream === "alchemy" || upstream === "infura") return upstream;
86
+ current = current.parent._tag === "Some" ? current.parent.value : void 0;
62
87
  }
63
88
  };
64
- const REDACTED_SPAN_FAILURE = Exit.fail(new RedactedEngineeringSpanFailure());
65
89
  /** Preserve domain exits while preventing the OTLP serializer from seeing raw causes. */
66
90
  const makeLeakSafeEngineeringTracer = (delegate) => Tracer.make({
67
91
  span(options) {
68
92
  const span = delegate.span(options);
69
93
  const wrapped = Object.create(span);
94
+ Object.defineProperty(wrapped, "attribute", { value: (name, value) => {
95
+ const source = spanUpstream(span);
96
+ const header = /^http\.(?:request|response)\.header\.(.+)$/u.exec(name)?.[1];
97
+ if (header !== void 0 && isCredentialField(header, "header")) span.attribute(name, "[REDACTED]");
98
+ else if (typeof value === "string" && name === "url.query") span.attribute(name, redactUrlSecrets(`?${value}`).slice(1, 2049));
99
+ else if (typeof value === "string" && name === "url.path") {
100
+ const fullUrl = span.attributes.get("url.full");
101
+ const path = typeof fullUrl === "string" ? /^https?:\/\/[^/]+([^?#]*)/u.exec(fullUrl)?.[1] ?? value : value;
102
+ span.attribute(name, redactUrlSecrets(path, source).slice(0, 2048));
103
+ } else if (typeof value === "string" && (name === "url.full" || header === "location" || header === "referer" || header === "referrer")) span.attribute(name, redactUrlSecrets(value, source).slice(0, 2048));
104
+ else if (header !== void 0 && typeof value === "string") span.attribute(name, redactSecrets(value, source).slice(0, 2048));
105
+ else span.attribute(name, value);
106
+ } });
70
107
  Object.defineProperty(wrapped, "end", {
71
108
  configurable: false,
72
109
  enumerable: false,
73
- value: (endTime, exit) => span.end(endTime, Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause) ? REDACTED_SPAN_FAILURE : exit),
110
+ value: (endTime, exit) => {
111
+ if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
112
+ const first = exit.cause.reasons.find((reason) => !Cause.isInterruptReason(reason));
113
+ const code = failureText(first === void 0 ? void 0 : Cause.isFailReason(first) ? first.error : first.defect, "code", 128);
114
+ if (code !== void 0) span.attribute("error.code", code);
115
+ span.end(endTime, exportFailureExit(exit.cause, spanUpstream(span)));
116
+ } else span.end(endTime, exit);
117
+ },
74
118
  writable: false
75
119
  });
76
120
  return wrapped;
@@ -101,8 +145,29 @@ const makeEngineeringTelemetryLayer = (config) => {
101
145
  resource,
102
146
  mergeWithExisting: true
103
147
  });
104
- const headerPolicy = Layer.merge(Layer.succeed(HttpClient.TracerHeaderFilter, traceHeaderFilter), Layer.succeed(Headers.CurrentRedactedNames, ENGINEERING_REDACTED_HEADER_NAMES));
105
- return Layer.mergeAll(tracing, logging, headerPolicy).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer));
148
+ const headerPolicy = Layer.merge(Layer.succeed(HttpClient.TracerHeaderFilter, traceHeaderFilter), Layer.succeed(Headers.CurrentRedactedNames, [credentialHeaderPattern]));
149
+ const browserLifecycle = Layer.effectDiscard(Effect.gen(function* () {
150
+ if (validated.producer !== "browser" || !hasBrowserDocument()) return;
151
+ const browserWindow = window;
152
+ const browserDocument = document;
153
+ const flusher = yield* OtlpExporter.Flusher;
154
+ const run = yield* FiberSet.makeRuntime();
155
+ const flush = () => {
156
+ run(flusher.flush);
157
+ };
158
+ const visibilityChanged = () => {
159
+ if (browserDocument.visibilityState === "hidden") flush();
160
+ };
161
+ yield* Effect.acquireRelease(Effect.sync(() => {
162
+ browserDocument.addEventListener("visibilitychange", visibilityChanged);
163
+ browserWindow.addEventListener("pagehide", flush);
164
+ }), () => Effect.sync(() => {
165
+ browserDocument.removeEventListener("visibilitychange", visibilityChanged);
166
+ browserWindow.removeEventListener("pagehide", flush);
167
+ }));
168
+ }));
169
+ const transport = Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, (client) => validated.producer === "browser" && hasBrowserDocument() ? browserExportClient(client) : client)).pipe(Layer.provide(FetchHttpClient.layer));
170
+ return browserLifecycle.pipe(Layer.provideMerge(Layer.mergeAll(tracing, logging, headerPolicy)), Layer.provide(OtlpSerialization.layerJson), Layer.provide(transport));
106
171
  };
107
172
  //#endregion
108
173
  export { ENGINEERING_CAPXUL_ENVS, makeEngineeringTelemetryLayer, makeLeakSafeEngineeringTracer, postHogOtlpEndpoints, traceHeaderFilter };
package/dist/index.d.mts CHANGED
@@ -1910,7 +1910,7 @@ declare const UiActivityActionCompletedTelemetryEventSchema: Schema.Struct<{
1910
1910
  readonly sdk_version: Schema.optional<Schema.String>;
1911
1911
  readonly filter_kind: Schema.optional<Schema.Literals<readonly ["all", "payment", "movement"]>>;
1912
1912
  readonly filter_direction: Schema.optional<Schema.Literals<readonly ["all", "in", "out", "self"]>>;
1913
- readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
1913
+ readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "unresolved", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
1914
1914
  readonly scope_kind: Schema.Literals<readonly ["account", "organization"]>;
1915
1915
  readonly reason_code: Schema.optional<Schema.Literals<readonly ["NOT_AUTHENTICATED", "EMAIL_DELIVERY_FAILED", "PROFILE_NOT_FOUND", "SMART_ACCOUNT_MISSING", "PLAYER_NOT_FOUND", "ACCOUNT_NOT_FOUND", "ACCOUNT_CLAIMED_BY_OTHER_SIGNER", "PROVIDER_ERROR", "CAPABILITY_UNAVAILABLE", "INVALID_INPUT", "ENV_MISSING", "NOT_IMPLEMENTED", "VERIFICATION_REQUIRED", "INSUFFICIENT_BALANCE", "INVALID_RECIPIENT", "ROLE_PERMISSION_DENIED", "TRANSACTION_FAILED", "RATE_LIMITED", "NETWORK_ERROR", "UNKNOWN", "OTP_EXPIRED", "SIGNER_REJECTED", "CANCELLED", "WRONG_STATE", "STALE_EPOCH", "SUPERSEDED", "WORK_DIED", "ACTOR_STOPPED"]>>;
1916
1916
  readonly action: Schema.Literal<"filter">;
@@ -1967,7 +1967,7 @@ declare const UiActivityActionCompletedTelemetryEventSchema: Schema.Struct<{
1967
1967
  readonly sdk_version: Schema.optional<Schema.String>;
1968
1968
  readonly filter_kind: Schema.optional<Schema.Literals<readonly ["all", "payment", "movement"]>>;
1969
1969
  readonly filter_direction: Schema.optional<Schema.Literals<readonly ["all", "in", "out", "self"]>>;
1970
- readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
1970
+ readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "unresolved", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
1971
1971
  readonly scope_kind: Schema.Literals<readonly ["account", "organization"]>;
1972
1972
  readonly reason_code: Schema.optional<Schema.Literals<readonly ["NOT_AUTHENTICATED", "EMAIL_DELIVERY_FAILED", "PROFILE_NOT_FOUND", "SMART_ACCOUNT_MISSING", "PLAYER_NOT_FOUND", "ACCOUNT_NOT_FOUND", "ACCOUNT_CLAIMED_BY_OTHER_SIGNER", "PROVIDER_ERROR", "CAPABILITY_UNAVAILABLE", "INVALID_INPUT", "ENV_MISSING", "NOT_IMPLEMENTED", "VERIFICATION_REQUIRED", "INSUFFICIENT_BALANCE", "INVALID_RECIPIENT", "ROLE_PERMISSION_DENIED", "TRANSACTION_FAILED", "RATE_LIMITED", "NETWORK_ERROR", "UNKNOWN", "OTP_EXPIRED", "SIGNER_REJECTED", "CANCELLED", "WRONG_STATE", "STALE_EPOCH", "SUPERSEDED", "WORK_DIED", "ACTOR_STOPPED"]>>;
1973
1973
  readonly action: Schema.Literal<"export">;
@@ -2057,7 +2057,7 @@ declare const TELEMETRY_EVENT_SCHEMAS: {
2057
2057
  readonly sdk_version: Schema.optional<Schema.String>;
2058
2058
  readonly filter_kind: Schema.optional<Schema.Literals<readonly ["all", "payment", "movement"]>>;
2059
2059
  readonly filter_direction: Schema.optional<Schema.Literals<readonly ["all", "in", "out", "self"]>>;
2060
- readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
2060
+ readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "unresolved", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
2061
2061
  readonly scope_kind: Schema.Literals<readonly ["account", "organization"]>;
2062
2062
  readonly reason_code: Schema.optional<Schema.Literals<readonly ["NOT_AUTHENTICATED", "EMAIL_DELIVERY_FAILED", "PROFILE_NOT_FOUND", "SMART_ACCOUNT_MISSING", "PLAYER_NOT_FOUND", "ACCOUNT_NOT_FOUND", "ACCOUNT_CLAIMED_BY_OTHER_SIGNER", "PROVIDER_ERROR", "CAPABILITY_UNAVAILABLE", "INVALID_INPUT", "ENV_MISSING", "NOT_IMPLEMENTED", "VERIFICATION_REQUIRED", "INSUFFICIENT_BALANCE", "INVALID_RECIPIENT", "ROLE_PERMISSION_DENIED", "TRANSACTION_FAILED", "RATE_LIMITED", "NETWORK_ERROR", "UNKNOWN", "OTP_EXPIRED", "SIGNER_REJECTED", "CANCELLED", "WRONG_STATE", "STALE_EPOCH", "SUPERSEDED", "WORK_DIED", "ACTOR_STOPPED"]>>;
2063
2063
  readonly action: Schema.Literal<"filter">;
@@ -2114,7 +2114,7 @@ declare const TELEMETRY_EVENT_SCHEMAS: {
2114
2114
  readonly sdk_version: Schema.optional<Schema.String>;
2115
2115
  readonly filter_kind: Schema.optional<Schema.Literals<readonly ["all", "payment", "movement"]>>;
2116
2116
  readonly filter_direction: Schema.optional<Schema.Literals<readonly ["all", "in", "out", "self"]>>;
2117
- readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
2117
+ readonly filter_status: Schema.optional<Schema.$Array<Schema.Literals<readonly ["pending", "settling", "unresolved", "pending_claim", "scheduled", "streaming", "settled", "cancelled", "failed"]>>>;
2118
2118
  readonly scope_kind: Schema.Literals<readonly ["account", "organization"]>;
2119
2119
  readonly reason_code: Schema.optional<Schema.Literals<readonly ["NOT_AUTHENTICATED", "EMAIL_DELIVERY_FAILED", "PROFILE_NOT_FOUND", "SMART_ACCOUNT_MISSING", "PLAYER_NOT_FOUND", "ACCOUNT_NOT_FOUND", "ACCOUNT_CLAIMED_BY_OTHER_SIGNER", "PROVIDER_ERROR", "CAPABILITY_UNAVAILABLE", "INVALID_INPUT", "ENV_MISSING", "NOT_IMPLEMENTED", "VERIFICATION_REQUIRED", "INSUFFICIENT_BALANCE", "INVALID_RECIPIENT", "ROLE_PERMISSION_DENIED", "TRANSACTION_FAILED", "RATE_LIMITED", "NETWORK_ERROR", "UNKNOWN", "OTP_EXPIRED", "SIGNER_REJECTED", "CANCELLED", "WRONG_STATE", "STALE_EPOCH", "SUPERSEDED", "WORK_DIED", "ACTOR_STOPPED"]>>;
2120
2120
  readonly action: Schema.Literal<"export">;
@@ -3181,12 +3181,12 @@ interface PostHogBrowserCapture {
3181
3181
  * The callback mutates and returns the supplied event, matching PostHog's
3182
3182
  * `before_send` contract while preserving the event's full structural type.
3183
3183
  */
3184
- declare function sanitizePostHogBrowserEvent<T extends PostHogBrowserCapture | null>(event: T): T;
3184
+ declare function sanitizePostHogBrowserEvent<T extends PostHogBrowserCapture | null>(event: T, urlPolicy?: "path-only" | "diagnostic"): T;
3185
3185
  /**
3186
3186
  * Drop local events before transport. Sanitize and stamp every other browser
3187
3187
  * event with `capxul_env` and `producer: "browser"`.
3188
3188
  */
3189
- declare function createCapxulBeforeSend(capxulEnv: CapxulEnv): <T extends PostHogBrowserCapture | null>(event: T) => T | null;
3189
+ declare function createCapxulBeforeSend(capxulEnv: CapxulEnv, urlPolicy?: "path-only" | "diagnostic"): <T extends PostHogBrowserCapture | null>(event: T) => T | null;
3190
3190
  type PostHogBeforeSend = ReturnType<typeof createCapxulBeforeSend>;
3191
3191
  /**
3192
3192
  * ADR-0016 posture, unchanged: capability-URL utility apps (approval links,
@@ -3227,7 +3227,7 @@ type PostHogBrowserOptions = PostHogUtilityAppOptions;
3227
3227
  * funnel lies.
3228
3228
  * - identify-driven person profiles (ADR-0020 A2 wires the calls at L1).
3229
3229
  * - session replay in EVERY environment. Ordinary text and input values remain
3230
- * visible. Inputs with `autocomplete="one-time-code"` are masked. Network
3230
+ * visible. Password, OTP, and credential controls are masked. Network
3231
3231
  * timing plus request/response headers and bodies are recorded, so a failed
3232
3232
  * call shows its status and body in the replay. Canvas stays off. The
3233
3233
  * recorder script loads from PostHog, so external dependency loading is on.
@@ -3298,17 +3298,14 @@ declare function utilityAppAnalytics(input: CapxulBrowserAnalyticsInput): PostHo
3298
3298
  * generic over the real request type and returns the same type it was given.
3299
3299
  */
3300
3300
  interface CapturedNetworkRequestLike {
3301
+ readonly name?: string | undefined;
3302
+ readonly url?: string | undefined;
3301
3303
  readonly requestHeaders?: Record<string, string> | undefined;
3302
3304
  readonly responseHeaders?: Record<string, string> | undefined;
3303
3305
  readonly requestBody?: unknown;
3304
3306
  readonly responseBody?: unknown;
3305
3307
  }
3306
- /**
3307
- * Replay keeps the request line, status, timing, headers, and bodies, so a
3308
- * failed call carries its evidence. Credential headers and secret material
3309
- * never leave the browser: bearer tokens, cookies, API keys, and the OTP a
3310
- * user just typed are masked before the recording is sent.
3311
- */
3308
+ /** Keep request evidence while masking credential values at the browser boundary. */
3312
3309
  declare function maskCapturedNetworkRequest<T extends CapturedNetworkRequestLike>(request: T): T;
3313
3310
  declare function productAppAnalytics(input: CapxulBrowserAnalyticsInput): PostHogProductAppOptions;
3314
3311
  type PostHogBrowserInitialize<Options, T> = (key: string, options: Options) => T;
package/dist/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { i as redactUrlSecrets, n as isCredentialField, r as redactSecrets, t as CAPXUL_ERROR_CODES } from "./src-CWfRYsBI.mjs";
1
2
  import { Effect, Schema, SchemaGetter } from "effect";
2
3
  //#region ../errors/src/chain-cause.ts
3
4
  /**
@@ -35,39 +36,6 @@ const CHAIN_UPSTREAMS = [
35
36
  "gateway"
36
37
  ];
37
38
  //#endregion
38
- //#region ../errors/src/errors.ts
39
- const CAPXUL_ERROR_CODES = [
40
- "NOT_AUTHENTICATED",
41
- "EMAIL_DELIVERY_FAILED",
42
- "PROFILE_NOT_FOUND",
43
- "SMART_ACCOUNT_MISSING",
44
- "PLAYER_NOT_FOUND",
45
- "ACCOUNT_NOT_FOUND",
46
- "ACCOUNT_CLAIMED_BY_OTHER_SIGNER",
47
- "PROVIDER_ERROR",
48
- "CAPABILITY_UNAVAILABLE",
49
- "INVALID_INPUT",
50
- "ENV_MISSING",
51
- "NOT_IMPLEMENTED",
52
- "VERIFICATION_REQUIRED",
53
- "INSUFFICIENT_BALANCE",
54
- "INVALID_RECIPIENT",
55
- "ROLE_PERMISSION_DENIED",
56
- "TRANSACTION_FAILED",
57
- "RATE_LIMITED",
58
- "NETWORK_ERROR",
59
- "UNKNOWN",
60
- "OTP_EXPIRED",
61
- "SIGNER_REJECTED",
62
- "CANCELLED",
63
- "WRONG_STATE",
64
- "STALE_EPOCH",
65
- "SUPERSEDED",
66
- "WORK_DIED",
67
- "ACTOR_STOPPED"
68
- ];
69
- new Set(CAPXUL_ERROR_CODES);
70
- //#endregion
71
39
  //#region ../types/src/index.ts
72
40
  const EVM_ADDRESS_RE = /^0x[0-9a-f]{40}$/i;
73
41
  const BYTES32_RE = /^0x[0-9a-f]{64}$/i;
@@ -1536,6 +1504,7 @@ const ActivityFilterProps = {
1536
1504
  filter_status: Schema.optional(Schema.Array(Schema.Literals([
1537
1505
  "pending",
1538
1506
  "settling",
1507
+ "unresolved",
1539
1508
  "pending_claim",
1540
1509
  "scheduled",
1541
1510
  "streaming",
@@ -1774,22 +1743,24 @@ function redactTelemetryProps(name, props, options = {}) {
1774
1743
  * The callback mutates and returns the supplied event, matching PostHog's
1775
1744
  * `before_send` contract while preserving the event's full structural type.
1776
1745
  */
1777
- function sanitizePostHogBrowserEvent(event) {
1746
+ function sanitizePostHogBrowserEvent(event, urlPolicy = "path-only") {
1778
1747
  if (event === null) return event;
1748
+ const transportToken = event.properties.token;
1779
1749
  const seen = /* @__PURE__ */ new WeakSet();
1780
- sanitizePostHogBrowserValue(event.properties, seen);
1781
- if (event.$set !== void 0) sanitizePostHogBrowserValue(event.$set, seen);
1782
- if (event.$set_once !== void 0) sanitizePostHogBrowserValue(event.$set_once, seen);
1750
+ sanitizePostHogBrowserValue(event.properties, seen, urlPolicy);
1751
+ if (transportToken !== void 0) event.properties.token = transportToken;
1752
+ if (event.$set !== void 0) sanitizePostHogBrowserValue(event.$set, seen, urlPolicy);
1753
+ if (event.$set_once !== void 0) sanitizePostHogBrowserValue(event.$set_once, seen, urlPolicy);
1783
1754
  return event;
1784
1755
  }
1785
1756
  /**
1786
1757
  * Drop local events before transport. Sanitize and stamp every other browser
1787
1758
  * event with `capxul_env` and `producer: "browser"`.
1788
1759
  */
1789
- function createCapxulBeforeSend(capxulEnv) {
1760
+ function createCapxulBeforeSend(capxulEnv, urlPolicy = "path-only") {
1790
1761
  return (event) => {
1791
1762
  if (capxulEnv === "local") return null;
1792
- const sanitized = sanitizePostHogBrowserEvent(event);
1763
+ const sanitized = sanitizePostHogBrowserEvent(event, urlPolicy);
1793
1764
  if (sanitized === null) return sanitized;
1794
1765
  sanitized.properties.capxul_env = capxulEnv;
1795
1766
  sanitized.properties.producer ??= "browser";
@@ -1820,67 +1791,64 @@ function utilityAppAnalytics(input) {
1820
1791
  before_send: createCapxulBeforeSend(input.capxulEnv)
1821
1792
  };
1822
1793
  }
1823
- const CREDENTIAL_HEADERS = /* @__PURE__ */ new Set([
1824
- "authorization",
1825
- "proxy-authorization",
1826
- "proxy-authenticate",
1827
- "www-authenticate",
1828
- "cookie",
1829
- "set-cookie",
1830
- "x-api-key",
1831
- "x-auth-token"
1832
- ]);
1833
- /** Any JSON field whose name says "credential", however it is spelled. */
1834
- const CREDENTIAL_FIELD_NAME = /(?:otp|code|pass(?:word|wd|phrase)?|token|secret|credential|api[_-]?key|private[_-]?key|authorization|cookie|session)/iu;
1835
- /** Secret material in captured text: bearer tokens, JWTs, keys, and credential fields. */
1836
- const NETWORK_TEXT_MASKS = [
1837
- [/\bBearer\s+[^\s,;"]+/giu, "Bearer [REDACTED]"],
1838
- [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]"],
1839
- [/\b0x[a-fA-F0-9]{40,}\b/gu, "[REDACTED]"],
1840
- [/\b([A-Za-z_][A-Za-z0-9_.-]*=)[^&\s"]+/gu, "$1[REDACTED]"]
1841
- ];
1842
- /** `"name": "value"` JSON pairs; the name decides whether the value is masked. */
1843
- const JSON_STRING_FIELD = /("([^"\\]*)"\s*:\s*")((?:[^"\\]|\\.)*)(")/gu;
1844
- const QUERY_PAIR = /\b([A-Za-z_][A-Za-z0-9_.-]*=)[^&\s"]+/gu;
1845
- function maskNetworkText(text) {
1846
- return NETWORK_TEXT_MASKS.slice(0, 3).reduce((current, [pattern, replacement]) => current.replace(pattern, replacement), text).replace(JSON_STRING_FIELD, (whole, open, name, _value, close) => CREDENTIAL_FIELD_NAME.test(name) ? `${open}[REDACTED]${close}` : whole).replace(QUERY_PAIR, (whole, name) => CREDENTIAL_FIELD_NAME.test(name) ? `${name}[REDACTED]` : whole);
1794
+ /** In unstructured bodies, preserve text around explicitly named credentials. */
1795
+ const NETWORK_FIELD = /((?:"?)([A-Za-z_][A-Za-z0-9_.-]*)(?:"?)\s*[:=]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,;&}]+)/gu;
1796
+ function maskNetworkText(text, context) {
1797
+ return redactSecrets(text).replace(NETWORK_FIELD, (whole, prefix, name, value) => {
1798
+ if (!isCredentialField(name, context)) return whole;
1799
+ const quote = value.startsWith("\"") ? "\"" : value.startsWith("'") ? "'" : "";
1800
+ return `${prefix}${quote}[REDACTED]${quote}`;
1801
+ });
1847
1802
  }
1848
1803
  function maskNetworkHeaders(headers) {
1849
1804
  if (headers === void 0) return void 0;
1850
1805
  const masked = {};
1851
- for (const [name, value] of Object.entries(headers)) masked[name] = CREDENTIAL_HEADERS.has(name.toLowerCase()) ? "[REDACTED]" : maskNetworkText(value);
1806
+ for (const [name, value] of Object.entries(headers)) masked[name] = isCredentialField(name, "header") ? "[REDACTED]" : /^(?:location|content-location|referer)$/iu.test(name) ? redactUrlSecrets(value) : maskNetworkText(value);
1852
1807
  return masked;
1853
1808
  }
1854
- /** Mask by KEY at every depth: a credential-named field loses its whole value. */
1855
- function maskJsonValue(value, depth = 0) {
1856
- if (typeof value === "string") return maskNetworkText(value);
1809
+ function maskJsonValue(value, depth = 0, context) {
1810
+ if (typeof value === "function" || typeof value === "symbol") return "[UNREADABLE]";
1811
+ if (typeof value === "string") return maskNetworkText(value, context);
1857
1812
  if (typeof value !== "object" || value === null) return value;
1858
1813
  if (depth >= 16) return "[REDACTED]";
1859
- if (Array.isArray(value)) return value.map((item) => maskJsonValue(item, depth + 1));
1860
- const masked = {};
1861
- for (const [key, nested] of Object.entries(value)) masked[key] = CREDENTIAL_FIELD_NAME.test(key) ? "[REDACTED]" : maskJsonValue(nested, depth + 1);
1814
+ if (Array.isArray(value)) return value.map((item) => maskJsonValue(item, depth + 1, context));
1815
+ const masked = Object.create(null);
1816
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
1817
+ if (!descriptor.enumerable) continue;
1818
+ masked[key] = isCredentialField(key, context) ? "[REDACTED]" : !("value" in descriptor) ? "[UNREADABLE]" : typeof descriptor.value === "string" && isPostHogUrlProperty(key) ? redactUrlSecrets(descriptor.value) : maskJsonValue(descriptor.value, depth + 1, context);
1819
+ }
1862
1820
  return masked;
1863
1821
  }
1864
- function maskNetworkBody(body) {
1865
- if (typeof body !== "string") return body;
1822
+ function maskNetworkBody(body, context) {
1866
1823
  try {
1867
- const parsed = JSON.parse(body);
1868
- if (typeof parsed === "object" && parsed !== null) return JSON.stringify(maskJsonValue(parsed));
1869
- } catch {}
1870
- return maskNetworkText(body);
1824
+ if (typeof body !== "string") return maskJsonValue(body, 0, context);
1825
+ let parsed;
1826
+ try {
1827
+ parsed = JSON.parse(body);
1828
+ } catch {
1829
+ if (/^[^=&\s]+=[\s\S]*$/u.test(body)) return new URLSearchParams(Array.from(new URLSearchParams(body), ([name, value]) => [name, isCredentialField(name, context) ? "[REDACTED]" : maskNetworkText(value)])).toString();
1830
+ return maskNetworkText(body, context);
1831
+ }
1832
+ return JSON.stringify(maskJsonValue(parsed, 0, context));
1833
+ } catch {
1834
+ return "[UNREADABLE]";
1835
+ }
1871
1836
  }
1872
- /**
1873
- * Replay keeps the request line, status, timing, headers, and bodies, so a
1874
- * failed call carries its evidence. Credential headers and secret material
1875
- * never leave the browser: bearer tokens, cookies, API keys, and the OTP a
1876
- * user just typed are masked before the recording is sent.
1877
- */
1837
+ /** Keep request evidence while masking credential values at the browser boundary. */
1878
1838
  function maskCapturedNetworkRequest(request) {
1839
+ const requestUrl = request.name ?? request.url ?? "";
1840
+ let context;
1841
+ try {
1842
+ const path = new URL(requestUrl, "https://capxul.invalid").pathname;
1843
+ if (/\/(?:auth|oauth|sign-in|signin|login|verify-otp|verify-email)(?:\/|$)/iu.test(path)) context = "authentication";
1844
+ } catch {}
1879
1845
  return {
1880
1846
  ...request,
1847
+ ...request.name === void 0 ? {} : { name: redactUrlSecrets(request.name) },
1848
+ ...request.url === void 0 ? {} : { url: redactUrlSecrets(request.url) },
1881
1849
  requestHeaders: maskNetworkHeaders(request.requestHeaders),
1882
1850
  responseHeaders: maskNetworkHeaders(request.responseHeaders),
1883
- requestBody: maskNetworkBody(request.requestBody),
1851
+ requestBody: maskNetworkBody(request.requestBody, context),
1884
1852
  responseBody: maskNetworkBody(request.responseBody)
1885
1853
  };
1886
1854
  }
@@ -1910,13 +1878,20 @@ function productAppAnalytics(input) {
1910
1878
  save_referrer: false,
1911
1879
  session_recording: {
1912
1880
  maskAllInputs: true,
1913
- maskInputFn: (text, element) => element?.getAttribute("autocomplete")?.toLowerCase().split(/\s+/u).includes("one-time-code") ? "*".repeat(text.length) : text,
1881
+ maskInputFn: (text, element) => {
1882
+ const autocomplete = element?.getAttribute("autocomplete")?.toLowerCase().split(/\s+/u) ?? [];
1883
+ return element?.getAttribute("type")?.toLowerCase() === "password" || autocomplete.some((value) => [
1884
+ "one-time-code",
1885
+ "current-password",
1886
+ "new-password"
1887
+ ].includes(value)) || isCredentialField(element?.getAttribute("name") ?? "") || isCredentialField(element?.getAttribute("id") ?? "") ? "*".repeat(text.length) : maskNetworkText(text);
1888
+ },
1914
1889
  captureCanvas: { recordCanvas: false },
1915
1890
  recordHeaders: true,
1916
1891
  recordBody: true,
1917
1892
  maskCapturedNetworkRequestFn: maskCapturedNetworkRequest
1918
1893
  },
1919
- before_send: createCapxulBeforeSend(input.capxulEnv)
1894
+ before_send: createCapxulBeforeSend(input.capxulEnv, "diagnostic")
1920
1895
  };
1921
1896
  }
1922
1897
  /**
@@ -1973,11 +1948,15 @@ function createPostHogBrowserPageviewTracker(client) {
1973
1948
  } catch {}
1974
1949
  };
1975
1950
  }
1976
- function sanitizePostHogBrowserValue(value, seen) {
1951
+ function sanitizePostHogBrowserValue(value, seen, urlPolicy) {
1977
1952
  if (typeof value !== "object" || value === null || seen.has(value)) return;
1978
1953
  seen.add(value);
1979
1954
  if (Array.isArray(value)) {
1980
- for (const item of value) sanitizePostHogBrowserValue(item, seen);
1955
+ for (let index = 0; index < value.length; index++) {
1956
+ const item = value[index];
1957
+ if (typeof item === "string") value[index] = maskNetworkText(item);
1958
+ else sanitizePostHogBrowserValue(item, seen, urlPolicy);
1959
+ }
1981
1960
  return;
1982
1961
  }
1983
1962
  const properties = value;
@@ -1986,11 +1965,15 @@ function sanitizePostHogBrowserValue(value, seen) {
1986
1965
  delete properties[key];
1987
1966
  continue;
1988
1967
  }
1989
- if (typeof nestedValue === "string" && isPostHogUrlProperty(key)) {
1990
- properties[key] = stripUrlQueryAndFragment(nestedValue);
1968
+ if (isCredentialField(key)) {
1969
+ properties[key] = "[REDACTED]";
1970
+ continue;
1971
+ }
1972
+ if (typeof nestedValue === "string") {
1973
+ properties[key] = isPostHogUrlProperty(key) ? urlPolicy === "path-only" ? stripUrlQueryAndFragment(nestedValue) : redactUrlSecrets(nestedValue) : maskNetworkText(nestedValue);
1991
1974
  continue;
1992
1975
  }
1993
- sanitizePostHogBrowserValue(nestedValue, seen);
1976
+ sanitizePostHogBrowserValue(nestedValue, seen, urlPolicy);
1994
1977
  }
1995
1978
  }
1996
1979
  const POSTHOG_QUERY_DERIVED_PROPERTY_NAMES = [
@@ -0,0 +1,302 @@
1
+ //#region ../errors/src/secret-material.ts
2
+ const CREDENTIAL_FIELDS = /* @__PURE__ */ new Set([
3
+ "password",
4
+ "passwd",
5
+ "pwd",
6
+ "pass",
7
+ "passphrase",
8
+ "currentpassword",
9
+ "newpassword",
10
+ "confirmpassword",
11
+ "passwordconfirmation",
12
+ "passwordhash",
13
+ "hashedpassword",
14
+ "pin",
15
+ "privatekey",
16
+ "walletprivatekey",
17
+ "secretkey",
18
+ "signingkey",
19
+ "signingsecret",
20
+ "seed",
21
+ "seedphrase",
22
+ "mnemonic",
23
+ "recoveryphrase",
24
+ "recovery",
25
+ "recoverycode",
26
+ "recoverycodes",
27
+ "keyshare",
28
+ "signingmaterial",
29
+ "recoverymaterial",
30
+ "privatekeybytes",
31
+ "signingprivatekey",
32
+ "encryptionkey",
33
+ "decryptionkey",
34
+ "authtoken",
35
+ "apitoken",
36
+ "bearertoken",
37
+ "oauthtoken",
38
+ "csrftoken",
39
+ "clientassertion",
40
+ "token",
41
+ "accesstoken",
42
+ "refreshtoken",
43
+ "idtoken",
44
+ "sessiontoken",
45
+ "session",
46
+ "sessionkey",
47
+ "sessionsecret",
48
+ "authsessionid",
49
+ "sessioncredential",
50
+ "sessioncredentials",
51
+ "sessioncookie",
52
+ "auth",
53
+ "authentication",
54
+ "authorization",
55
+ "proxyauthorization",
56
+ "cookie",
57
+ "cookies",
58
+ "setcookie",
59
+ "apikey",
60
+ "xapikey",
61
+ "xauthtoken",
62
+ "clientsecret",
63
+ "secret",
64
+ "secrets",
65
+ "credential",
66
+ "credentials",
67
+ "signature",
68
+ "otp",
69
+ "otpcode",
70
+ "totpsecret",
71
+ "onetimecode",
72
+ "onetimepassword",
73
+ "verificationcode",
74
+ "authcode",
75
+ "authorizationcode",
76
+ "oauthcode",
77
+ "codeverifier"
78
+ ]);
79
+ /** Match credential fields by meaning, without hiding token addresses or replay IDs. */
80
+ function isCredentialField(name, context) {
81
+ const normalized = name.toLowerCase().replace(/[^a-z0-9]/gu, "");
82
+ return CREDENTIAL_FIELDS.has(normalized) || /(?:password(?:hash)?|passwd|passphrase|privatekey|signingkey|apikey|clientsecret|accesstoken|refreshtoken|sessiontoken)$/u.test(normalized) || context === "authentication" && (normalized === "code" || normalized === "sessionid") || context === "header" && /(?:token|secret|apikey|privatekey|signingkey|password|authorization|cookie|session)$/u.test(normalized);
83
+ }
84
+ function decodeUrlComponent(text) {
85
+ try {
86
+ return decodeURIComponent(text);
87
+ } catch {
88
+ return text;
89
+ }
90
+ }
91
+ /** Preserve URL diagnostics while removing credentials from their declared components. */
92
+ function redactUrlSecrets(value, source) {
93
+ try {
94
+ const absolute = /^[a-z][a-z0-9+.-]*:/iu.test(value);
95
+ const protocolRelative = value.startsWith("//");
96
+ const url = new URL(value, "https://redaction.invalid");
97
+ if (url.protocol !== "https:" && url.protocol !== "http:") return maskText(value);
98
+ let changed = url.username.length > 0 || url.password.length > 0;
99
+ const maskParams = (params) => new URLSearchParams(Array.from(params, ([name, entry]) => {
100
+ const safeName = maskText(name);
101
+ const safeEntry = isCredentialField(name) || /^(?:code|draft|__posthog)$/iu.test(name) ? "[REDACTED]" : maskText(entry);
102
+ changed ||= safeName !== name || safeEntry !== entry;
103
+ return [safeName, safeEntry];
104
+ })).toString();
105
+ const provider = source !== void 0 || /(?:^|\.)(?:alchemy\.com|alchemyapi\.io|infura\.io)$/iu.test(url.hostname);
106
+ const segments = (absolute || protocolRelative ? url.pathname : value.split(/[?#]/u)[0] ?? "").split("/");
107
+ const path = segments.map((segment, index) => {
108
+ const decoded = decodeUrlComponent(segment);
109
+ const masked = provider && /^v[23]$/u.test(decodeUrlComponent(segments[index - 1] ?? "")) && decoded.length > 0 ? "[REDACTED]" : maskText(decoded);
110
+ changed ||= masked !== decoded;
111
+ return masked === decoded ? segment : encodeURIComponent(masked);
112
+ }).join("/");
113
+ const query = maskParams(url.searchParams);
114
+ const fragment = url.hash.slice(1);
115
+ const decodedFragment = decodeUrlComponent(fragment);
116
+ const maskedFragment = maskText(decodedFragment);
117
+ changed ||= maskedFragment !== decodedFragment;
118
+ const fragmentQuery = fragment.indexOf("?");
119
+ const fragmentPath = fragment.slice(0, fragmentQuery);
120
+ const maskedFragmentPath = maskText(decodeUrlComponent(fragmentPath));
121
+ changed ||= maskedFragmentPath !== decodeUrlComponent(fragmentPath);
122
+ const hash = fragmentQuery >= 0 ? `${maskedFragmentPath === decodeUrlComponent(fragmentPath) ? fragmentPath : encodeURIComponent(maskedFragmentPath)}?${maskParams(new URLSearchParams(fragment.slice(fragmentQuery + 1)))}` : fragment.includes("=") ? maskParams(new URLSearchParams(fragment)) : maskedFragment === decodedFragment ? fragment : encodeURIComponent(maskedFragment);
123
+ const origin = absolute ? url.origin : protocolRelative ? `//${url.host}` : "";
124
+ if (!changed) return value;
125
+ return `${origin}${path}${query ? `?${query}` : ""}${hash ? `#${hash}` : ""}`;
126
+ } catch {
127
+ return maskText(value.replace(/(https?:\/\/)[^/\s@]+@/giu, "$1[REDACTED]@").replace(/([?&#])([^=&#]+)=([^&#]*)/gu, (whole, separator, name) => isCredentialField(decodeUrlComponent(name)) || /^(?:code|draft|__posthog)$/iu.test(decodeUrlComponent(name)) ? `${separator}${name}=[REDACTED]` : whole));
128
+ }
129
+ }
130
+ const JSON_FIELD = /("(?:[^"\\]|\\.)*")(\s*:\s*)("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/gu;
131
+ const PROSE_FIELD = /(?=(\b([A-Za-z][A-Za-z0-9_-]*(?:[ \t]+[A-Za-z][A-Za-z0-9_-]*){0,3})\s*[:=]\s*((?:Bearer|Basic)[ \t]+[^\s,;"']+|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,;&"']+)))/gu;
132
+ const EMBEDDED_URL = /(https?:\/\/[^\s"<>]*)/giu;
133
+ function isSecretLabel(name) {
134
+ return isCredentialField(name) || /^request[ _-]?body$/iu.test(name);
135
+ }
136
+ const JSON_ENCODING_MAX = 8;
137
+ const JSON_NESTING_MAX = 64;
138
+ const JSON_KEY = /("(?:[^"\\]|\\.)*")\s*:/gu;
139
+ const JSON_CONTAINER_START = /(?:\{\s*["}]|\[\s*[[\]{}"0-9tfn-])/uy;
140
+ function hasSecretJsonKey(value) {
141
+ for (const match of value.matchAll(JSON_KEY)) try {
142
+ if (match[1] !== void 0 && isSecretLabel(JSON.parse(match[1]))) return true;
143
+ } catch {}
144
+ return false;
145
+ }
146
+ /** Project a complete fragment once. Never retry all of its nested substrings. */
147
+ function projectJsonFragment(value, encodingDepth, overDepth) {
148
+ if (overDepth || encodingDepth >= JSON_ENCODING_MAX) return "[REDACTED]";
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(value);
152
+ } catch {
153
+ return hasSecretJsonKey(value) ? "[REDACTED]" : value;
154
+ }
155
+ try {
156
+ let changed = false;
157
+ const masked = JSON.stringify(parsed, (name, nested) => {
158
+ if (isSecretLabel(name)) {
159
+ changed ||= nested !== "[REDACTED]";
160
+ return "[REDACTED]";
161
+ }
162
+ if (typeof nested === "string") {
163
+ const projected = maskJsonFragments(nested, encodingDepth + 1);
164
+ changed ||= projected !== nested;
165
+ return projected;
166
+ }
167
+ return nested;
168
+ });
169
+ return changed ? masked : value;
170
+ } catch {
171
+ return "[REDACTED]";
172
+ }
173
+ }
174
+ /** Scan balanced object/array fragments once, respecting JSON quotes and escapes. */
175
+ function maskJsonFragments(value, encodingDepth = 0) {
176
+ if (value.trimStart().startsWith("\"")) try {
177
+ const decoded = JSON.parse(value);
178
+ if (typeof decoded === "string") {
179
+ if (encodingDepth >= JSON_ENCODING_MAX) return "\"[REDACTED]\"";
180
+ const masked = maskJsonFragments(decoded, encodingDepth + 1);
181
+ return masked === decoded ? value : JSON.stringify(masked);
182
+ }
183
+ } catch {}
184
+ let output = "";
185
+ let cursor = 0;
186
+ let start = -1;
187
+ let depth = 0;
188
+ let overDepth = false;
189
+ let quoted = false;
190
+ let escaped = false;
191
+ for (let index = 0; index < value.length; index++) {
192
+ const character = value[index];
193
+ if (start < 0) {
194
+ JSON_CONTAINER_START.lastIndex = index;
195
+ if (!JSON_CONTAINER_START.test(value)) continue;
196
+ start = index;
197
+ depth = 1;
198
+ overDepth = false;
199
+ continue;
200
+ }
201
+ if (quoted) {
202
+ if (escaped) escaped = false;
203
+ else if (character === "\\") escaped = true;
204
+ else if (character === "\"") quoted = false;
205
+ continue;
206
+ }
207
+ if (character === "\"") quoted = true;
208
+ else if (character === "{" || character === "[") {
209
+ depth++;
210
+ overDepth ||= depth > JSON_NESTING_MAX;
211
+ } else if (character === "}" || character === "]") {
212
+ depth--;
213
+ if (depth !== 0) continue;
214
+ output += value.slice(cursor, start) + projectJsonFragment(value.slice(start, index + 1), encodingDepth, overDepth);
215
+ cursor = index + 1;
216
+ start = -1;
217
+ }
218
+ }
219
+ if (start >= 0) {
220
+ const fragment = value.slice(start);
221
+ output += value.slice(cursor, start) + (overDepth || hasSecretJsonKey(fragment) ? "[REDACTED]" : fragment);
222
+ cursor = value.length;
223
+ }
224
+ return output + value.slice(cursor);
225
+ }
226
+ function maskLabelledText(value) {
227
+ const jsonMasked = maskJsonFragments(value).replace(JSON_FIELD, (whole, quotedName, separator) => {
228
+ try {
229
+ return isSecretLabel(JSON.parse(quotedName)) ? `${quotedName}${separator}"[REDACTED]"` : whole;
230
+ } catch {
231
+ return whole;
232
+ }
233
+ });
234
+ let output = "";
235
+ let cursor = 0;
236
+ for (const match of jsonMasked.matchAll(PROSE_FIELD)) {
237
+ const [, whole, label, entry] = match;
238
+ if (whole === void 0 || label === void 0 || entry === void 0 || match.index < cursor || /^Bearer[ \t]+/iu.test(entry)) continue;
239
+ const words = label.split(/[ \t]+/u);
240
+ for (let index = 0; index < words.length; index++) {
241
+ const name = words.slice(index).join(" ");
242
+ if (isSecretLabel(name)) {
243
+ const prefix = words.slice(0, index).join(" ");
244
+ output += jsonMasked.slice(cursor, match.index) + `${prefix ? `${prefix} ` : ""}${name}=[REDACTED]`;
245
+ cursor = match.index + whole.length;
246
+ break;
247
+ }
248
+ }
249
+ }
250
+ return output + jsonMasked.slice(cursor);
251
+ }
252
+ const TOKEN_MASKS = [
253
+ [/\bBearer\s+(?!(?:realm|error|error_description|scope|authorization_uri|resource|claims)\s*=)[^\s,;"']+/giu, "Bearer [REDACTED]"],
254
+ [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]"],
255
+ [/(^|[^a-fA-F0-9])(?<!0[xX])[a-fA-F0-9]{64}(?=$|[^a-fA-F0-9])/gu, "$1[REDACTED]"],
256
+ [/(?:(?:sk|pk|rk)_(?:live|test)|(?:phc|phx|ghp|gho|ghu|ghs|ghr|whsec)_|github_pat_|xox[baprs]-|AKIA[0-9A-Z]{16}|AIza)[A-Za-z0-9_-]*/giu, "[REDACTED]"]
257
+ ];
258
+ /** URL components and malformed URLs use this non-recursive masking layer. */
259
+ function maskText(value) {
260
+ let text = maskLabelledText(value);
261
+ for (const [pattern, replacement] of TOKEN_MASKS) text = text.replace(pattern, replacement);
262
+ return text;
263
+ }
264
+ /** Mask credential fields and embedded URLs while preserving surrounding evidence. */
265
+ function redactSecrets(value, source) {
266
+ return maskLabelledText(value).split(EMBEDDED_URL).map((part, index) => index % 2 === 1 ? redactUrlSecrets(part, source).replaceAll("%5BREDACTED%5D", "[REDACTED]") : maskText(part)).join("");
267
+ }
268
+ //#endregion
269
+ //#region ../errors/src/errors.ts
270
+ const CAPXUL_ERROR_CODES = [
271
+ "NOT_AUTHENTICATED",
272
+ "EMAIL_DELIVERY_FAILED",
273
+ "PROFILE_NOT_FOUND",
274
+ "SMART_ACCOUNT_MISSING",
275
+ "PLAYER_NOT_FOUND",
276
+ "ACCOUNT_NOT_FOUND",
277
+ "ACCOUNT_CLAIMED_BY_OTHER_SIGNER",
278
+ "PROVIDER_ERROR",
279
+ "CAPABILITY_UNAVAILABLE",
280
+ "INVALID_INPUT",
281
+ "ENV_MISSING",
282
+ "NOT_IMPLEMENTED",
283
+ "VERIFICATION_REQUIRED",
284
+ "INSUFFICIENT_BALANCE",
285
+ "INVALID_RECIPIENT",
286
+ "ROLE_PERMISSION_DENIED",
287
+ "TRANSACTION_FAILED",
288
+ "RATE_LIMITED",
289
+ "NETWORK_ERROR",
290
+ "UNKNOWN",
291
+ "OTP_EXPIRED",
292
+ "SIGNER_REJECTED",
293
+ "CANCELLED",
294
+ "WRONG_STATE",
295
+ "STALE_EPOCH",
296
+ "SUPERSEDED",
297
+ "WORK_DIED",
298
+ "ACTOR_STOPPED"
299
+ ];
300
+ new Set(CAPXUL_ERROR_CODES);
301
+ //#endregion
302
+ export { redactUrlSecrets as i, isCredentialField as n, redactSecrets as r, CAPXUL_ERROR_CODES as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capxul/observability",
3
- "version": "4.1.3",
3
+ "version": "4.2.0-rc.1",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,8 +37,8 @@
37
37
  "vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
38
38
  "vite-plus": "0.3.0",
39
39
  "vitest": "4.1.11",
40
- "@capxul/errors": "0.3.0",
41
40
  "@capxul/types": "0.3.0",
41
+ "@capxul/errors": "0.3.0",
42
42
  "@capxul/typescript-config": "0.0.0"
43
43
  },
44
44
  "scripts": {