har2sdk 0.6.4 → 0.6.5

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.5
4
+
5
+ Regexes that CodeQL flagged as polynomial, and one that was exponential, are replaced with linear code. A crafted capture could make the CLI, the MCP server or the mock take seconds or minutes; nothing leaked and no code ran. Output is unchanged: every replacement is compared with the code it replaces on 3,000 generated inputs, and `test/fixtures/demo-snapshot.json` is unchanged.
6
+
7
+ **Slow inputs**
8
+ - GraphQL documents: spaces before the last character of a document (`withoutComments`: 100 KB took 17 s) and a fragment head with no `{` after it (`withoutFragments`: 100 KB, about 5 s, also from a GraphQL request to the running mock) (#7).
9
+ - GraphQL strings: a string that doesn't close, made of escaped quotes (`"\"\"\"…`), was scanned again from every escaped quote, in `model.ts` and `leak.ts` (a 200 KB capture: 27 s) (#10, #11).
10
+ - A GraphQL list argument that doesn't close (`ids: [1 1 1 …`) was exponential in `liftGraphQLLiterals`: 60 bytes took 29 s, and each further item doubled it (#11).
11
+ - The leak guard's tokens: trimming `.`, `-` and `~` from a token scanned a run of them again from each character (`scanText` and `scrubText` on a 200 KB token: about 25 s) (#10).
12
+ - Replaced as a precaution: trailing slashes (`plan.ts`, `openapi.ts`), percent-encoded runs (`leak.ts`) and the JSON fence in a pasted reply (`annotations.ts`) were quadratic in isolation but not reached slowly from any capture we built; the relative-link cut (`redact.ts`) was flagged but was already linear (#7).
13
+
14
+ **CI**
15
+ - `ci.yml` gives its token read-only access (`permissions: contents: read`) (#7).
16
+
17
+ **Dependencies**
18
+ - fast-uri 3.1.8 → 3.1.9 (#8) and body-parser 2.3.0 → 2.4.0 (#9): lockfile updates to dependencies of `@modelcontextprotocol/sdk`.
19
+
20
+ **Tests**
21
+ - `test/review9.test.ts` (G1–G7) and `test/review10.test.ts` (H1–H12): each replacement has an attack input with a time limit and a generated comparison with the original on 3,000 inputs. The replacements are in `src/linear.ts`.
22
+
3
23
  ## 0.6.4
4
24
 
5
25
  A privacy fix in the mock's fake data, an Auth section in the README, and tests that pin both.
package/README.md CHANGED
@@ -121,7 +121,7 @@ HAR files contain live session cookies, tokens and often other people's data. ha
121
121
 
122
122
  The guard is a heuristic with evidence from the capture, not a proof: a value with **no evidence anywhere** (a word used once as a path segment that nothing else names) can't be recognised, and one-word names of things (a category `"Books"`) are treated as vocabulary. Look over `har2sdk inspect` before publishing generated code. Details, and exactly what is and isn't collected: [SECURITY.md](SECURITY.md).
123
123
 
124
- It's tested with the inputs of eight review rounds (`test/review*.test.ts`) and a generated privacy test that builds a random person and hides them in 30 kinds of places in a random capture, then checks every output, including a real MCP session, the CLI's `inspect --json` and every mock response (30 seeds in CI; the long run of 1,000 seeds passes).
124
+ It's tested with the inputs of ten review rounds (`test/review*.test.ts`) and a generated privacy test that builds a random person and hides them in 30 kinds of places in a random capture, then checks every output, including a real MCP session, the CLI's `inspect --json` and every mock response (30 seeds in CI; the long run of 1,000 seeds passes).
125
125
 
126
126
  **Treat `.har` files like passwords.** **Use it responsibly:** reverse-engineering an API you don't own can break its terms of use. Good uses: your own apps, legacy internal systems, services you're allowed to integrate with. Please don't publish SDKs for other companies' services.
127
127
 
@@ -17,6 +17,7 @@
17
17
  * }
18
18
  */
19
19
  import { z } from "zod";
20
+ import { fencedBody } from "./linear.js";
20
21
  import { RESERVED_METHOD_NAMES } from "./naming.js";
21
22
  import { tokenText } from "./template.js";
22
23
  import { isIdentifier, pascal, safeIdentifier } from "./words.js";
@@ -35,8 +36,8 @@ export const ANNOTATIONS_SCHEMA = z.object({
35
36
  });
36
37
  export function parseAnnotations(text) {
37
38
  // Accept a chat reply as-is: strip prose and ```json fences around the object.
38
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
39
- const body = fenced ? fenced[1] : text.slice(Math.max(0, text.indexOf("{")), text.lastIndexOf("}") + 1);
39
+ const fenced = fencedBody(text);
40
+ const body = fenced !== undefined ? fenced : text.slice(Math.max(0, text.indexOf("{")), text.lastIndexOf("}") + 1);
40
41
  const data = JSON.parse(body);
41
42
  if (typeof data !== "object" || data === null || Array.isArray(data))
42
43
  throw new Error("Annotations must be a JSON object");
package/dist/codegen.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * runtime dependencies (uses the global `fetch`, Node 18+ or any browser).
4
4
  */
5
5
  import type { Plan, PlannedOp } from "./plan.js";
6
- export declare const VERSION = "0.6.4";
6
+ export declare const VERSION = "0.6.5";
7
7
  export declare function generateTypeScript(plan: Plan): string;
8
8
  /** Just the type declarations (used for prompts and MCP responses). */
9
9
  export declare function generateTypesOnly(plan: Plan, onlyOps?: PlannedOp[]): string;
package/dist/codegen.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { printTs, printTsInterface } from "./types.js";
2
2
  import { propertyKey } from "./words.js";
3
- export const VERSION = "0.6.4";
3
+ export const VERSION = "0.6.5";
4
4
  export function generateTypeScript(plan) {
5
5
  const { model, clientName, ops } = plan;
6
6
  const out = [];
package/dist/leak.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isObject } from "./har.js";
2
+ import { graphqlLiteralMatches, quotedStrings, replacePercentRuns, trimDotsDashesTildes } from "./linear.js";
2
3
  import { isContactName, isDataKey, isEmail, isPersonalName, isSecretValue, isSensitiveName, Scrubber } from "./redact.js";
3
4
  import { singular, words } from "./words.js";
4
5
  export function emptyTaint() {
@@ -522,13 +523,13 @@ function mapJsonish(text, fn) {
522
523
  }
523
524
  /** String literals (block strings too) and long numbers inside a GraphQL document. */
524
525
  function graphqlLiterals(doc, c) {
525
- for (const m of doc.matchAll(/#[^\n]*|(?:\b([A-Za-z_]\w*)\s*:\s*)?(?:"""([\s\S]*?)"""|"((?:[^"\\\n]|\\.)*)"|(?<![\w$.])(-?\d{6,})(?![\w.]))/g)) {
526
+ for (const m of graphqlLiteralMatches(doc)) {
526
527
  // `login: "mdemir"`: the argument name says what the value is.
527
528
  const identity = !!m[1] && (isIdentityKey(m[1]) || isIdKey(m[1]));
528
529
  if (m[0].startsWith("#")) {
529
530
  // Comments stay in the document: anything data-like in them is data.
530
- for (const q of m[0].matchAll(/"((?:[^"\\\n]|\\.)*)"/g))
531
- c.addAlways(q[1]);
531
+ for (const [start, end] of quotedStrings(m[0]))
532
+ c.addAlways(m[0].slice(start + 1, end - 1));
532
533
  for (const w of m[0].match(/[^\s"'<>,;()[\]{}]{4,}/g) ?? [])
533
534
  if (isDataLike(w))
534
535
  c.add(w);
@@ -623,7 +624,7 @@ function index(text, taint) {
623
624
  for (const raw of text.split(TOKEN_SPLIT)) {
624
625
  if (!raw)
625
626
  continue;
626
- const t = raw.replace(/^[.\-~]+|[.\-~]+$/g, "").toLowerCase();
627
+ const t = trimDotsDashesTildes(raw).toLowerCase();
627
628
  if (!t || tokens.has(t))
628
629
  continue;
629
630
  tokens.add(t);
@@ -694,8 +695,8 @@ export function scanText(input, taint, where, ignore) {
694
695
  if (!parts.length)
695
696
  continue;
696
697
  if (parts.length === 1
697
- ? (tokens.has(parts[0].replace(/^[.\-~]+|[.\-~]+$/g, "")) || inPieces(taint, pieces, v)) && lower.includes(v)
698
- : parts.every((p) => tokens.has(p.replace(/^[.\-~]+|[.\-~]+$/g, ""))) && lower.includes(v)) {
698
+ ? (tokens.has(trimDotsDashesTildes(parts[0])) || inPieces(taint, pieces, v)) && lower.includes(v)
699
+ : parts.every((p) => tokens.has(trimDotsDashesTildes(p))) && lower.includes(v)) {
699
700
  found.push({ where, preview: maskPreview(v), kind: "value" });
700
701
  hit.add(aliasOf(v));
701
702
  }
@@ -735,7 +736,7 @@ export function scrubText(text, taint) {
735
736
  if (taint.allowed.has(v))
736
737
  continue;
737
738
  const parts = v.split(TOKEN_SPLIT).filter(Boolean);
738
- if (parts.length && (parts.every((p) => tokens.has(p.replace(/^[.\-~]+|[.\-~]+$/g, ""))) || (parts.length === 1 && inPieces(taint, pieces, v))) && lower.includes(v))
739
+ if (parts.length && (parts.every((p) => tokens.has(trimDotsDashesTildes(p))) || (parts.length === 1 && inPieces(taint, pieces, v))) && lower.includes(v))
739
740
  findings.add(v);
740
741
  }
741
742
  for (const t of tokens)
@@ -762,7 +763,7 @@ export function scrubText(text, taint) {
762
763
  return out;
763
764
  };
764
765
  // Percent-encoded tokens are matched in their decoded form ("G%C3%BCl%C5%9Fen").
765
- return redact(text).replace(/[^\s"'<>`|()[\]{},;]*%[0-9A-Fa-f]{2}[^\s"'<>`|()[\]{},;]*/g, (tok) => {
766
+ return replacePercentRuns(redact(text), (tok) => {
766
767
  let d;
767
768
  try {
768
769
  d = decodeURIComponent(tok);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Linear-time replacements for regexes that CodeQL flagged as polynomial. All
3
+ * but withoutQueryOrHash backtracked quadratically on crafted input. Each
4
+ * function returns exactly what the regex in its comment returns;
5
+ * test/review9.test.ts and test/review10.test.ts compare them on generated input.
6
+ */
7
+ /** `s.replace(/[ \t]+$/gm, "")`: spaces and tabs at the end of every line. */
8
+ export declare function trimLineEnds(s: string): string;
9
+ /** `s.replace(/\/+$/, "")`: trailing slashes. */
10
+ export declare function trimTrailingSlashes(s: string): string;
11
+ /**
12
+ * `s.replace(/[?#].*$/, "")` for a string without line terminators (the caller
13
+ * has checked `/^\/[^\s]*$/`): everything from the first `?` or `#`.
14
+ */
15
+ export declare function withoutQueryOrHash(s: string): string;
16
+ /** `text.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]`: the body of the first fenced block. */
17
+ export declare function fencedBody(text: string): string | undefined;
18
+ /**
19
+ * `text.replace(/[^\s"'<>`|()[\]{},;]*%[0-9A-Fa-f]{2}[^\s"'<>`|()[\]{},;]*\/g, fn)`:
20
+ * every run of token characters that holds a percent escape. The regex's
21
+ * leftmost match always spans a whole run, so runs are matched once each.
22
+ */
23
+ export declare function replacePercentRuns(text: string, fn: (token: string) => string): string;
24
+ /**
25
+ * `/(^|\})\s*fragment\s+[A-Za-z_]\w*\s+on\s+[A-Za-z_]\w*[^{]*\{/.exec(s)` as
26
+ * `{ index, prefix, end }`: where the match starts, its first group, and the
27
+ * index after its `{`. The regex's tail `[^{]*\{` ends at the first `{` after
28
+ * the type condition; if there is none, no later start can match either.
29
+ */
30
+ export declare function findFragmentHead(s: string): {
31
+ index: number;
32
+ prefix: string;
33
+ end: number;
34
+ } | undefined;
35
+ /** `s.replace(/^[.\-~]+|[.\-~]+$/g, "")`: dots, dashes and tildes at either end. */
36
+ export declare function trimDotsDashesTildes(s: string): string;
37
+ /**
38
+ * The `/"(?:[^"\\\n]|\\.)*"/g` matches in `s` (`/"(?:[^"\\]|\\.)*"/g` when
39
+ * `newline` is true), as `[start, end]`.
40
+ */
41
+ export declare function quotedStrings(s: string, newline?: boolean): Generator<[number, number]>;
42
+ /** `s.replace(/"(?:[^"\\\n]|\\.)*"/g, fn)` (`/"(?:[^"\\]|\\.)*"/g` when `newline` is true). */
43
+ export declare function replaceQuoted(s: string, fn: (quoted: string) => string, newline?: boolean): string;
44
+ /**
45
+ * `s.replace(/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|#[^\n\r]*\/g, (m) => (m.startsWith("#") ? "" : m))`:
46
+ * a GraphQL document without its comments.
47
+ */
48
+ export declare function removeHashComments(s: string): string;
49
+ /**
50
+ * The matches of
51
+ * `/([{}()])|(\.\.\.\s*[A-Za-z_]\w*)|([A-Za-z_]\w*)(\s*:\s*([A-Za-z_]\w*))?|"(?:[^"\\]|\\.)*"/g`
52
+ * from `start`: GraphQL punctuation, spreads, fields with their aliases, and strings.
53
+ */
54
+ export declare function graphqlTokens(doc: string, start: number): Generator<RegExpExecArray>;
55
+ /** A match of {@link graphqlLiteralMatches}: the whole match and its four groups. */
56
+ export type GraphqlLiteral = [string, string | undefined, string | undefined, string | undefined, string | undefined] & {
57
+ index: number;
58
+ };
59
+ /**
60
+ * The matches of
61
+ * `/#[^\n]*|(?:\b([A-Za-z_]\w*)\s*:\s*)?(?:"""([\s\S]*?)"""|"((?:[^"\\\n]|\\.)*)"|(?<![\w$.])(-?\d{6,})(?![\w.]))/g`:
62
+ * comments, and string and long number literals with the argument name before them.
63
+ */
64
+ export declare function graphqlLiteralMatches(doc: string): Generator<GraphqlLiteral>;
65
+ /** What `\s` matches (without the u flag). */
66
+ export declare function isSpaceCode(c: number): boolean;
67
+ /** A match of {@link graphqlLiftMatches}: the whole match, the argument name, the separator and the literal. */
68
+ export type GraphqlLift = [string, string | undefined, string | undefined, string | undefined] & {
69
+ index: number;
70
+ };
71
+ /**
72
+ * The matches of the regex model.ts used to read literals with (GQL_LIFT),
73
+ * `/#[^\n]*|"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|\b([A-Za-z_][A-Za-z0-9_]*)(\s*:\s*)(STRING|NUMBER|\[\s*(?:(?:STRING|NUMBER)\s*,?\s*)+\])/g`
74
+ * with STRING = `"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"(?!")` and NUMBER = `-?\d+(?![\w.])`:
75
+ * comments, strings, and arguments with a literal value.
76
+ */
77
+ export declare function graphqlLiftMatches(doc: string): Generator<GraphqlLift>;
78
+ /**
79
+ * The matches of `/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"(?!")|-?\d+(?![\w.])/g`:
80
+ * the items of a list literal, as `[start, end]`.
81
+ */
82
+ export declare function graphqlListItems(lit: string): Generator<[number, number]>;
package/dist/linear.js ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Linear-time replacements for regexes that CodeQL flagged as polynomial. All
3
+ * but withoutQueryOrHash backtracked quadratically on crafted input. Each
4
+ * function returns exactly what the regex in its comment returns;
5
+ * test/review9.test.ts and test/review10.test.ts compare them on generated input.
6
+ */
7
+ /** `s.replace(/[ \t]+$/gm, "")`: spaces and tabs at the end of every line. */
8
+ export function trimLineEnds(s) {
9
+ // `$` with the m flag matches before \n, \r, \u2028, \u2029 and at the end.
10
+ return s.replace(/[^\n\r\u2028\u2029]+/g, (line) => {
11
+ let end = line.length;
12
+ while (end > 0 && (line.charCodeAt(end - 1) === 32 || line.charCodeAt(end - 1) === 9))
13
+ end--;
14
+ return end === line.length ? line : line.slice(0, end);
15
+ });
16
+ }
17
+ /** `s.replace(/\/+$/, "")`: trailing slashes. */
18
+ export function trimTrailingSlashes(s) {
19
+ let end = s.length;
20
+ while (end > 0 && s.charCodeAt(end - 1) === 47)
21
+ end--;
22
+ return end === s.length ? s : s.slice(0, end);
23
+ }
24
+ /**
25
+ * `s.replace(/[?#].*$/, "")` for a string without line terminators (the caller
26
+ * has checked `/^\/[^\s]*$/`): everything from the first `?` or `#`.
27
+ */
28
+ export function withoutQueryOrHash(s) {
29
+ const cut = s.search(/[?#]/);
30
+ return cut < 0 ? s : s.slice(0, cut);
31
+ }
32
+ /** `text.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1]`: the body of the first fenced block. */
33
+ export function fencedBody(text) {
34
+ const open = text.indexOf("```");
35
+ if (open < 0)
36
+ return undefined;
37
+ let start = open + 3;
38
+ if (text.startsWith("json", start))
39
+ start += 4;
40
+ while (start < text.length && /\s/.test(text[start]))
41
+ start++;
42
+ // "json" and whitespace hold no backtick, so no closing fence can start
43
+ // before `start`; and if none follows the first opening, none follows a later one.
44
+ const close = text.indexOf("```", start);
45
+ return close < 0 ? undefined : text.slice(start, close);
46
+ }
47
+ /**
48
+ * `text.replace(/[^\s"'<>`|()[\]{},;]*%[0-9A-Fa-f]{2}[^\s"'<>`|()[\]{},;]*\/g, fn)`:
49
+ * every run of token characters that holds a percent escape. The regex's
50
+ * leftmost match always spans a whole run, so runs are matched once each.
51
+ */
52
+ export function replacePercentRuns(text, fn) {
53
+ return text.replace(/[^\s"'<>`|()[\]{},;]+/g, (run) => (/%[0-9A-Fa-f]{2}/.test(run) ? fn(run) : run));
54
+ }
55
+ const FRAGMENT_HEAD = /(^|\})\s*fragment\s+[A-Za-z_]\w*\s+on\s+(?=([A-Za-z_]\w*))\2/;
56
+ /**
57
+ * `/(^|\})\s*fragment\s+[A-Za-z_]\w*\s+on\s+[A-Za-z_]\w*[^{]*\{/.exec(s)` as
58
+ * `{ index, prefix, end }`: where the match starts, its first group, and the
59
+ * index after its `{`. The regex's tail `[^{]*\{` ends at the first `{` after
60
+ * the type condition; if there is none, no later start can match either.
61
+ */
62
+ export function findFragmentHead(s) {
63
+ const m = FRAGMENT_HEAD.exec(s);
64
+ if (!m)
65
+ return undefined;
66
+ const brace = s.indexOf("{", m.index + m[0].length);
67
+ return brace < 0 ? undefined : { index: m.index, prefix: m[1], end: brace + 1 };
68
+ }
69
+ /** `s.replace(/^[.\-~]+|[.\-~]+$/g, "")`: dots, dashes and tildes at either end. */
70
+ export function trimDotsDashesTildes(s) {
71
+ const edge = (c) => c === 46 || c === 45 || c === 126;
72
+ let start = 0;
73
+ while (start < s.length && edge(s.charCodeAt(start)))
74
+ start++;
75
+ let end = s.length;
76
+ while (end > start && edge(s.charCodeAt(end - 1)))
77
+ end--;
78
+ return start === 0 && end === s.length ? s : s.slice(start, end);
79
+ }
80
+ /** What `.` matches outside the s flag: anything but a line terminator. */
81
+ function isDot(c) {
82
+ return c !== 10 && c !== 13 && c !== 0x2028 && c !== 0x2029;
83
+ }
84
+ /**
85
+ * Where `/"(?:[^"\\]|\\.)*"/y` (or `/"(?:[^"\\\n]|\\.)*"/y` when `newline` is
86
+ * false) stops when started at the `"` at `q`: `{ end }` after the closing
87
+ * quote, or `{ fail }` at the backslash or newline it can't get past.
88
+ *
89
+ * A string that fails at `fail` also fails from every `"` between `q` and
90
+ * `fail`: each of those was escaped, so a scan from it continues exactly as
91
+ * the scan from `q` did. Callers remember `fail` and skip those quotes, which
92
+ * the regexes tried one by one (quadratic on `"\"\"\"…`).
93
+ */
94
+ function quotedEnd(s, q, newline) {
95
+ let i = q + 1;
96
+ while (i < s.length) {
97
+ const c = s.charCodeAt(i);
98
+ if (c === 34)
99
+ return { end: i + 1 };
100
+ if (c === 92) {
101
+ if (i + 1 < s.length && isDot(s.charCodeAt(i + 1)))
102
+ i += 2;
103
+ else
104
+ return { fail: i };
105
+ }
106
+ else if (c === 10 && !newline)
107
+ return { fail: i };
108
+ else
109
+ i++;
110
+ }
111
+ return { fail: i };
112
+ }
113
+ /**
114
+ * The `/"(?:[^"\\\n]|\\.)*"/g` matches in `s` (`/"(?:[^"\\]|\\.)*"/g` when
115
+ * `newline` is true), as `[start, end]`.
116
+ */
117
+ export function* quotedStrings(s, newline = false) {
118
+ let unclosedUntil = -1;
119
+ for (let q = s.indexOf('"'); q >= 0;) {
120
+ if (q < unclosedUntil) {
121
+ q = s.indexOf('"', q + 1);
122
+ continue;
123
+ }
124
+ const r = quotedEnd(s, q, newline);
125
+ if ("end" in r) {
126
+ yield [q, r.end];
127
+ q = s.indexOf('"', r.end);
128
+ }
129
+ else {
130
+ unclosedUntil = r.fail;
131
+ q = s.indexOf('"', q + 1);
132
+ }
133
+ }
134
+ }
135
+ /** `s.replace(/"(?:[^"\\\n]|\\.)*"/g, fn)` (`/"(?:[^"\\]|\\.)*"/g` when `newline` is true). */
136
+ export function replaceQuoted(s, fn, newline = false) {
137
+ let out = "";
138
+ let last = 0;
139
+ for (const [start, end] of quotedStrings(s, newline)) {
140
+ out += s.slice(last, start) + fn(s.slice(start, end));
141
+ last = end;
142
+ }
143
+ return last === 0 ? s : out + s.slice(last);
144
+ }
145
+ /**
146
+ * `s.replace(/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|#[^\n\r]*\/g, (m) => (m.startsWith("#") ? "" : m))`:
147
+ * a GraphQL document without its comments.
148
+ */
149
+ export function removeHashComments(s) {
150
+ const re = /"""|"|#[^\n\r]*/g;
151
+ let out = "";
152
+ let last = 0;
153
+ let unclosedUntil = -1;
154
+ let noBlockEnd = false;
155
+ let m;
156
+ while ((m = re.exec(s))) {
157
+ const q = m.index;
158
+ if (m[0][0] === "#") {
159
+ out += s.slice(last, q);
160
+ last = re.lastIndex;
161
+ continue;
162
+ }
163
+ if (m[0] === '"""' && !noBlockEnd) {
164
+ const close = s.indexOf('"""', q + 3);
165
+ if (close >= 0) {
166
+ re.lastIndex = close + 3;
167
+ continue;
168
+ }
169
+ // No `"""` after this one, so none after a later one either.
170
+ noBlockEnd = true;
171
+ }
172
+ // A `"` inside a string that doesn't close (see quotedEnd).
173
+ if (q < unclosedUntil) {
174
+ re.lastIndex = q + 1;
175
+ continue;
176
+ }
177
+ const r = quotedEnd(s, q, false);
178
+ if ("end" in r)
179
+ re.lastIndex = r.end;
180
+ else {
181
+ unclosedUntil = r.fail;
182
+ re.lastIndex = q + 1;
183
+ }
184
+ }
185
+ return last === 0 ? s : out + s.slice(last);
186
+ }
187
+ const GQL_TOKEN = /([{}()])|(\.\.\.\s*[A-Za-z_]\w*)|([A-Za-z_]\w*)(\s*:\s*([A-Za-z_]\w*))?|"/g;
188
+ /**
189
+ * The matches of
190
+ * `/([{}()])|(\.\.\.\s*[A-Za-z_]\w*)|([A-Za-z_]\w*)(\s*:\s*([A-Za-z_]\w*))?|"(?:[^"\\]|\\.)*"/g`
191
+ * from `start`: GraphQL punctuation, spreads, fields with their aliases, and strings.
192
+ */
193
+ export function* graphqlTokens(doc, start) {
194
+ const re = new RegExp(GQL_TOKEN);
195
+ re.lastIndex = start;
196
+ let unclosedUntil = -1;
197
+ let m;
198
+ while ((m = re.exec(doc))) {
199
+ if (m[0] !== '"') {
200
+ yield m;
201
+ continue;
202
+ }
203
+ // The other alternatives can't start at a `"`: if the string doesn't
204
+ // close, the regex moved on to the next character.
205
+ if (m.index < unclosedUntil)
206
+ continue;
207
+ const r = quotedEnd(doc, m.index, true);
208
+ if ("end" in r) {
209
+ // The string closes: the match the regex made, with its five groups unset.
210
+ const text = doc.slice(m.index, r.end);
211
+ yield Object.assign([text, undefined, undefined, undefined, undefined, undefined], { index: m.index, input: doc, groups: undefined });
212
+ re.lastIndex = r.end;
213
+ }
214
+ else
215
+ unclosedUntil = r.fail;
216
+ }
217
+ }
218
+ const GQL_LITERAL = /#[^\n]*|(?:\b([A-Za-z_]\w*)\s*:\s*)?(?:(""")|"|(?<![\w$.])(-?\d{6,})(?![\w.]))/g;
219
+ /**
220
+ * The matches of
221
+ * `/#[^\n]*|(?:\b([A-Za-z_]\w*)\s*:\s*)?(?:"""([\s\S]*?)"""|"((?:[^"\\\n]|\\.)*)"|(?<![\w$.])(-?\d{6,})(?![\w.]))/g`:
222
+ * comments, and string and long number literals with the argument name before them.
223
+ */
224
+ export function* graphqlLiteralMatches(doc) {
225
+ const re = new RegExp(GQL_LITERAL);
226
+ const literal = (index, end, groups) => Object.assign([doc.slice(index, end), ...groups], { index });
227
+ let unclosedUntil = -1;
228
+ let noBlockEnd = false;
229
+ let m;
230
+ while ((m = re.exec(doc))) {
231
+ if (m[0][0] === "#" || m[3] !== undefined) {
232
+ yield literal(m.index, re.lastIndex, [m[1], undefined, undefined, m[3]]);
233
+ continue;
234
+ }
235
+ // The literal starts at the `"` the match ends with.
236
+ const q = re.lastIndex - (m[2] ? 3 : 1);
237
+ if (m[2] && !noBlockEnd) {
238
+ const close = doc.indexOf('"""', q + 3);
239
+ if (close >= 0) {
240
+ yield literal(m.index, close + 3, [m[1], doc.slice(q + 3, close), undefined, undefined]);
241
+ re.lastIndex = close + 3;
242
+ continue;
243
+ }
244
+ noBlockEnd = true;
245
+ }
246
+ const r = q < unclosedUntil ? undefined : quotedEnd(doc, q, false);
247
+ if (r && "end" in r) {
248
+ yield literal(m.index, r.end, [m[1], undefined, doc.slice(q + 1, r.end - 1), undefined]);
249
+ re.lastIndex = r.end;
250
+ continue;
251
+ }
252
+ if (r)
253
+ unclosedUntil = r.fail;
254
+ // The string doesn't close. Without it, nothing matches at m.index (a
255
+ // name or a `"`): the regex moved on to the next character.
256
+ re.lastIndex = m.index + 1;
257
+ }
258
+ }
259
+ /** What `\s` matches (without the u flag). */
260
+ export function isSpaceCode(c) {
261
+ return ((c >= 9 && c <= 13) || c === 32 || c === 0xa0 || c === 0x1680 || (c >= 0x2000 && c <= 0x200a) ||
262
+ c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff);
263
+ }
264
+ const isDigitCode = (c) => c >= 48 && c <= 57;
265
+ const isWordCode = (c) => isDigitCode(c) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95;
266
+ /**
267
+ * Lookup tables over a GraphQL document, filled right to left in one pass so
268
+ * that no position is scanned twice:
269
+ * - `space[i]`: the first index at or after `i` that `\s` doesn't match;
270
+ * - `digits[i]`: the first index at or after `i` that isn't a digit;
271
+ * - `body[i]`: the index after the `"` that closes a string whose body
272
+ * (`(?:[^"\\\n]|\\.)*`) starts at `i`, or -1 if it doesn't close.
273
+ */
274
+ class GraphqlTables {
275
+ doc;
276
+ space;
277
+ digits;
278
+ body;
279
+ lastTriple;
280
+ list;
281
+ constructor(doc) {
282
+ this.doc = doc;
283
+ const n = doc.length;
284
+ this.space = new Int32Array(n + 1);
285
+ this.digits = new Int32Array(n + 1);
286
+ this.body = new Int32Array(n + 2).fill(-1);
287
+ this.space[n] = n;
288
+ this.digits[n] = n;
289
+ for (let i = n - 1; i >= 0; i--) {
290
+ const c = doc.charCodeAt(i);
291
+ this.space[i] = isSpaceCode(c) ? this.space[i + 1] : i;
292
+ this.digits[i] = isDigitCode(c) ? this.digits[i + 1] : i;
293
+ if (c === 34)
294
+ this.body[i] = i + 1;
295
+ else if (c === 92)
296
+ this.body[i] = i + 1 < n && isDot(doc.charCodeAt(i + 1)) ? this.body[i + 2] : -1;
297
+ else
298
+ this.body[i] = c === 10 ? -1 : this.body[i + 1];
299
+ }
300
+ this.lastTriple = doc.lastIndexOf('"""');
301
+ }
302
+ triple(i) {
303
+ return this.doc.startsWith('"""', i);
304
+ }
305
+ /** `"""[\s\S]*?"""` at `i` (a `"""` there): the end, or -1. */
306
+ block(i) {
307
+ // The last `"""` bounds every search, so a failed one costs nothing.
308
+ if (i + 3 > this.lastTriple)
309
+ return -1;
310
+ return this.doc.indexOf('"""', i + 3) + 3;
311
+ }
312
+ /** `"(?:[^"\\\n]|\\.)*"(?!")` at `i` (a `"` there): the end, or -1. */
313
+ string(i) {
314
+ const end = this.body[i + 1];
315
+ return end >= 0 && this.doc.charCodeAt(end) !== 34 ? end : -1;
316
+ }
317
+ /** `-?\d+(?![\w.])` at `i`: the end, or -1. */
318
+ number(i) {
319
+ const start = this.doc.charCodeAt(i) === 45 ? i + 1 : i;
320
+ if (!isDigitCode(this.doc.charCodeAt(start)))
321
+ return -1;
322
+ const end = this.digits[start];
323
+ const next = this.doc.charCodeAt(end);
324
+ return isWordCode(next) || next === 46 ? -1 : end;
325
+ }
326
+ /**
327
+ * `\[\s*(?:(?:STRING|NUMBER)\s*,?\s*)+\]` at the `[` at `i`, where STRING
328
+ * and NUMBER are {@link block}/{@link string} and {@link number}: the end, or -1.
329
+ *
330
+ * The regex backtracks exponentially over the ways `\s*,?\s*` can split
331
+ * the spaces between items, but only the longest split can be followed by
332
+ * an item or `]`. What remains is a choice for block strings: the regex
333
+ * tries each later `"""` as the end of one, in order, until the rest of the
334
+ * list matches. So, right to left over the document:
335
+ * - `item[j]`: where a list whose next item starts at `j` ends, or -1;
336
+ * - `close[j]`: the same for the first `"""` at or after `j` that ends a
337
+ * block string the rest of the list can follow, or -1.
338
+ */
339
+ listAt(i) {
340
+ const item = (this.list ??= this.fillList());
341
+ const j = this.space[i + 1];
342
+ return j < this.doc.length ? item[j] : -1;
343
+ }
344
+ fillList() {
345
+ const { doc, space } = this;
346
+ const n = doc.length;
347
+ const item = new Int32Array(n + 1).fill(-1);
348
+ const close = new Int32Array(n + 4).fill(-1);
349
+ // After an item that ends at `e`: the separator, then `]` or another item.
350
+ const after = (e) => {
351
+ let s = space[e];
352
+ if (doc.charCodeAt(s) === 44)
353
+ s = space[s + 1];
354
+ if (s >= n)
355
+ return -1;
356
+ return doc.charCodeAt(s) === 93 ? s + 1 : item[s];
357
+ };
358
+ for (let j = n - 1; j >= 0; j--) {
359
+ const c = doc.charCodeAt(j);
360
+ const triple = this.triple(j);
361
+ if (triple)
362
+ item[j] = close[j + 3];
363
+ else if (c === 34) {
364
+ const e = this.string(j);
365
+ item[j] = e < 0 ? -1 : after(e);
366
+ }
367
+ else if (c === 45 || isDigitCode(c)) {
368
+ const e = this.number(j);
369
+ item[j] = e < 0 ? -1 : after(e);
370
+ }
371
+ const end = triple ? after(j + 3) : -1;
372
+ close[j] = end >= 0 ? end : close[j + 1];
373
+ }
374
+ return item;
375
+ }
376
+ }
377
+ /**
378
+ * The matches of the regex model.ts used to read literals with (GQL_LIFT),
379
+ * `/#[^\n]*|"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|\b([A-Za-z_][A-Za-z0-9_]*)(\s*:\s*)(STRING|NUMBER|\[\s*(?:(?:STRING|NUMBER)\s*,?\s*)+\])/g`
380
+ * with STRING = `"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"(?!")` and NUMBER = `-?\d+(?![\w.])`:
381
+ * comments, strings, and arguments with a literal value.
382
+ */
383
+ export function* graphqlLiftMatches(doc) {
384
+ const t = new GraphqlTables(doc);
385
+ const n = doc.length;
386
+ const match = (index, end, arg, sep, lit) => Object.assign([doc.slice(index, end), arg, sep, lit], { index });
387
+ for (let p = 0; p < n;) {
388
+ const c = doc.charCodeAt(p);
389
+ if (c === 35) {
390
+ const nl = doc.indexOf("\n", p);
391
+ const end = nl < 0 ? n : nl;
392
+ yield match(p, end);
393
+ p = end;
394
+ continue;
395
+ }
396
+ if (c === 34) {
397
+ const block = t.triple(p) ? t.block(p) : -1;
398
+ const end = block >= 0 ? block : t.body[p + 1];
399
+ if (end >= 0) {
400
+ yield match(p, end);
401
+ p = end;
402
+ }
403
+ else
404
+ p++;
405
+ continue;
406
+ }
407
+ // `\b` before a letter: the start of a name.
408
+ if (isWordCode(c) && !isDigitCode(c) && (p === 0 || !isWordCode(doc.charCodeAt(p - 1)))) {
409
+ let q = p + 1;
410
+ while (q < n && isWordCode(doc.charCodeAt(q)))
411
+ q++;
412
+ const colon = t.space[q];
413
+ if (doc.charCodeAt(colon) === 58) {
414
+ const b = t.space[colon + 1];
415
+ const l = doc.charCodeAt(b);
416
+ const end = l === 34 ? (t.triple(b) ? t.block(b) : t.string(b)) : l === 91 ? t.listAt(b) : l === 45 || isDigitCode(l) ? t.number(b) : -1;
417
+ if (end >= 0) {
418
+ yield match(p, end, doc.slice(p, q), doc.slice(q, b), doc.slice(b, end));
419
+ p = end;
420
+ continue;
421
+ }
422
+ }
423
+ // No literal follows: nothing matches inside the name either.
424
+ p = q;
425
+ continue;
426
+ }
427
+ p++;
428
+ }
429
+ }
430
+ /**
431
+ * The matches of `/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"(?!")|-?\d+(?![\w.])/g`:
432
+ * the items of a list literal, as `[start, end]`.
433
+ */
434
+ export function* graphqlListItems(lit) {
435
+ const t = new GraphqlTables(lit);
436
+ for (let p = 0; p < lit.length;) {
437
+ const c = lit.charCodeAt(p);
438
+ const end = c === 34 ? (t.triple(p) ? t.block(p) : t.string(p)) : c === 45 || isDigitCode(c) ? t.number(p) : -1;
439
+ if (end >= 0) {
440
+ yield [p, end];
441
+ p = end;
442
+ }
443
+ else
444
+ p++;
445
+ }
446
+ }
package/dist/model.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { filterExchanges } from "./filter.js";
8
8
  import { isJsonMime, isObject, loadHar, NOT_JSON, parseJson } from "./har.js";
9
+ import { findFragmentHead, graphqlListItems, graphqlLiftMatches, graphqlTokens, removeHashComments, replaceQuoted, trimLineEnds } from "./linear.js";
9
10
  import { emptyShape, observe, observeText } from "./infer.js";
10
11
  import { assignNames } from "./naming.js";
11
12
  import { collectTaint, isDataLike, isIdentity, isIdentityKey, isIdKey, isTainted, scrubberFor } from "./leak.js";
@@ -252,10 +253,7 @@ export function graphqlRootFields(doc) {
252
253
  const fields = [];
253
254
  let depth = 0;
254
255
  let parens = 0;
255
- const re = /([{}()])|(\.\.\.\s*[A-Za-z_]\w*)|([A-Za-z_]\w*)(\s*:\s*([A-Za-z_]\w*))?|"(?:[^"\\]|\\.)*"/g;
256
- re.lastIndex = start;
257
- let m;
258
- while ((m = re.exec(doc))) {
256
+ for (const m of graphqlTokens(doc, start)) {
259
257
  if (m[1] === "{")
260
258
  depth++;
261
259
  else if (m[1] === "}") {
@@ -309,17 +307,17 @@ function gqlFromObject(body, path, viaGet) {
309
307
  * Strings and block strings are kept as they are.
310
308
  */
311
309
  export function withoutComments(doc) {
312
- return doc.replace(/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|#[^\n\r]*/g, (m) => (m.startsWith("#") ? "" : m)).replace(/[ \t]+$/gm, "");
310
+ return trimLineEnds(removeHashComments(doc));
313
311
  }
314
312
  /** The document without its fragment definitions (they may precede the operation). */
315
313
  export function withoutFragments(doc) {
316
314
  let out = doc;
317
315
  for (let guard = 0; guard < 50; guard++) {
318
- const m = /(^|\})\s*fragment\s+[A-Za-z_]\w*\s+on\s+[A-Za-z_]\w*[^{]*\{/.exec(out);
316
+ const m = findFragmentHead(out);
319
317
  if (!m)
320
318
  break;
321
319
  let depth = 1;
322
- let i = m.index + m[0].length;
320
+ let i = m.end;
323
321
  for (; i < out.length && depth > 0; i++) {
324
322
  if (out[i] === "{")
325
323
  depth++;
@@ -328,15 +326,13 @@ export function withoutFragments(doc) {
328
326
  else if (out[i] === '"')
329
327
  i = Math.max(i, out.indexOf('"', i + 1));
330
328
  }
331
- out = out.slice(0, m.index + m[1].length) + " " + out.slice(i);
329
+ out = out.slice(0, m.index + m.prefix.length) + " " + out.slice(i);
332
330
  }
333
331
  return out;
334
332
  }
335
333
  /** Whitespace and literal values folded away: two recordings of the same operation compare equal. */
336
334
  export function normalizeGraphQL(doc) {
337
- return doc
338
- .replace(/#[^\n]*/g, "")
339
- .replace(/"(?:[^"\\]|\\.)*"/g, '""')
335
+ return replaceQuoted(doc.replace(/#[^\n]*/g, ""), () => '""', true)
340
336
  .replace(/(?<![A-Za-z_$])-?\d+(\.\d+)?/g, "0")
341
337
  .replace(/\s+/g, " ")
342
338
  .replace(/\s*([{}():,!$=@\[\]])\s*/g, "$1")
@@ -348,10 +344,10 @@ export function normalizeGraphQL(doc) {
348
344
  * strings) become variables: the document stays valid, the SDK method takes
349
345
  * them as parameters, and no recorded value is baked in. Small numbers
350
346
  * (`first: 20`) and enum values stay: they are part of the query's logic.
347
+ * The document is read by graphqlLiftMatches (linear.ts), which matches what
348
+ * the regex GQL_LIFT that it replaces (quoted in its comment) matched, without
349
+ * that regex's exponential backtracking.
351
350
  */
352
- const GQL_STRING = String.raw `"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"(?!")`;
353
- const GQL_NUMBER = String.raw `-?\d+(?![\w.])`;
354
- const GQL_LIFT = new RegExp(String.raw `#[^\n]*|"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|\b([A-Za-z_][A-Za-z0-9_]*)(\s*:\s*)(${GQL_STRING}|${GQL_NUMBER}|\[\s*(?:(?:${GQL_STRING}|${GQL_NUMBER})\s*,?\s*)+\])`, "g");
355
351
  function liftGraphQLLiterals(doc, variables) {
356
352
  const taken = new Set([...doc.matchAll(/\$([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]));
357
353
  const defs = [];
@@ -373,13 +369,13 @@ function liftGraphQLLiterals(doc, variables) {
373
369
  };
374
370
  // Numbers are data when they look like ids: an id argument, or six digits or more.
375
371
  const dataNumber = (arg, n) => Number.isInteger(n) && (idArg(arg) || String(Math.abs(n)).length >= 6);
376
- const body = doc.replace(GQL_LIFT, (m, arg, sep, lit) => {
372
+ const lift = (m, arg, sep, lit) => {
377
373
  if (!arg)
378
374
  return m; // a comment or a string that isn't an argument value
379
375
  let value;
380
376
  let type;
381
377
  if (lit.startsWith("[")) {
382
- const items = [...lit.matchAll(new RegExp(`${GQL_STRING}|${GQL_NUMBER}`, "g"))].map((x) => scalar(x[0]));
378
+ const items = [...graphqlListItems(lit)].map(([start, end]) => scalar(lit.slice(start, end)));
383
379
  if (!items.some((x) => typeof x === "string" || dataNumber(arg, x)))
384
380
  return m;
385
381
  value = items;
@@ -405,12 +401,19 @@ function liftGraphQLLiterals(doc, variables) {
405
401
  defs.push(`$${name}: ${type}!`);
406
402
  setOwn(values, name, value);
407
403
  return `${arg}${sep}$${name}`;
408
- });
404
+ };
405
+ let body = "";
406
+ let last = 0;
407
+ for (const m of graphqlLiftMatches(doc)) {
408
+ body += doc.slice(last, m.index) + lift(m[0], m[1], m[2], m[3]);
409
+ last = m.index + m[0].length;
410
+ }
411
+ body += doc.slice(last);
409
412
  if (!defs.length)
410
413
  return { query: doc, variables, names };
411
414
  const query = insertVariableDefinitions(body, defs);
412
415
  if (query === undefined)
413
- return { query: doc.replace(/"(?:[^"\\\n]|\\.)*"/g, (s) => (isDataLike(s.slice(1, -1)) ? JSON.stringify(REDACTED) : s)), variables, names: [] };
416
+ return { query: replaceQuoted(doc, (s) => (isDataLike(s.slice(1, -1)) ? JSON.stringify(REDACTED) : s)), variables, names: [] };
414
417
  return { query, variables: { ...(isObject(variables) ? variables : {}), ...values }, names };
415
418
  }
416
419
  /** Add `$x: String!` definitions to the (first) operation of a document. */
package/dist/openapi.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** OpenAPI 3.1 emitter, sharing the plan (and named schemas) with the TS SDK. */
2
2
  import { VERSION } from "./codegen.js";
3
+ import { trimTrailingSlashes } from "./linear.js";
3
4
  import { namedToJsonSchema, toJsonSchema } from "./types.js";
4
5
  export function generateOpenApi(plan, opts = {}) {
5
6
  const { model, registry, ops } = plan;
@@ -15,7 +16,7 @@ export function generateOpenApi(plan, opts = {}) {
15
16
  }
16
17
  // tRPC procedures each get their own path: /api/trpc/user.byId
17
18
  for (const op of ops.filter((o) => o.ep.rpc?.kind === "trpc")) {
18
- const path = op.ep.path.replace(/\/+$/, "") + "/" + op.ep.rpc.procedure;
19
+ const path = trimTrailingSlashes(op.ep.path) + "/" + op.ep.rpc.procedure;
19
20
  const item = (paths[path] ??= {});
20
21
  item[op.ep.method.toLowerCase()] = trpcOperation(op);
21
22
  }
package/dist/plan.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { applyAnnotations, clientNameFor, isValidClientName } from "./annotations.js";
7
7
  import { mergeShapes, emptyShape } from "./infer.js";
8
8
  import { renameRefs, TypeRegistry } from "./types.js";
9
+ import { trimTrailingSlashes } from "./linear.js";
9
10
  import { isActionEndpoint } from "./naming.js";
10
11
  import { isIdentifier, isPlural, pascal, singular, words } from "./words.js";
11
12
  const RUNTIME_NAMES = ["ClientOptions", "RequestOptions", "ApiError", "GraphQLError", "RpcError", "QueryValue", "DEFAULT_HEADERS"];
@@ -50,7 +51,7 @@ export function buildPlan(model, opts = {}) {
50
51
  renameTypes(registry, ops, opts.annotations.types, warnings);
51
52
  if (opts.baseUrl !== undefined)
52
53
  checkBaseUrl(opts.baseUrl);
53
- const baseUrl = (opts.baseUrl ?? model.primaryOrigin).replace(/\/+$/, "");
54
+ const baseUrl = trimTrailingSlashes(opts.baseUrl ?? model.primaryOrigin);
54
55
  return { model, clientName, baseUrl, registry, ops, warnings };
55
56
  }
56
57
  function singularPascal(s) {
package/dist/redact.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * engine (examples in OpenAPI, text handed to an LLM, the chat prompt) goes
6
6
  * through these functions first.
7
7
  */
8
+ import { withoutQueryOrHash } from "./linear.js";
8
9
  import { setOwn, words } from "./words.js";
9
10
  export const REDACTED = "<redacted>";
10
11
  const SENSITIVE_WORDS = new Set([
@@ -454,7 +455,7 @@ export class Scrubber {
454
455
  }
455
456
  // The API's own relative links ("/v1/customers?page=2"): the same rules as a URL's path.
456
457
  if (/^\/[^\s]*$/.test(v) && !v.startsWith("//"))
457
- return personal ? REDACTED : this.path(v.replace(/[?#].*$/, ""));
458
+ return personal ? REDACTED : this.path(withoutQueryOrHash(v));
458
459
  // IP addresses become documentation addresses (RFC 5737 / 3849).
459
460
  if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v))
460
461
  return `192.0.2.${(Number.parseInt(this.fake(v).replace(/\D/g, "").slice(0, 3) || "1", 10) % 254) + 1}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "har2sdk",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Turn browser network traffic (HAR) into a typed TypeScript SDK, an OpenAPI 3.1 spec and a mock server. Local, deterministic, no API key. Ships an MCP server so Claude can name and document every endpoint.",
5
5
  "keywords": [
6
6
  "har",