@velajs/errors 1.0.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ac4e9f8: Add the `@velajs/errors/fingerprint` subpath: a zero-dependency, cross-runtime error-grouping hash. `fingerprintError({ functionPath, message, code? })` returns a stable 16-hex-char digest over `functionPath` plus a normalized message bucket — `code` is metadata and never hashed, so redacted wire errors and raw server-side errors group identically. Ships `bucketMessage` (strips URLs, UUIDs, IPs, ids, paths, timestamps; ReDoS-clamped), `FINGERPRINT_VERSION` for persisted-fingerprint migrations, and a portable synchronous `sha256Hex` (content-addressing only, never a security primitive). Grouping approach inspired by @superlog/fingerprint (Apache-2.0), independently implemented.
8
+
3
9
  ## 1.0.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -73,6 +73,30 @@ return Response.json(body, { status });
73
73
 
74
74
  `invariant(condition, message, data?)` narrows types (`asserts condition`) and, on failure, throws an `internal`-coded `VelaError` — always redacted by rule 2. `unreachable(value: never)` is its exhaustiveness-check companion.
75
75
 
76
+ ## Error fingerprinting (`@velajs/errors/fingerprint`)
77
+
78
+ A separate, tree-shakeable subpath that turns noisy repeats of the same error into one stable **issue** identity. It is zero-dependency and computes identically in the browser, the workerd runtime, and Node, so a live in-flight error and one recomputed later from a persisted log row collapse onto the same fingerprint.
79
+
80
+ ```ts
81
+ import { fingerprintError, bucketMessage, FINGERPRINT_VERSION } from '@velajs/errors/fingerprint';
82
+
83
+ // 16-hex-char stable grouping id over functionPath :: bucket(message).
84
+ fingerprintError({ functionPath: 'orders:get', message: 'order 550e8400-… not found' });
85
+
86
+ // The `code` is metadata and is NEVER hashed — the redacted wire view (whose
87
+ // code toErrorBody may rewrite or drop) and the raw server-side error group
88
+ // together. It also works straight off toErrorBody's output:
89
+ const { body } = toErrorBody(err);
90
+ fingerprintError({ functionPath, message: body.error.message, code: body.error.code });
91
+ ```
92
+
93
+ - **`fingerprintError({ functionPath, message, code? }): string`** — the stable 16-hex grouping hash. `code` is display metadata only and is never folded into the hash.
94
+ - **`bucketMessage(message): string`** — the exported normalizer. Strips per-occurrence noise (URLs, request/filesystem paths, UUIDs, IPs, long numeric/hex ids, timestamps, emails) so a route-scanner sweep of 404s with varying paths folds to a single fingerprint. Input is clamped (~1 KB) before any regex runs as a ReDoS guard.
95
+ - **`FINGERPRINT_VERSION`** — bump whenever the bucketer heuristics change; changed heuristics re-partition history, so consumers that persist fingerprints store this alongside each hash to know when a recompute is due.
96
+ - **`sha256Hex(input): string`** — the internal portable synchronous SHA-256 (no `node:crypto`, no async `crypto.subtle`), also exported. **Content-addressing / grouping only — never a security or MAC primitive.**
97
+
98
+ > The message-normalization / grouping approach is inspired by [`@superlog/fingerprint`](https://github.com/superloglabs/superlog) (Apache-2.0). This is an independent, clean-room implementation.
99
+
76
100
  ## API
77
101
 
78
102
  - `VelaError`, `VelaErrorOptions` — the one error and its constructor options.
@@ -81,3 +105,4 @@ return Response.json(body, { status });
81
105
  - `defineErrorCatalog`, `composeCatalogs`, `Catalog`, `ErrorCatalogEntry` — catalog authoring.
82
106
  - `CORE_CATALOG`, `CORE_ENTRIES`, `CoreErrorCode`, `STATUS_TO_CODE` — the core catalog and its lookups.
83
107
  - `invariant`, `unreachable` — internal-coded assertion helpers.
108
+ - `@velajs/errors/fingerprint`: `fingerprintError`, `ErrorFingerprintInput`, `bucketMessage`, `FINGERPRINT_VERSION`, `sha256Hex` — the error-grouping subpath.
@@ -0,0 +1,61 @@
1
+ //#region src/sha256.d.ts
2
+ /**
3
+ * Portable, synchronous SHA-256 (FIPS 180-4) returning a lowercase hex digest.
4
+ *
5
+ * Implemented straight from the published standard so this file carries no
6
+ * runtime dependency and produces byte-for-byte identical output on every
7
+ * target Vela supports — browsers, the Cloudflare Workers (workerd) runtime,
8
+ * and Node. Neither built-in alternative fits the fingerprinter:
9
+ * - `node:crypto` (`createHash`) is absent in browsers and needs
10
+ * `nodejs_compat` to load in workerd.
11
+ * - `crypto.subtle.digest` is async, which is clumsy for folding error rows
12
+ * into a group key one synchronous call at a time.
13
+ *
14
+ * SECURITY: this is a plain, non-constant-time digest meant ONLY for
15
+ * content-addressing and grouping keys. Never use it as a MAC, a password hash,
16
+ * or any other authentication or security primitive.
17
+ */
18
+ declare const sha256Hex: (input: string) => string;
19
+ //#endregion
20
+ //#region src/fingerprint.d.ts
21
+ /**
22
+ * Heuristic generation of {@link bucketMessage}. Bump it whenever the
23
+ * normalizer's rules change: changed heuristics re-partition history (they may
24
+ * split one group into several or merge several into one), so a consumer that
25
+ * persists fingerprints stores this alongside each hash to know which
26
+ * generation produced it and when a recompute/backfill is due.
27
+ */
28
+ declare const FINGERPRINT_VERSION: number;
29
+ /**
30
+ * Normalize an error message into its grouping bucket: strip per-occurrence
31
+ * noise (urls, uuids, ips, request/filesystem paths, long ids, numbers) and
32
+ * fold whitespace/case, so occurrences of the same logical error collapse to
33
+ * one bucket. Exported for direct testing of the normalization heuristics.
34
+ */
35
+ declare const bucketMessage: (message: string) => string;
36
+ /** Everything a fingerprint source can supply. */
37
+ interface ErrorFingerprintInput {
38
+ /**
39
+ * What raised the error — the invoked function/route path, e.g.
40
+ * `messages:list`. Part of the grouping key.
41
+ */
42
+ functionPath: string;
43
+ /** Human-readable message (may embed user input); normalized into the key. */
44
+ message: string;
45
+ /**
46
+ * Machine error code, when known. Pure metadata: **never folded into the
47
+ * hash**, so the redacted wire error (which may rewrite or drop the code) and
48
+ * the raw server-side error still produce the same fingerprint.
49
+ */
50
+ code?: string;
51
+ }
52
+ /**
53
+ * Fold an error into its stable 16-hex-character grouping fingerprint. Pure and
54
+ * synchronous, safe to call per row when grouping a log page. The same
55
+ * `functionPath` and logically-equal `message` always yield the same hash
56
+ * regardless of the `code` supplied or per-occurrence noise in the message.
57
+ */
58
+ declare const fingerprintError: (input: ErrorFingerprintInput) => string;
59
+ //#endregion
60
+ export { ErrorFingerprintInput, FINGERPRINT_VERSION, bucketMessage, fingerprintError, sha256Hex };
61
+ //# sourceMappingURL=fingerprint.d.ts.map
@@ -0,0 +1,169 @@
1
+ //#region src/sha256.ts
2
+ /**
3
+ * Portable, synchronous SHA-256 (FIPS 180-4) returning a lowercase hex digest.
4
+ *
5
+ * Implemented straight from the published standard so this file carries no
6
+ * runtime dependency and produces byte-for-byte identical output on every
7
+ * target Vela supports — browsers, the Cloudflare Workers (workerd) runtime,
8
+ * and Node. Neither built-in alternative fits the fingerprinter:
9
+ * - `node:crypto` (`createHash`) is absent in browsers and needs
10
+ * `nodejs_compat` to load in workerd.
11
+ * - `crypto.subtle.digest` is async, which is clumsy for folding error rows
12
+ * into a group key one synchronous call at a time.
13
+ *
14
+ * SECURITY: this is a plain, non-constant-time digest meant ONLY for
15
+ * content-addressing and grouping keys. Never use it as a MAC, a password hash,
16
+ * or any other authentication or security primitive.
17
+ */
18
+ const BLOCK_BYTES = 64;
19
+ const ROUND = Uint32Array.of(1116352408, 1899447441, 3049323471, 3921009573, 961987163, 1508970993, 2453635748, 2870763221, 3624381080, 310598401, 607225278, 1426881987, 1925078388, 2162078206, 2614888103, 3248222580, 3835390401, 4022224774, 264347078, 604807628, 770255983, 1249150122, 1555081692, 1996064986, 2554220882, 2821834349, 2952996808, 3210313671, 3336571891, 3584528711, 113926993, 338241895, 666307205, 773529912, 1294757372, 1396182291, 1695183700, 1986661051, 2177026350, 2456956037, 2730485921, 2820302411, 3259730800, 3345764771, 3516065817, 3600352804, 4094571909, 275423344, 430227734, 506948616, 659060556, 883997877, 958139571, 1322822218, 1537002063, 1747873779, 1955562222, 2024104815, 2227730452, 2361852424, 2428436474, 2756734187, 3204031479, 3329325298);
20
+ const rotr = (word, bits) => word >>> bits | word << 32 - bits;
21
+ const toHex8 = (word) => (word >>> 0).toString(16).padStart(8, "0");
22
+ const sha256Hex = (input) => {
23
+ const message = new TextEncoder().encode(input);
24
+ const bitLength = message.length * 8;
25
+ const totalBytes = (Math.floor((message.length + 8) / BLOCK_BYTES) + 1) * BLOCK_BYTES;
26
+ const padded = new Uint8Array(totalBytes);
27
+ padded.set(message);
28
+ padded[message.length] = 128;
29
+ const frame = new DataView(padded.buffer);
30
+ frame.setUint32(totalBytes - 8, Math.floor(bitLength / 4294967296), false);
31
+ frame.setUint32(totalBytes - 4, bitLength >>> 0, false);
32
+ let h0 = 1779033703;
33
+ let h1 = 3144134277;
34
+ let h2 = 1013904242;
35
+ let h3 = 2773480762;
36
+ let h4 = 1359893119;
37
+ let h5 = 2600822924;
38
+ let h6 = 528734635;
39
+ let h7 = 1541459225;
40
+ const schedule = /* @__PURE__ */ new Uint32Array(64);
41
+ for (let base = 0; base < totalBytes; base += BLOCK_BYTES) {
42
+ for (let t = 0; t < 16; t += 1) schedule[t] = frame.getUint32(base + t * 4, false);
43
+ for (let t = 16; t < 64; t += 1) {
44
+ const x = schedule[t - 15];
45
+ const y = schedule[t - 2];
46
+ const sigma0 = rotr(x, 7) ^ rotr(x, 18) ^ x >>> 3;
47
+ const sigma1 = rotr(y, 17) ^ rotr(y, 19) ^ y >>> 10;
48
+ schedule[t] = schedule[t - 16] + sigma0 + schedule[t - 7] + sigma1 >>> 0;
49
+ }
50
+ let a = h0;
51
+ let b = h1;
52
+ let c = h2;
53
+ let d = h3;
54
+ let e = h4;
55
+ let f = h5;
56
+ let g = h6;
57
+ let h = h7;
58
+ for (let t = 0; t < 64; t += 1) {
59
+ const bigSigma1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
60
+ const choose = e & f ^ ~e & g;
61
+ const t1 = h + bigSigma1 + choose + ROUND[t] + schedule[t] >>> 0;
62
+ const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) >>> 0;
63
+ h = g;
64
+ g = f;
65
+ f = e;
66
+ e = d + t1 >>> 0;
67
+ d = c;
68
+ c = b;
69
+ b = a;
70
+ a = t1 + t2 >>> 0;
71
+ }
72
+ h0 = h0 + a >>> 0;
73
+ h1 = h1 + b >>> 0;
74
+ h2 = h2 + c >>> 0;
75
+ h3 = h3 + d >>> 0;
76
+ h4 = h4 + e >>> 0;
77
+ h5 = h5 + f >>> 0;
78
+ h6 = h6 + g >>> 0;
79
+ h7 = h7 + h >>> 0;
80
+ }
81
+ return toHex8(h0) + toHex8(h1) + toHex8(h2) + toHex8(h3) + toHex8(h4) + toHex8(h5) + toHex8(h6) + toHex8(h7);
82
+ };
83
+ //#endregion
84
+ //#region src/fingerprint.ts
85
+ /**
86
+ * `@velajs/errors/fingerprint` — zero-dependency, cross-runtime error grouping.
87
+ *
88
+ * Collapses noisy repeats of the same error into one stable "issue" identity: a
89
+ * 16-hex-character hash over the function path plus a *normalized* message, so a
90
+ * live in-flight error and one recomputed later from a persisted log row fold
91
+ * onto the same fingerprint. The machine `code` rides along as metadata and is
92
+ * deliberately excluded from the hash, so the redacted wire view produced by
93
+ * `toErrorBody` and the raw server-side error group together.
94
+ *
95
+ * The digest is a portable synchronous SHA-256 (see `./sha256`) truncated to 16
96
+ * hex chars — content-addressing only, never a security or MAC primitive.
97
+ *
98
+ * The message-normalization / grouping approach is inspired by
99
+ * `@superlog/fingerprint` (Apache-2.0); this is an independent implementation.
100
+ */
101
+ /**
102
+ * Heuristic generation of {@link bucketMessage}. Bump it whenever the
103
+ * normalizer's rules change: changed heuristics re-partition history (they may
104
+ * split one group into several or merge several into one), so a consumer that
105
+ * persists fingerprints stores this alongside each hash to know which
106
+ * generation produced it and when a recompute/backfill is due.
107
+ */
108
+ const FINGERPRINT_VERSION = 1;
109
+ /**
110
+ * Upper bound on the raw message length fed to the normalizer's regexes. A few
111
+ * of them (the email and long-run patterns especially) can backtrack
112
+ * super-linearly on a long delimiter-free run, and an error message can carry
113
+ * attacker-influenced input of unbounded size — so clamp first to keep the
114
+ * regex work bounded (a ReDoS guard). The final bucket is capped far below this
115
+ * anyway, so the clamp is transparent for any real message.
116
+ */
117
+ const MAX_INPUT_LENGTH = 1024;
118
+ /** Cap on the normalized bucket so one runaway message can't bloat the key. */
119
+ const MAX_BUCKET_LENGTH = 160;
120
+ /** Hex length of the truncated digest used as the grouping id. */
121
+ const HASH_LENGTH = 16;
122
+ /** Namespaces the hash so a fingerprint can't collide with another content hash. */
123
+ const SCHEME = "velajs.errors/fingerprint";
124
+ /**
125
+ * Ordered noise-stripping rules applied to a message before hashing. Each
126
+ * replaces a class of per-occurrence identifier with a stable placeholder, so
127
+ * two errors that differ only in their variable parts land in the same bucket.
128
+ * Order matters: broader shapes (urls, timestamps) run before the greedy
129
+ * numeric/id sweeps that would otherwise chew their digits.
130
+ */
131
+ const NOISE_RULES = [
132
+ [/https?:\/\/\S+/gi, "[url]"],
133
+ [/\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b/gi, "[email]"],
134
+ [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "[uuid]"],
135
+ [/\b\d{4}-\d{2}-\d{2}[t ][\d:.]+z?(?:[+-]\d{2}:?\d{2})?\b/gi, "[time]"],
136
+ [/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g, "[ip]"],
137
+ [/(^|\s)\/\S*/g, "$1[path]"],
138
+ [/\b[a-z]:\\[^\s]*/gi, "[path]"],
139
+ [/\b0x[0-9a-f]+\b/gi, "[hex]"],
140
+ [/\b[a-z0-9_]{20,}\b/gi, "[id]"],
141
+ [/\b\d+\b/g, "[num]"]
142
+ ];
143
+ /**
144
+ * Normalize an error message into its grouping bucket: strip per-occurrence
145
+ * noise (urls, uuids, ips, request/filesystem paths, long ids, numbers) and
146
+ * fold whitespace/case, so occurrences of the same logical error collapse to
147
+ * one bucket. Exported for direct testing of the normalization heuristics.
148
+ */
149
+ const bucketMessage = (message) => {
150
+ if (message.length === 0) return "";
151
+ let text = message.length > MAX_INPUT_LENGTH ? message.slice(0, MAX_INPUT_LENGTH) : message;
152
+ for (const [pattern, placeholder] of NOISE_RULES) text = text.replace(pattern, placeholder);
153
+ text = text.replace(/\s+/g, " ").trim().toLowerCase();
154
+ return text.length > MAX_BUCKET_LENGTH ? text.slice(0, MAX_BUCKET_LENGTH) : text;
155
+ };
156
+ /**
157
+ * Fold an error into its stable 16-hex-character grouping fingerprint. Pure and
158
+ * synchronous, safe to call per row when grouping a log page. The same
159
+ * `functionPath` and logically-equal `message` always yield the same hash
160
+ * regardless of the `code` supplied or per-occurrence noise in the message.
161
+ */
162
+ const fingerprintError = (input) => {
163
+ const source = input.functionPath.length > 0 ? input.functionPath : "unknown";
164
+ return sha256Hex(`${SCHEME}\n${source}\n${bucketMessage(input.message)}`).slice(0, HASH_LENGTH);
165
+ };
166
+ //#endregion
167
+ export { FINGERPRINT_VERSION, bucketMessage, fingerprintError, sha256Hex };
168
+
169
+ //# sourceMappingURL=fingerprint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.js","names":[],"sources":["../src/sha256.ts","../src/fingerprint.ts"],"sourcesContent":["/**\n * Portable, synchronous SHA-256 (FIPS 180-4) returning a lowercase hex digest.\n *\n * Implemented straight from the published standard so this file carries no\n * runtime dependency and produces byte-for-byte identical output on every\n * target Vela supports — browsers, the Cloudflare Workers (workerd) runtime,\n * and Node. Neither built-in alternative fits the fingerprinter:\n * - `node:crypto` (`createHash`) is absent in browsers and needs\n * `nodejs_compat` to load in workerd.\n * - `crypto.subtle.digest` is async, which is clumsy for folding error rows\n * into a group key one synchronous call at a time.\n *\n * SECURITY: this is a plain, non-constant-time digest meant ONLY for\n * content-addressing and grouping keys. Never use it as a MAC, a password hash,\n * or any other authentication or security primitive.\n */\n\nconst BLOCK_BYTES = 64;\n\n// Round constants K[0..63]: the first 32 bits of the fractional parts of the\n// cube roots of the first 64 primes (FIPS 180-4 §4.2.2).\nconst ROUND = Uint32Array.of(\n 0x428a2f98,\n 0x71374491,\n 0xb5c0fbcf,\n 0xe9b5dba5,\n 0x3956c25b,\n 0x59f111f1,\n 0x923f82a4,\n 0xab1c5ed5,\n 0xd807aa98,\n 0x12835b01,\n 0x243185be,\n 0x550c7dc3,\n 0x72be5d74,\n 0x80deb1fe,\n 0x9bdc06a7,\n 0xc19bf174,\n 0xe49b69c1,\n 0xefbe4786,\n 0x0fc19dc6,\n 0x240ca1cc,\n 0x2de92c6f,\n 0x4a7484aa,\n 0x5cb0a9dc,\n 0x76f988da,\n 0x983e5152,\n 0xa831c66d,\n 0xb00327c8,\n 0xbf597fc7,\n 0xc6e00bf3,\n 0xd5a79147,\n 0x06ca6351,\n 0x14292967,\n 0x27b70a85,\n 0x2e1b2138,\n 0x4d2c6dfc,\n 0x53380d13,\n 0x650a7354,\n 0x766a0abb,\n 0x81c2c92e,\n 0x92722c85,\n 0xa2bfe8a1,\n 0xa81a664b,\n 0xc24b8b70,\n 0xc76c51a3,\n 0xd192e819,\n 0xd6990624,\n 0xf40e3585,\n 0x106aa070,\n 0x19a4c116,\n 0x1e376c08,\n 0x2748774c,\n 0x34b0bcb5,\n 0x391c0cb3,\n 0x4ed8aa4a,\n 0x5b9cca4f,\n 0x682e6ff3,\n 0x748f82ee,\n 0x78a5636f,\n 0x84c87814,\n 0x8cc70208,\n 0x90befffa,\n 0xa4506ceb,\n 0xbef9a3f7,\n 0xc67178f2,\n);\n\nconst rotr = (word: number, bits: number): number => (word >>> bits) | (word << (32 - bits));\n\nconst toHex8 = (word: number): string => (word >>> 0).toString(16).padStart(8, '0');\n\nexport const sha256Hex = (input: string): string => {\n const message = new TextEncoder().encode(input);\n const bitLength = message.length * 8;\n\n // One 0x80 marker byte, an 8-byte length trailer, zero fill in between, all\n // rounded up to whole 64-byte blocks.\n const totalBytes = (Math.floor((message.length + 8) / BLOCK_BYTES) + 1) * BLOCK_BYTES;\n const padded = new Uint8Array(totalBytes);\n padded.set(message);\n padded[message.length] = 0x80;\n\n const frame = new DataView(padded.buffer);\n // Big-endian 64-bit bit length in the final 8 bytes. Fingerprint inputs are\n // short strings, so the high word stays zero in practice, but compute it\n // anyway for correctness.\n frame.setUint32(totalBytes - 8, Math.floor(bitLength / 0x1_0000_0000), false);\n frame.setUint32(totalBytes - 4, bitLength >>> 0, false);\n\n // Initial state H[0..7]: first 32 bits of the fractional parts of the square\n // roots of the first 8 primes (FIPS 180-4 §5.3.3).\n let h0 = 0x6a09e667;\n let h1 = 0xbb67ae85;\n let h2 = 0x3c6ef372;\n let h3 = 0xa54ff53a;\n let h4 = 0x510e527f;\n let h5 = 0x9b05688c;\n let h6 = 0x1f83d9ab;\n let h7 = 0x5be0cd19;\n\n const schedule = new Uint32Array(64);\n\n for (let base = 0; base < totalBytes; base += BLOCK_BYTES) {\n for (let t = 0; t < 16; t += 1) {\n schedule[t] = frame.getUint32(base + t * 4, false);\n }\n for (let t = 16; t < 64; t += 1) {\n const x = schedule[t - 15] as number;\n const y = schedule[t - 2] as number;\n const sigma0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);\n const sigma1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10);\n schedule[t] =\n ((schedule[t - 16] as number) + sigma0 + (schedule[t - 7] as number) + sigma1) >>> 0;\n }\n\n let a = h0;\n let b = h1;\n let c = h2;\n let d = h3;\n let e = h4;\n let f = h5;\n let g = h6;\n let h = h7;\n\n for (let t = 0; t < 64; t += 1) {\n const bigSigma1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);\n const choose = (e & f) ^ (~e & g);\n const t1 = (h + bigSigma1 + choose + (ROUND[t] as number) + (schedule[t] as number)) >>> 0;\n const bigSigma0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);\n const majority = (a & b) ^ (a & c) ^ (b & c);\n const t2 = (bigSigma0 + majority) >>> 0;\n\n h = g;\n g = f;\n f = e;\n e = (d + t1) >>> 0;\n d = c;\n c = b;\n b = a;\n a = (t1 + t2) >>> 0;\n }\n\n h0 = (h0 + a) >>> 0;\n h1 = (h1 + b) >>> 0;\n h2 = (h2 + c) >>> 0;\n h3 = (h3 + d) >>> 0;\n h4 = (h4 + e) >>> 0;\n h5 = (h5 + f) >>> 0;\n h6 = (h6 + g) >>> 0;\n h7 = (h7 + h) >>> 0;\n }\n\n return (\n toHex8(h0) +\n toHex8(h1) +\n toHex8(h2) +\n toHex8(h3) +\n toHex8(h4) +\n toHex8(h5) +\n toHex8(h6) +\n toHex8(h7)\n );\n};\n","/**\n * `@velajs/errors/fingerprint` — zero-dependency, cross-runtime error grouping.\n *\n * Collapses noisy repeats of the same error into one stable \"issue\" identity: a\n * 16-hex-character hash over the function path plus a *normalized* message, so a\n * live in-flight error and one recomputed later from a persisted log row fold\n * onto the same fingerprint. The machine `code` rides along as metadata and is\n * deliberately excluded from the hash, so the redacted wire view produced by\n * `toErrorBody` and the raw server-side error group together.\n *\n * The digest is a portable synchronous SHA-256 (see `./sha256`) truncated to 16\n * hex chars — content-addressing only, never a security or MAC primitive.\n *\n * The message-normalization / grouping approach is inspired by\n * `@superlog/fingerprint` (Apache-2.0); this is an independent implementation.\n */\nimport { sha256Hex } from './sha256';\n\n/**\n * Heuristic generation of {@link bucketMessage}. Bump it whenever the\n * normalizer's rules change: changed heuristics re-partition history (they may\n * split one group into several or merge several into one), so a consumer that\n * persists fingerprints stores this alongside each hash to know which\n * generation produced it and when a recompute/backfill is due.\n */\nexport const FINGERPRINT_VERSION: number = 1;\n\n/**\n * Upper bound on the raw message length fed to the normalizer's regexes. A few\n * of them (the email and long-run patterns especially) can backtrack\n * super-linearly on a long delimiter-free run, and an error message can carry\n * attacker-influenced input of unbounded size — so clamp first to keep the\n * regex work bounded (a ReDoS guard). The final bucket is capped far below this\n * anyway, so the clamp is transparent for any real message.\n */\nconst MAX_INPUT_LENGTH = 1024;\n\n/** Cap on the normalized bucket so one runaway message can't bloat the key. */\nconst MAX_BUCKET_LENGTH = 160;\n\n/** Hex length of the truncated digest used as the grouping id. */\nconst HASH_LENGTH = 16;\n\n/** Namespaces the hash so a fingerprint can't collide with another content hash. */\nconst SCHEME = 'velajs.errors/fingerprint';\n\n/**\n * Ordered noise-stripping rules applied to a message before hashing. Each\n * replaces a class of per-occurrence identifier with a stable placeholder, so\n * two errors that differ only in their variable parts land in the same bucket.\n * Order matters: broader shapes (urls, timestamps) run before the greedy\n * numeric/id sweeps that would otherwise chew their digits.\n */\nconst NOISE_RULES: readonly (readonly [RegExp, string])[] = [\n // Whole URLs (before path/number rules can nibble their insides).\n [/https?:\\/\\/\\S+/gi, '[url]'],\n // Email addresses.\n [/\\b[\\w.+-]+@[\\w.-]+\\.[a-z]{2,}\\b/gi, '[email]'],\n // RFC-4122 UUIDs.\n [/\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b/gi, '[uuid]'],\n // ISO-8601-ish timestamps (before the ip/number rules touch their digits).\n [/\\b\\d{4}-\\d{2}-\\d{2}[t ][\\d:.]+z?(?:[+-]\\d{2}:?\\d{2})?\\b/gi, '[time]'],\n // IPv4 addresses with an optional port.\n [/\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d+)?\\b/g, '[ip]'],\n // Unix-style request/filesystem paths at a token boundary — keeps in-word\n // slashes (client/server, and/or) intact while folding a scanner's probed\n // paths (/wp-admin, /.env, /.git/config) onto one placeholder.\n [/(^|\\s)\\/\\S*/g, '$1[path]'],\n // Windows drive-letter paths.\n [/\\b[a-z]:\\\\[^\\s]*/gi, '[path]'],\n // Hex literals (0x…).\n [/\\b0x[0-9a-f]+\\b/gi, '[hex]'],\n // Long opaque ids: tokens, hashes, base-ish ids. Threshold high enough to\n // spare ordinary words while catching machine identifiers.\n [/\\b[a-z0-9_]{20,}\\b/gi, '[id]'],\n // Any remaining bare integer.\n [/\\b\\d+\\b/g, '[num]'],\n];\n\n/**\n * Normalize an error message into its grouping bucket: strip per-occurrence\n * noise (urls, uuids, ips, request/filesystem paths, long ids, numbers) and\n * fold whitespace/case, so occurrences of the same logical error collapse to\n * one bucket. Exported for direct testing of the normalization heuristics.\n */\nexport const bucketMessage = (message: string): string => {\n if (message.length === 0) {\n return '';\n }\n\n let text = message.length > MAX_INPUT_LENGTH ? message.slice(0, MAX_INPUT_LENGTH) : message;\n\n for (const [pattern, placeholder] of NOISE_RULES) {\n text = text.replace(pattern, placeholder);\n }\n\n text = text.replace(/\\s+/g, ' ').trim().toLowerCase();\n\n return text.length > MAX_BUCKET_LENGTH ? text.slice(0, MAX_BUCKET_LENGTH) : text;\n};\n\n/** Everything a fingerprint source can supply. */\nexport interface ErrorFingerprintInput {\n /**\n * What raised the error — the invoked function/route path, e.g.\n * `messages:list`. Part of the grouping key.\n */\n functionPath: string;\n /** Human-readable message (may embed user input); normalized into the key. */\n message: string;\n /**\n * Machine error code, when known. Pure metadata: **never folded into the\n * hash**, so the redacted wire error (which may rewrite or drop the code) and\n * the raw server-side error still produce the same fingerprint.\n */\n code?: string;\n}\n\n/**\n * Fold an error into its stable 16-hex-character grouping fingerprint. Pure and\n * synchronous, safe to call per row when grouping a log page. The same\n * `functionPath` and logically-equal `message` always yield the same hash\n * regardless of the `code` supplied or per-occurrence noise in the message.\n */\nexport const fingerprintError = (input: ErrorFingerprintInput): string => {\n const source = input.functionPath.length > 0 ? input.functionPath : 'unknown';\n const canonical = `${SCHEME}\\n${source}\\n${bucketMessage(input.message)}`;\n return sha256Hex(canonical).slice(0, HASH_LENGTH);\n};\n\nexport { sha256Hex } from './sha256';\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,MAAM,cAAc;AAIpB,MAAM,QAAQ,YAAY,GACxB,YACA,YACA,YACA,YACA,WACA,YACA,YACA,YACA,YACA,WACA,WACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,WACA,WACA,WACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,WACA,WACA,WACA,WACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,WACA,WACA,WACA,WACA,WACA,WACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,YACA,UACF;AAEA,MAAM,QAAQ,MAAc,SAA0B,SAAS,OAAS,QAAS,KAAK;AAEtF,MAAM,UAAU,UAA0B,SAAS,EAAA,CAAG,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG;AAElF,MAAa,aAAa,UAA0B;CAClD,MAAM,UAAU,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK;CAC9C,MAAM,YAAY,QAAQ,SAAS;CAInC,MAAM,cAAc,KAAK,OAAO,QAAQ,SAAS,KAAK,WAAW,IAAI,KAAK;CAC1E,MAAM,SAAS,IAAI,WAAW,UAAU;CACxC,OAAO,IAAI,OAAO;CAClB,OAAO,QAAQ,UAAU;CAEzB,MAAM,QAAQ,IAAI,SAAS,OAAO,MAAM;CAIxC,MAAM,UAAU,aAAa,GAAG,KAAK,MAAM,YAAY,UAAa,GAAG,KAAK;CAC5E,MAAM,UAAU,aAAa,GAAG,cAAc,GAAG,KAAK;CAItD,IAAI,KAAK;CACT,IAAI,KAAK;CACT,IAAI,KAAK;CACT,IAAI,KAAK;CACT,IAAI,KAAK;CACT,IAAI,KAAK;CACT,IAAI,KAAK;CACT,IAAI,KAAK;CAET,MAAM,2BAAW,IAAI,YAAY,EAAE;CAEnC,KAAK,IAAI,OAAO,GAAG,OAAO,YAAY,QAAQ,aAAa;EACzD,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,KAAK,GAC3B,SAAS,KAAK,MAAM,UAAU,OAAO,IAAI,GAAG,KAAK;EAEnD,KAAK,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK,GAAG;GAC/B,MAAM,IAAI,SAAS,IAAI;GACvB,MAAM,IAAI,SAAS,IAAI;GACvB,MAAM,SAAS,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,EAAE,IAAK,MAAM;GACjD,MAAM,SAAS,KAAK,GAAG,EAAE,IAAI,KAAK,GAAG,EAAE,IAAK,MAAM;GAClD,SAAS,KACL,SAAS,IAAI,MAAiB,SAAU,SAAS,IAAI,KAAgB,WAAY;EACvF;EAEA,IAAI,IAAI;EACR,IAAI,IAAI;EACR,IAAI,IAAI;EACR,IAAI,IAAI;EACR,IAAI,IAAI;EACR,IAAI,IAAI;EACR,IAAI,IAAI;EACR,IAAI,IAAI;EAER,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,KAAK,GAAG;GAC9B,MAAM,YAAY,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,EAAE,IAAI,KAAK,GAAG,EAAE;GACvD,MAAM,SAAU,IAAI,IAAM,CAAC,IAAI;GAC/B,MAAM,KAAM,IAAI,YAAY,SAAU,MAAM,KAAiB,SAAS,OAAmB;GAGzF,MAAM,MAFY,KAAK,GAAG,CAAC,IAAI,KAAK,GAAG,EAAE,IAAI,KAAK,GAAG,EAAE,MACrC,IAAI,IAAM,IAAI,IAAM,IAAI,OACJ;GAEtC,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,IAAK,IAAI,OAAQ;GACjB,IAAI;GACJ,IAAI;GACJ,IAAI;GACJ,IAAK,KAAK,OAAQ;EACpB;EAEA,KAAM,KAAK,MAAO;EAClB,KAAM,KAAK,MAAO;EAClB,KAAM,KAAK,MAAO;EAClB,KAAM,KAAK,MAAO;EAClB,KAAM,KAAK,MAAO;EAClB,KAAM,KAAK,MAAO;EAClB,KAAM,KAAK,MAAO;EAClB,KAAM,KAAK,MAAO;CACpB;CAEA,OACE,OAAO,EAAE,IACT,OAAO,EAAE,IACT,OAAO,EAAE,IACT,OAAO,EAAE,IACT,OAAO,EAAE,IACT,OAAO,EAAE,IACT,OAAO,EAAE,IACT,OAAO,EAAE;AAEb;;;;;;;;;;;;;;;;;;;;;;;;;;AC9JA,MAAa,sBAA8B;;;;;;;;;AAU3C,MAAM,mBAAmB;;AAGzB,MAAM,oBAAoB;;AAG1B,MAAM,cAAc;;AAGpB,MAAM,SAAS;;;;;;;;AASf,MAAM,cAAsD;CAE1D,CAAC,oBAAoB,OAAO;CAE5B,CAAC,qCAAqC,SAAS;CAE/C,CAAC,sEAAsE,QAAQ;CAE/E,CAAC,6DAA6D,QAAQ;CAEtE,CAAC,yCAAyC,MAAM;CAIhD,CAAC,gBAAgB,UAAU;CAE3B,CAAC,sBAAsB,QAAQ;CAE/B,CAAC,qBAAqB,OAAO;CAG7B,CAAC,wBAAwB,MAAM;CAE/B,CAAC,YAAY,OAAO;AACtB;;;;;;;AAQA,MAAa,iBAAiB,YAA4B;CACxD,IAAI,QAAQ,WAAW,GACrB,OAAO;CAGT,IAAI,OAAO,QAAQ,SAAS,mBAAmB,QAAQ,MAAM,GAAG,gBAAgB,IAAI;CAEpF,KAAK,MAAM,CAAC,SAAS,gBAAgB,aACnC,OAAO,KAAK,QAAQ,SAAS,WAAW;CAG1C,OAAO,KAAK,QAAQ,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY;CAEpD,OAAO,KAAK,SAAS,oBAAoB,KAAK,MAAM,GAAG,iBAAiB,IAAI;AAC9E;;;;;;;AAyBA,MAAa,oBAAoB,UAAyC;CACxE,MAAM,SAAS,MAAM,aAAa,SAAS,IAAI,MAAM,eAAe;CAEpE,OAAO,UAAU,GADI,OAAO,IAAI,OAAO,IAAI,cAAc,MAAM,OAAO,GAC5C,CAAC,CAAC,MAAM,GAAG,WAAW;AAClD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/errors",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Unified error layer for Vela: branded VelaError, composable error catalogs, and the single toErrorBody wire-redaction seam",
5
5
  "keywords": [
6
6
  "error-catalog",
@@ -33,6 +33,10 @@
33
33
  ".": {
34
34
  "types": "./dist/index.d.ts",
35
35
  "import": "./dist/index.js"
36
+ },
37
+ "./fingerprint": {
38
+ "types": "./dist/fingerprint.d.ts",
39
+ "import": "./dist/fingerprint.js"
36
40
  }
37
41
  },
38
42
  "devDependencies": {