@meshconnect/uwc-core 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +247 -76
  2. package/dist/events.d.ts +241 -5
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +77 -1
  5. package/dist/events.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/logger/create-logger.d.ts +32 -0
  11. package/dist/logger/create-logger.d.ts.map +1 -0
  12. package/dist/logger/create-logger.js +79 -0
  13. package/dist/logger/create-logger.js.map +1 -0
  14. package/dist/managers/event-manager.d.ts.map +1 -1
  15. package/dist/managers/event-manager.js +5 -1
  16. package/dist/managers/event-manager.js.map +1 -1
  17. package/dist/observability/connect-observer.d.ts +33 -0
  18. package/dist/observability/connect-observer.d.ts.map +1 -0
  19. package/dist/observability/connect-observer.js +115 -0
  20. package/dist/observability/connect-observer.js.map +1 -0
  21. package/dist/observability/focus-tracker.d.ts +42 -0
  22. package/dist/observability/focus-tracker.d.ts.map +1 -0
  23. package/dist/observability/focus-tracker.js +57 -0
  24. package/dist/observability/focus-tracker.js.map +1 -0
  25. package/dist/observability/instrument-handoff.d.ts +54 -0
  26. package/dist/observability/instrument-handoff.d.ts.map +1 -0
  27. package/dist/observability/instrument-handoff.js +173 -0
  28. package/dist/observability/instrument-handoff.js.map +1 -0
  29. package/dist/observability/lifecycle-bridge.d.ts +69 -0
  30. package/dist/observability/lifecycle-bridge.d.ts.map +1 -0
  31. package/dist/observability/lifecycle-bridge.js +155 -0
  32. package/dist/observability/lifecycle-bridge.js.map +1 -0
  33. package/dist/observability/scrub.d.ts +31 -0
  34. package/dist/observability/scrub.d.ts.map +1 -0
  35. package/dist/observability/scrub.js +167 -0
  36. package/dist/observability/scrub.js.map +1 -0
  37. package/dist/observability/telemetry.d.ts +157 -0
  38. package/dist/observability/telemetry.d.ts.map +1 -0
  39. package/dist/observability/telemetry.js +373 -0
  40. package/dist/observability/telemetry.js.map +1 -0
  41. package/dist/services/connection-service.d.ts +23 -1
  42. package/dist/services/connection-service.d.ts.map +1 -1
  43. package/dist/services/connection-service.js +128 -19
  44. package/dist/services/connection-service.js.map +1 -1
  45. package/dist/services/network-switch-service.d.ts +2 -1
  46. package/dist/services/network-switch-service.d.ts.map +1 -1
  47. package/dist/services/network-switch-service.js +45 -16
  48. package/dist/services/network-switch-service.js.map +1 -1
  49. package/dist/services/signature-service.d.ts +14 -7
  50. package/dist/services/signature-service.d.ts.map +1 -1
  51. package/dist/services/signature-service.js +56 -58
  52. package/dist/services/signature-service.js.map +1 -1
  53. package/dist/services/transaction-service.d.ts +19 -5
  54. package/dist/services/transaction-service.d.ts.map +1 -1
  55. package/dist/services/transaction-service.js +55 -38
  56. package/dist/services/transaction-service.js.map +1 -1
  57. package/dist/universal-wallet-connector.d.ts +56 -1
  58. package/dist/universal-wallet-connector.d.ts.map +1 -1
  59. package/dist/universal-wallet-connector.js +207 -110
  60. package/dist/universal-wallet-connector.js.map +1 -1
  61. package/dist/utils/id.d.ts +2 -0
  62. package/dist/utils/id.d.ts.map +1 -0
  63. package/dist/utils/id.js +41 -0
  64. package/dist/utils/id.js.map +1 -0
  65. package/dist/utils/resolve-session-provider.d.ts +10 -0
  66. package/dist/utils/resolve-session-provider.d.ts.map +1 -0
  67. package/dist/utils/resolve-session-provider.js +21 -0
  68. package/dist/utils/resolve-session-provider.js.map +1 -0
  69. package/dist/utils/to-wallet-error.d.ts +35 -0
  70. package/dist/utils/to-wallet-error.d.ts.map +1 -0
  71. package/dist/utils/to-wallet-error.js +130 -0
  72. package/dist/utils/to-wallet-error.js.map +1 -0
  73. package/dist/utils/validate-wallet-session.d.ts +15 -0
  74. package/dist/utils/validate-wallet-session.d.ts.map +1 -0
  75. package/dist/utils/validate-wallet-session.js +29 -0
  76. package/dist/utils/validate-wallet-session.js.map +1 -0
  77. package/dist/version.d.ts +2 -0
  78. package/dist/version.d.ts.map +1 -0
  79. package/dist/version.js +3 -0
  80. package/dist/version.js.map +1 -0
  81. package/package.json +6 -6
  82. package/src/events.ts +326 -14
  83. package/src/index.ts +10 -0
  84. package/src/logger/create-logger.test.ts +126 -0
  85. package/src/logger/create-logger.ts +112 -0
  86. package/src/managers/event-manager.test.ts +55 -0
  87. package/src/managers/event-manager.ts +5 -1
  88. package/src/observability/connect-observer.test.ts +214 -0
  89. package/src/observability/connect-observer.ts +128 -0
  90. package/src/observability/focus-tracker.test.ts +191 -0
  91. package/src/observability/focus-tracker.ts +96 -0
  92. package/src/observability/instrument-handoff.test.ts +397 -0
  93. package/src/observability/instrument-handoff.ts +231 -0
  94. package/src/observability/lifecycle-bridge.test.ts +398 -0
  95. package/src/observability/lifecycle-bridge.ts +216 -0
  96. package/src/observability/scrub.test.ts +183 -0
  97. package/src/observability/scrub.ts +174 -0
  98. package/src/observability/telemetry.test.ts +549 -0
  99. package/src/observability/telemetry.ts +546 -0
  100. package/src/services/connection-service.test.ts +261 -1
  101. package/src/services/connection-service.ts +167 -38
  102. package/src/services/network-switch-service.test.ts +51 -1
  103. package/src/services/network-switch-service.ts +56 -24
  104. package/src/services/signature-service.test.ts +181 -76
  105. package/src/services/signature-service.ts +75 -85
  106. package/src/services/transaction-service.test.ts +270 -69
  107. package/src/services/transaction-service.ts +74 -51
  108. package/src/universal-wallet-connector.instrumentation-guard.test.ts +199 -0
  109. package/src/universal-wallet-connector.observability.test.ts +610 -0
  110. package/src/universal-wallet-connector.test.ts +27 -146
  111. package/src/universal-wallet-connector.ts +279 -142
  112. package/src/utils/id.test.ts +15 -0
  113. package/src/utils/id.ts +48 -0
  114. package/src/utils/resolve-session-provider.test.ts +54 -0
  115. package/src/utils/resolve-session-provider.ts +25 -0
  116. package/src/utils/to-wallet-error.test.ts +181 -0
  117. package/src/utils/to-wallet-error.ts +152 -0
  118. package/src/utils/validate-wallet-session.test.ts +77 -0
  119. package/src/utils/validate-wallet-session.ts +40 -0
  120. package/src/version.test.ts +11 -0
  121. package/src/version.ts +2 -0
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { scrubValue, scrubMessage, scrubArgs } from './scrub'
3
+
4
+ describe('scrub', () => {
5
+ it('redacts sensitive object keys (substring match, case-insensitive)', () => {
6
+ const out = scrubValue({
7
+ walletPrivateKey: '0xabc',
8
+ authToken: 'jwt',
9
+ 'x-api-key': 'k',
10
+ mnemonic: 'a b c',
11
+ signature: '0xsig',
12
+ message: 'sign me',
13
+ chainId: 'eip155:1'
14
+ }) as Record<string, unknown>
15
+ expect(out.walletPrivateKey).toBe('[Redacted]')
16
+ expect(out.authToken).toBe('[Redacted]')
17
+ expect(out['x-api-key']).toBe('[Redacted]')
18
+ expect(out.mnemonic).toBe('[Redacted]')
19
+ expect(out.signature).toBe('[Redacted]')
20
+ expect(out.message).toBe('[Redacted]')
21
+ // Non-sensitive keys pass through.
22
+ expect(out.chainId).toBe('eip155:1')
23
+ })
24
+
25
+ it('redacts secrets embedded in free-form strings (hex blobs, k/v params, bearer)', () => {
26
+ // EVM address (40 nibbles) and a longer signature/key blob.
27
+ expect(
28
+ scrubMessage(
29
+ 'insufficient funds for 0x1234567890abcdef1234567890abcdef12345678'
30
+ )
31
+ ).toBe('insufficient funds for 0x[redacted-hex]')
32
+ expect(
33
+ scrubMessage('relay https://r.io/?token=abc123secret&chain=1 failed')
34
+ ).toContain('token=[redacted]')
35
+ expect(scrubMessage('Authorization: Bearer eyJabc.def-ghi')).toContain(
36
+ 'Bearer [redacted]'
37
+ )
38
+ })
39
+
40
+ it('redacts a Solana base58 address embedded in a free-form string', () => {
41
+ const out = scrubMessage(
42
+ 'transfer to EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v failed'
43
+ )
44
+ expect(out).toContain('[redacted-base58]')
45
+ expect(out).not.toContain('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')
46
+ })
47
+
48
+ it('redacts base58 runs LONGER than a bare address (signatures, serialized blobs)', () => {
49
+ // A Solana signature is ~88 base58 chars. The old `{32,44}\b` rule could
50
+ // never match a contiguous run >44 chars (no word boundary lands inside a
51
+ // run), so signatures egressed verbatim — contradicting the documented
52
+ // over-redaction contract.
53
+ const sig = 'A2b'.repeat(30) // 90-char base58 run (no 0/O/I/l)
54
+ const out = scrubMessage(`Transaction ${sig} failed to confirm`)
55
+ expect(out).not.toContain(sig)
56
+ expect(out).toContain('[redacted-base58]')
57
+ })
58
+
59
+ it('redacts a base58 address even when GLUED to a base58-excluded word char', () => {
60
+ // `0`, `O`, `I`, `l`, `_` are word chars OUTSIDE the base58 alphabet: they
61
+ // break the character class AND defeat a `\b` anchor, so an address glued
62
+ // to one slipped through. Anchor-free matching closes that shape.
63
+ const addr = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
64
+ expect(scrubMessage(`account_${addr}`)).not.toContain(addr)
65
+ expect(scrubMessage(`${addr}0suffix`)).not.toContain(addr)
66
+ expect(scrubMessage(`l${addr}`)).not.toContain(addr)
67
+ })
68
+
69
+ it('redacts raw-form TON addresses (workchain:hex), both 0: and -1:', () => {
70
+ const raw = '0:' + 'ab12'.repeat(16)
71
+ const neg = '-1:' + 'ab12'.repeat(16)
72
+ const out = scrubMessage(`bounce from ${raw} and ${neg}`)
73
+ expect(out).not.toContain(raw)
74
+ expect(out).not.toContain(neg)
75
+ expect(out).toContain('[redacted-ton-address]')
76
+ })
77
+
78
+ it('redacts a raw-form TON address GLUED to word chars (no \\b anchoring)', () => {
79
+ // Digits and `_` are word chars, so a `\b`-anchored pattern never matched
80
+ // `account_0:<hex>` — the same glue failure as the base58 case above.
81
+ const raw = '0:' + 'ab12'.repeat(16)
82
+ const glued = scrubMessage(`account_${raw}`)
83
+ expect(glued).not.toContain(raw)
84
+ expect(glued).toContain('[redacted-ton-address]')
85
+ expect(scrubMessage(`${raw}_suffix`)).not.toContain(raw)
86
+ })
87
+
88
+ it('does not treat a LONGER hex run as a raw-form TON address', () => {
89
+ // 65+ hex chars after the colon is some other blob, not a raw address.
90
+ // (The broad base58 rule may still over-redact it — by design — so assert
91
+ // only that the TON-specific marker is not applied.)
92
+ const longer = '0:' + 'ab12'.repeat(16) + 'f'
93
+ expect(scrubMessage(`id ${longer}`)).not.toContain('[redacted-ton-address]')
94
+ })
95
+
96
+ it('redacts a TON user-friendly address (EQ…/UQ…)', () => {
97
+ const ton = 'EQ' + 'D'.repeat(46)
98
+ expect(scrubMessage(`bounce from ${ton}`)).toContain(
99
+ '[redacted-ton-address]'
100
+ )
101
+ const uq = 'UQ' + 'a'.repeat(46)
102
+ expect(scrubMessage(`to ${uq}`)).toContain('[redacted-ton-address]')
103
+ })
104
+
105
+ it('does not redact a short ordinary word', () => {
106
+ expect(scrubMessage('transfer failed: insufficient balance')).toBe(
107
+ 'transfer failed: insufficient balance'
108
+ )
109
+ })
110
+
111
+ it('redacts embedded secrets inside object string values too', () => {
112
+ const out = scrubValue({
113
+ note: 'sent to 0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'
114
+ }) as Record<string, unknown>
115
+ expect(out.note).toBe('sent to 0x[redacted-hex]')
116
+ })
117
+
118
+ it('caps long strings', () => {
119
+ // '0' is outside the base58 alphabet, so this exercises the cap alone.
120
+ const long = '0'.repeat(600)
121
+ const out = scrubMessage(long)
122
+ expect(out.length).toBeLessThan(600)
123
+ expect(out.endsWith('…[truncated]')).toBe(true)
124
+ })
125
+
126
+ it('redacts a hex blob before the length cap can slice it (no surviving tail)', () => {
127
+ // The secret must be redacted whole BEFORE truncation — never sliced so a
128
+ // partial address survives the cut. The address sits past the 500-char cap.
129
+ const addr = '0x' + 'a'.repeat(40)
130
+ const out = scrubMessage('y'.repeat(600) + addr)
131
+ expect(out).not.toContain(addr)
132
+ // And when the placeholder fits, it's present (redaction actually ran).
133
+ const out2 = scrubMessage('y'.repeat(100) + addr + 'z'.repeat(600))
134
+ expect(out2).not.toContain(addr)
135
+ expect(out2).toContain('0x[redacted-hex]')
136
+ })
137
+
138
+ it('uses a null prototype so __proto__ keys cannot pollute Object.prototype', () => {
139
+ const malicious = JSON.parse('{"__proto__": {"polluted": true}}')
140
+ const out = scrubValue(malicious) as Record<string, unknown>
141
+ expect(Object.getPrototypeOf(out)).toBeNull()
142
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined()
143
+ })
144
+
145
+ it('renders bigint, function and symbol inertly', () => {
146
+ expect(scrubValue(10n)).toBe('10')
147
+ expect(scrubValue(() => 1)).toBe('[function]')
148
+ expect(scrubValue(Symbol('s'))).toBe('[symbol]')
149
+ })
150
+
151
+ it('reduces Error instances to name + message (no stack)', () => {
152
+ const out = scrubValue(new Error('kaboom')) as Record<string, unknown>
153
+ expect(out).toEqual({ name: 'Error', message: 'kaboom' })
154
+ // Same null-prototype guarantee as the generic object branch.
155
+ expect(Object.getPrototypeOf(out)).toBe(null)
156
+ expect(out.stack).toBeUndefined()
157
+ })
158
+
159
+ it('stops recursing past max depth', () => {
160
+ const deep = { a: { b: { c: { d: { e: { f: 1 } } } } } }
161
+ const out = scrubValue(deep) as Record<string, unknown>
162
+ // 5 levels down we hit the cap.
163
+ const a = out.a as Record<string, unknown>
164
+ const b = a.b as Record<string, unknown>
165
+ const c = b.c as Record<string, unknown>
166
+ const d = c.d as Record<string, unknown>
167
+ expect(d.e).toBe('[Truncated: max depth]')
168
+ })
169
+
170
+ it('scrubArgs maps every arg', () => {
171
+ const out = scrubArgs(['ok', { secret: 's', id: 1 }])
172
+ expect(out[0]).toBe('ok')
173
+ expect((out[1] as Record<string, unknown>).secret).toBe('[Redacted]')
174
+ expect((out[1] as Record<string, unknown>).id).toBe(1)
175
+ })
176
+
177
+ it('passes null/undefined/primitives through', () => {
178
+ expect(scrubValue(null)).toBeNull()
179
+ expect(scrubValue(undefined)).toBeUndefined()
180
+ expect(scrubValue(true)).toBe(true)
181
+ expect(scrubValue(5)).toBe(5)
182
+ })
183
+ })
@@ -0,0 +1,174 @@
1
+ /**
2
+ * PII / secret scrubber for data leaving UWC through the observer seam.
3
+ *
4
+ * Security-first: the normalized `UWCTelemetryEvent` excludes signing payloads
5
+ * and addresses by construction — its STRUCTURED fields have no slot for them
6
+ * (§ telemetry.ts). This scrubber is the second line of defence for the two
7
+ * surfaces that CAN still carry free-form data on the observer path: the
8
+ * `WalletError.message` forwarded to `observer.onEvent`, and the `args` forwarded
9
+ * to `observer.onLog`. (The console / custom-logger sink receives raw data — it's
10
+ * local, not egress.)
11
+ *
12
+ * Two detection layers:
13
+ * - KEY-based redaction of sensitive object keys — substring match (not regex),
14
+ * avoiding the ReDoS class that Wiz flags on user-input regexes.
15
+ * - CONTENT-based redaction of secrets embedded in free-form strings (hex blobs,
16
+ * secret k/v params, bearer tokens) — via linear regexes with no nested or
17
+ * overlapping quantifiers (see SECRET_CONTENT_PATTERNS), so they carry no
18
+ * catastrophic-backtracking risk.
19
+ */
20
+
21
+ /**
22
+ * Separator-free, lower-cased fragments that mark a value as sensitive. Keys are
23
+ * normalized (separators stripped) before matching, so `walletPrivateKey`,
24
+ * `private_key`, `x-api-key`, and `authToken` all hit the same short list. This
25
+ * over-redacts by design (a `security`-first scrubber prefers a false redaction
26
+ * to a leak).
27
+ */
28
+ const SENSITIVE_KEY_FRAGMENTS = [
29
+ 'seed',
30
+ 'mnemonic',
31
+ 'privatekey',
32
+ 'secret',
33
+ 'password',
34
+ 'passphrase',
35
+ 'signature',
36
+ 'message',
37
+ 'payload',
38
+ 'token',
39
+ 'apikey',
40
+ 'authorization',
41
+ 'auth'
42
+ ]
43
+
44
+ const MAX_STRING_LENGTH = 500
45
+ const MAX_DEPTH = 4
46
+ const REDACTED = '[Redacted]'
47
+
48
+ function isSensitiveKey(key: string): boolean {
49
+ // Strip separators so `private_key` / `x-api-key` normalize to the bare form.
50
+ const normalized = key.toLowerCase().split('-').join('').split('_').join('')
51
+ return SENSITIVE_KEY_FRAGMENTS.some(fragment => normalized.includes(fragment))
52
+ }
53
+
54
+ // Content patterns for secrets embedded in FREE-FORM strings (error messages,
55
+ // log args) where key-based redaction can't reach. All are linear expressions
56
+ // with no nested/overlapping quantifiers, so they carry no catastrophic-
57
+ // backtracking (ReDoS) risk on attacker-controlled input.
58
+ const SECRET_CONTENT_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
59
+ // 0x-prefixed hex blobs: EVM addresses (40 nibbles) and longer
60
+ // signatures/keys/hashes/calldata (64+). Over-redacts public hashes by design.
61
+ [/0x[a-fA-F0-9]{40,}/g, '0x[redacted-hex]'],
62
+ // `secret=…` / `token=…` / `key=…` query-string or k/v fragments.
63
+ [
64
+ /\b(token|key|secret|auth|sig|signature|password|passphrase|mnemonic|seed|apikey)=[^\s&"']+/gi,
65
+ '$1=[redacted]'
66
+ ],
67
+ // `Bearer <opaque>` / `Basic <opaque>` authorization values.
68
+ [/\b(bearer|basic)\s+[A-Za-z0-9._~+/-]+=*/gi, '$1 [redacted]'],
69
+ // TON user-friendly addresses: `EQ…` / `UQ…` + 46 base64url chars (48 total).
70
+ // Trailing edge is a negative lookahead, not `\b`: a base64url address can end
71
+ // in `-`/`_`, where `\b` fails to anchor and the address would slip through.
72
+ [/\b[EU]Q[A-Za-z0-9_-]{46}(?![A-Za-z0-9_-])/g, '[redacted-ton-address]'],
73
+ // TON raw-form addresses: `workchain:64-hex` (`0:…`, `-1:…`). Before base58 so
74
+ // the hex tail gets the specific TON marker, not a generic base58 hit.
75
+ // Anchor-free like base58 below: digits and `_` are word chars, so a `\b`
76
+ // never anchors when the address is glued to one (`account_0:…` leaked).
77
+ // The trailing edge is a hex negative lookahead — a LONGER contiguous hex
78
+ // run is a different blob, not a 64-hex address.
79
+ [/-?\d+:[0-9a-fA-F]{64}(?![0-9a-fA-F])/g, '[redacted-ton-address]'],
80
+ // Solana / generic base58, 32 chars or MORE — bare addresses AND longer blobs
81
+ // (a signature is ~88 chars, a serialized tx longer still). No upper bound: a
82
+ // capped quantifier can never match inside a longer contiguous run. No `\b`
83
+ // anchors either: `0`/`O`/`I`/`l`/`_` are word chars OUTSIDE the base58
84
+ // alphabet, so they break the class AND defeat a boundary anchor — an address
85
+ // glued to one (`account_<addr>`) would slip through anchored matching.
86
+ // Over-redaction inside longer tokens is accepted by design. Placed LAST: it
87
+ // is the broadest pattern and would otherwise swallow narrower matches above.
88
+ [/[1-9A-HJ-NP-Za-km-z]{32,}/g, '[redacted-base58]']
89
+ ]
90
+
91
+ /**
92
+ * Redact secrets that live INSIDE a free-form string. Best-effort and
93
+ * deliberately conservative toward over-redaction: a wallet error string can
94
+ * embed an address, a reverted-tx calldata blob, or a URL with a token, none of
95
+ * which key-based redaction sees.
96
+ *
97
+ * Base58 (Solana) and TON addresses (user-friendly `EQ…`/`UQ…` AND the raw
98
+ * `workchain:hex` form) are redacted as a security gate: a leaked
99
+ * recipient/wallet address on the egress path is treated as a defect, so we
100
+ * accept that any base58-looking token of 32+ chars (some ids, tx hashes,
101
+ * IPFS CIDs) is over-redacted by design. The narrower TON patterns run before
102
+ * the broad base58 one so TON addresses get the specific marker.
103
+ *
104
+ * One documented gap remains: BARE (non-`0x`) hex blobs — a raw 64-char hex
105
+ * string is far more often a legitimate id/hash than a secret, so only
106
+ * `0x`-prefixed hex is redacted. Structured telemetry fields never carry
107
+ * addresses regardless (see telemetry.ts); this pass only hardens the free-form
108
+ * `error.message` / log-arg surface.
109
+ */
110
+ function redactSecretsInString(input: string): string {
111
+ let out = input
112
+ for (const [pattern, replacement] of SECRET_CONTENT_PATTERNS) {
113
+ out = out.replace(pattern, replacement)
114
+ }
115
+ return out
116
+ }
117
+
118
+ /**
119
+ * Recursively copy `value`, redacting sensitive object-keys, capping long
120
+ * strings, and rendering non-serializable values inert. The returned objects use
121
+ * a null prototype so a malicious `__proto__`/`constructor` key in attacker-shaped
122
+ * input can't pollute `Object.prototype` downstream.
123
+ */
124
+ export function scrubValue(value: unknown, depth = 0): unknown {
125
+ if (depth > MAX_DEPTH) return '[Truncated: max depth]'
126
+
127
+ if (value === null || value === undefined) return value
128
+
129
+ const type = typeof value
130
+ if (type === 'string') {
131
+ // Redact embedded secrets BEFORE capping, so the cap can't slice a pattern
132
+ // in half and leak its tail.
133
+ const str = redactSecretsInString(value as string)
134
+ return str.length > MAX_STRING_LENGTH
135
+ ? str.slice(0, MAX_STRING_LENGTH) + '…[truncated]'
136
+ : str
137
+ }
138
+ if (type === 'number' || type === 'boolean') return value
139
+ if (type === 'bigint') return (value as bigint).toString()
140
+ if (type === 'function' || type === 'symbol') return `[${type}]`
141
+
142
+ if (Array.isArray(value)) {
143
+ return value.map(item => scrubValue(item, depth + 1))
144
+ }
145
+
146
+ if (type === 'object') {
147
+ // Error instances carry a useful message but also a stack we don't want.
148
+ // Null prototype here too — the doc above promises it for every returned
149
+ // object, and an Error-shaped attacker value lands in this branch.
150
+ if (value instanceof Error) {
151
+ const out: Record<string, unknown> = Object.create(null)
152
+ out['name'] = value.name
153
+ out['message'] = scrubValue(value.message, depth + 1)
154
+ return out
155
+ }
156
+ const out: Record<string, unknown> = Object.create(null)
157
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
158
+ out[key] = isSensitiveKey(key) ? REDACTED : scrubValue(val, depth + 1)
159
+ }
160
+ return out
161
+ }
162
+
163
+ return '[Unserializable]'
164
+ }
165
+
166
+ /** Scrub a free-form error message (string in → capped string out). */
167
+ export function scrubMessage(message: string): string {
168
+ return scrubValue(message) as string
169
+ }
170
+
171
+ /** Scrub a variadic `args` array before it reaches `observer.onLog`. */
172
+ export function scrubArgs(args: unknown[]): unknown[] {
173
+ return args.map(arg => scrubValue(arg))
174
+ }