@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +88 -88
  2. package/dist/opencode-anthropic-auth-cli.mjs +804 -507
  3. package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
  4. package/package.json +67 -59
  5. package/src/__tests__/billing-edge-cases.test.ts +59 -59
  6. package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
  7. package/src/__tests__/cc-comparison.test.ts +87 -87
  8. package/src/__tests__/cc-credentials.test.ts +254 -250
  9. package/src/__tests__/cch-drift-checker.test.ts +51 -51
  10. package/src/__tests__/cch-native-style.test.ts +56 -56
  11. package/src/__tests__/debug-gating.test.ts +42 -42
  12. package/src/__tests__/decomposition-smoke.test.ts +68 -68
  13. package/src/__tests__/fingerprint-regression.test.ts +575 -566
  14. package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
  15. package/src/__tests__/helpers/conversation-history.ts +119 -119
  16. package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
  17. package/src/__tests__/helpers/deferred.ts +69 -69
  18. package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
  19. package/src/__tests__/helpers/in-memory-storage.ts +88 -88
  20. package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
  21. package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
  22. package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
  23. package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
  24. package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
  25. package/src/__tests__/helpers/sse.ts +209 -209
  26. package/src/__tests__/index.parallel.test.ts +605 -595
  27. package/src/__tests__/sanitization-regex.test.ts +112 -112
  28. package/src/__tests__/state-bounds.test.ts +90 -90
  29. package/src/account-identity.test.ts +197 -192
  30. package/src/account-identity.ts +69 -67
  31. package/src/account-state.test.ts +86 -86
  32. package/src/account-state.ts +25 -25
  33. package/src/accounts/matching.test.ts +335 -0
  34. package/src/accounts/matching.ts +167 -0
  35. package/src/accounts/persistence.test.ts +345 -0
  36. package/src/accounts/persistence.ts +432 -0
  37. package/src/accounts/repair.test.ts +276 -0
  38. package/src/accounts/repair.ts +407 -0
  39. package/src/accounts.dedup.test.ts +621 -621
  40. package/src/accounts.test.ts +933 -929
  41. package/src/accounts.ts +633 -989
  42. package/src/backoff.test.ts +345 -345
  43. package/src/backoff.ts +219 -219
  44. package/src/betas.ts +124 -124
  45. package/src/bun-fetch.test.ts +345 -342
  46. package/src/bun-fetch.ts +424 -424
  47. package/src/bun-proxy.test.ts +25 -25
  48. package/src/bun-proxy.ts +209 -209
  49. package/src/cc-credentials.ts +111 -111
  50. package/src/circuit-breaker.test.ts +184 -184
  51. package/src/circuit-breaker.ts +169 -169
  52. package/src/cli/commands/auth.ts +963 -0
  53. package/src/cli/commands/config.ts +547 -0
  54. package/src/cli/formatting.test.ts +406 -0
  55. package/src/cli/formatting.ts +219 -0
  56. package/src/cli.ts +255 -2022
  57. package/src/commands/handlers/betas.ts +100 -0
  58. package/src/commands/handlers/config.ts +99 -0
  59. package/src/commands/handlers/files.ts +375 -0
  60. package/src/commands/oauth-flow.ts +181 -166
  61. package/src/commands/prompts.ts +61 -61
  62. package/src/commands/router.test.ts +421 -0
  63. package/src/commands/router.ts +143 -635
  64. package/src/config.test.ts +482 -482
  65. package/src/config.ts +412 -404
  66. package/src/constants.ts +48 -48
  67. package/src/drift/cch-constants.ts +95 -95
  68. package/src/env.ts +111 -105
  69. package/src/headers/billing.ts +33 -33
  70. package/src/headers/builder.ts +130 -130
  71. package/src/headers/cch.ts +75 -75
  72. package/src/headers/stainless.ts +25 -25
  73. package/src/headers/user-agent.ts +23 -23
  74. package/src/index.ts +436 -828
  75. package/src/models.ts +27 -27
  76. package/src/oauth.test.ts +102 -102
  77. package/src/oauth.ts +178 -178
  78. package/src/parent-pid-watcher.test.ts +148 -148
  79. package/src/parent-pid-watcher.ts +69 -69
  80. package/src/plugin-helpers.ts +82 -82
  81. package/src/refresh-helpers.ts +145 -139
  82. package/src/refresh-lock.test.ts +94 -94
  83. package/src/refresh-lock.ts +93 -93
  84. package/src/request/body.history.test.ts +579 -571
  85. package/src/request/body.ts +255 -255
  86. package/src/request/metadata.ts +65 -65
  87. package/src/request/retry.test.ts +156 -156
  88. package/src/request/retry.ts +67 -67
  89. package/src/request/url.ts +21 -21
  90. package/src/request-orchestration-helpers.ts +648 -0
  91. package/src/response/index.ts +5 -5
  92. package/src/response/mcp.ts +58 -58
  93. package/src/response/streaming.test.ts +313 -311
  94. package/src/response/streaming.ts +412 -410
  95. package/src/rotation.test.ts +304 -301
  96. package/src/rotation.ts +205 -205
  97. package/src/storage.test.ts +547 -547
  98. package/src/storage.ts +315 -291
  99. package/src/system-prompt/builder.ts +38 -38
  100. package/src/system-prompt/index.ts +5 -5
  101. package/src/system-prompt/normalize.ts +60 -60
  102. package/src/system-prompt/sanitize.ts +30 -30
  103. package/src/thinking.ts +21 -20
  104. package/src/token-refresh.test.ts +265 -265
  105. package/src/token-refresh.ts +219 -214
  106. package/src/types.ts +30 -30
  107. package/dist/bun-proxy.mjs +0 -291
@@ -10,116 +10,116 @@ import { describe, it, expect } from "vitest";
10
10
  import { sanitizeSystemText } from "../system-prompt/sanitize.js";
11
11
 
12
12
  describe("sanitizeSystemText word boundaries", () => {
13
- it("replaces the PascalCase word 'OpenCode' with 'Claude Code'", () => {
14
- const result = sanitizeSystemText("Run OpenCode first", true);
15
- expect(result).not.toContain("OpenCode");
16
- expect(result).toContain("Claude Code");
17
- });
18
-
19
- it("replaces the lowercase word 'opencode' with 'Claude'", () => {
20
- // Per sanitize.ts, /\bopencode\b/gi → "Claude" (not "Claude Code")
21
- const result = sanitizeSystemText("use opencode here", true);
22
- expect(result).not.toContain("opencode");
23
- expect(result).toContain("Claude");
24
- });
25
-
26
- it("does NOT replace 'myopencode' (word boundary on left)", () => {
27
- const result = sanitizeSystemText("the myopencode binary", true);
28
- expect(result).toContain("myopencode");
29
- });
30
-
31
- it("does NOT replace 'opencoder' (word boundary on right)", () => {
32
- const result = sanitizeSystemText("known as opencoder", true);
33
- expect(result).toContain("opencoder");
34
- });
35
-
36
- it("does NOT replace 'preopencode' (word boundary on both sides)", () => {
37
- const result = sanitizeSystemText("run preopencode first", true);
38
- expect(result).toContain("preopencode");
39
- });
40
-
41
- it("handles mixed content correctly", () => {
42
- const result = sanitizeSystemText("use opencode inside myopencode directory", true);
43
- // Standalone "opencode" becomes "Claude"
44
- expect(result).toContain("Claude");
45
- // "myopencode" is preserved because of word boundary
46
- expect(result).toContain("myopencode");
47
- });
48
-
49
- it("preserves text when enabled=false", () => {
50
- const result = sanitizeSystemText("use opencode here", false);
51
- expect(result).toContain("opencode");
52
- });
53
-
54
- it("replaces 'Sisyphus' with 'Claude Code Agent'", () => {
55
- const result = sanitizeSystemText("from the Sisyphus agent", true);
56
- expect(result).not.toContain("Sisyphus");
57
- expect(result).toContain("Claude Code Agent");
58
- });
59
-
60
- it("replaces 'morph_edit' with 'edit' at word boundaries", () => {
61
- const result = sanitizeSystemText("call morph_edit tool", true);
62
- expect(result).not.toContain("morph_edit");
63
- expect(result).toContain("edit");
64
- });
65
-
66
- // ---------------------------------------------------------------------
67
- // Regressions for the hyphen/slash word boundary fix.
68
- // The previous regex used \b which treats `-` and `/` as word boundaries,
69
- // so `opencode-anthropic-fix` and `/Users/.../opencode/dist` were getting
70
- // rewritten in place. The new regex uses negative lookarounds for
71
- // [\w\-/] on both sides so these forms survive verbatim.
72
- // ---------------------------------------------------------------------
73
-
74
- it("does NOT rewrite 'opencode-anthropic-fix' (hyphen on the right)", () => {
75
- const result = sanitizeSystemText("Loaded opencode-anthropic-fix from disk", true);
76
- expect(result).toContain("opencode-anthropic-fix");
77
- expect(result).not.toContain("Claude-anthropic-fix");
78
- });
79
-
80
- it("does NOT rewrite 'pre-opencode' (hyphen on the left)", () => {
81
- const result = sanitizeSystemText("the pre-opencode hook fired", true);
82
- expect(result).toContain("pre-opencode");
83
- });
84
-
85
- it("does NOT corrupt path-like strings containing /opencode/", () => {
86
- const input = "Working dir: /Users/rmk/projects/opencode-auth/src";
87
- const result = sanitizeSystemText(input, true);
88
- expect(result).toBe(input);
89
- });
90
-
91
- it("does NOT corrupt deep paths with multiple opencode segments", () => {
92
- const input = "/home/user/.config/opencode/plugin/opencode-anthropic-auth-plugin.js";
93
- const result = sanitizeSystemText(input, true);
94
- expect(result).toBe(input);
95
- });
96
-
97
- it("does NOT rewrite the PascalCase form inside hyphenated identifiers", () => {
98
- const result = sanitizeSystemText("the OpenCode-Plugin loader", true);
99
- expect(result).toContain("OpenCode-Plugin");
100
- expect(result).not.toContain("Claude Code-Plugin");
101
- });
102
-
103
- it("still rewrites a standalone PascalCase 'OpenCode' next to a hyphenated form", () => {
104
- const result = sanitizeSystemText("OpenCode loaded opencode-anthropic-fix", true);
105
- expect(result).toContain("Claude Code loaded");
106
- expect(result).toContain("opencode-anthropic-fix");
107
- });
108
-
109
- it("defaults to enabled=false (no second arg means no rewriting)", () => {
110
- const result = sanitizeSystemText("use OpenCode and opencode and Sisyphus and morph_edit");
111
- expect(result).toBe("use OpenCode and opencode and Sisyphus and morph_edit");
112
- });
113
-
114
- it("explicit enabled=false preserves text verbatim", () => {
115
- const input = "Path: /Users/rmk/projects/opencode-anthropic-fix";
116
- const result = sanitizeSystemText(input, false);
117
- expect(result).toBe(input);
118
- });
119
-
120
- it("explicit enabled=true rewrites the standalone forms", () => {
121
- const result = sanitizeSystemText("use opencode for tasks", true);
122
- expect(result).toContain("Claude");
123
- expect(result).not.toContain("opencode");
124
- });
13
+ it("replaces the PascalCase word 'OpenCode' with 'Claude Code'", () => {
14
+ const result = sanitizeSystemText("Run OpenCode first", true);
15
+ expect(result).not.toContain("OpenCode");
16
+ expect(result).toContain("Claude Code");
17
+ });
18
+
19
+ it("replaces the lowercase word 'opencode' with 'Claude'", () => {
20
+ // Per sanitize.ts, /\bopencode\b/gi → "Claude" (not "Claude Code")
21
+ const result = sanitizeSystemText("use opencode here", true);
22
+ expect(result).not.toContain("opencode");
23
+ expect(result).toContain("Claude");
24
+ });
25
+
26
+ it("does NOT replace 'myopencode' (word boundary on left)", () => {
27
+ const result = sanitizeSystemText("the myopencode binary", true);
28
+ expect(result).toContain("myopencode");
29
+ });
30
+
31
+ it("does NOT replace 'opencoder' (word boundary on right)", () => {
32
+ const result = sanitizeSystemText("known as opencoder", true);
33
+ expect(result).toContain("opencoder");
34
+ });
35
+
36
+ it("does NOT replace 'preopencode' (word boundary on both sides)", () => {
37
+ const result = sanitizeSystemText("run preopencode first", true);
38
+ expect(result).toContain("preopencode");
39
+ });
40
+
41
+ it("handles mixed content correctly", () => {
42
+ const result = sanitizeSystemText("use opencode inside myopencode directory", true);
43
+ // Standalone "opencode" becomes "Claude"
44
+ expect(result).toContain("Claude");
45
+ // "myopencode" is preserved because of word boundary
46
+ expect(result).toContain("myopencode");
47
+ });
48
+
49
+ it("preserves text when enabled=false", () => {
50
+ const result = sanitizeSystemText("use opencode here", false);
51
+ expect(result).toContain("opencode");
52
+ });
53
+
54
+ it("replaces 'Sisyphus' with 'Claude Code Agent'", () => {
55
+ const result = sanitizeSystemText("from the Sisyphus agent", true);
56
+ expect(result).not.toContain("Sisyphus");
57
+ expect(result).toContain("Claude Code Agent");
58
+ });
59
+
60
+ it("replaces 'morph_edit' with 'edit' at word boundaries", () => {
61
+ const result = sanitizeSystemText("call morph_edit tool", true);
62
+ expect(result).not.toContain("morph_edit");
63
+ expect(result).toContain("edit");
64
+ });
65
+
66
+ // ---------------------------------------------------------------------
67
+ // Regressions for the hyphen/slash word boundary fix.
68
+ // The previous regex used \b which treats `-` and `/` as word boundaries,
69
+ // so `opencode-anthropic-fix` and `/Users/.../opencode/dist` were getting
70
+ // rewritten in place. The new regex uses negative lookarounds for
71
+ // [\w\-/] on both sides so these forms survive verbatim.
72
+ // ---------------------------------------------------------------------
73
+
74
+ it("does NOT rewrite 'opencode-anthropic-fix' (hyphen on the right)", () => {
75
+ const result = sanitizeSystemText("Loaded opencode-anthropic-fix from disk", true);
76
+ expect(result).toContain("opencode-anthropic-fix");
77
+ expect(result).not.toContain("Claude-anthropic-fix");
78
+ });
79
+
80
+ it("does NOT rewrite 'pre-opencode' (hyphen on the left)", () => {
81
+ const result = sanitizeSystemText("the pre-opencode hook fired", true);
82
+ expect(result).toContain("pre-opencode");
83
+ });
84
+
85
+ it("does NOT corrupt path-like strings containing /opencode/", () => {
86
+ const input = "Working dir: /Users/rmk/projects/opencode-auth/src";
87
+ const result = sanitizeSystemText(input, true);
88
+ expect(result).toBe(input);
89
+ });
90
+
91
+ it("does NOT corrupt deep paths with multiple opencode segments", () => {
92
+ const input = "/home/user/.config/opencode/plugin/opencode-anthropic-auth-plugin.js";
93
+ const result = sanitizeSystemText(input, true);
94
+ expect(result).toBe(input);
95
+ });
96
+
97
+ it("does NOT rewrite the PascalCase form inside hyphenated identifiers", () => {
98
+ const result = sanitizeSystemText("the OpenCode-Plugin loader", true);
99
+ expect(result).toContain("OpenCode-Plugin");
100
+ expect(result).not.toContain("Claude Code-Plugin");
101
+ });
102
+
103
+ it("still rewrites a standalone PascalCase 'OpenCode' next to a hyphenated form", () => {
104
+ const result = sanitizeSystemText("OpenCode loaded opencode-anthropic-fix", true);
105
+ expect(result).toContain("Claude Code loaded");
106
+ expect(result).toContain("opencode-anthropic-fix");
107
+ });
108
+
109
+ it("defaults to enabled=false (no second arg means no rewriting)", () => {
110
+ const result = sanitizeSystemText("use OpenCode and opencode and Sisyphus and morph_edit");
111
+ expect(result).toBe("use OpenCode and opencode and Sisyphus and morph_edit");
112
+ });
113
+
114
+ it("explicit enabled=false preserves text verbatim", () => {
115
+ const input = "Path: /Users/rmk/projects/opencode-anthropic-fix";
116
+ const result = sanitizeSystemText(input, false);
117
+ expect(result).toBe(input);
118
+ });
119
+
120
+ it("explicit enabled=true rewrites the standalone forms", () => {
121
+ const result = sanitizeSystemText("use opencode for tasks", true);
122
+ expect(result).toContain("Claude");
123
+ expect(result).not.toContain("opencode");
124
+ });
125
125
  });
@@ -11,100 +11,100 @@ import { capFileAccountMap, FILE_ACCOUNT_MAP_MAX_SIZE } from "../commands/router
11
11
  import { pruneExpiredPendingOAuth, PENDING_OAUTH_TTL_MS, type PendingOAuthEntry } from "../commands/oauth-flow.js";
12
12
 
13
13
  function makePendingEntry(createdAt: number): PendingOAuthEntry {
14
- return {
15
- mode: "login",
16
- verifier: "test-verifier",
17
- createdAt,
18
- };
14
+ return {
15
+ mode: "login",
16
+ verifier: "test-verifier",
17
+ createdAt,
18
+ };
19
19
  }
20
20
 
21
21
  describe("capFileAccountMap FIFO eviction", () => {
22
- it("exports FILE_ACCOUNT_MAP_MAX_SIZE = 1000", () => {
23
- expect(FILE_ACCOUNT_MAP_MAX_SIZE).toBe(1000);
24
- });
25
-
26
- it("caps the map at FILE_ACCOUNT_MAP_MAX_SIZE entries", () => {
27
- const map = new Map<string, number>();
28
- for (let i = 0; i < FILE_ACCOUNT_MAP_MAX_SIZE + 100; i++) {
29
- capFileAccountMap(map, `file_${i}`, i % 5);
30
- }
31
- expect(map.size).toBeLessThanOrEqual(FILE_ACCOUNT_MAP_MAX_SIZE);
32
- });
33
-
34
- it("evicts oldest entries first (FIFO)", () => {
35
- const map = new Map<string, number>();
36
- for (let i = 0; i < FILE_ACCOUNT_MAP_MAX_SIZE; i++) {
37
- capFileAccountMap(map, `file_${i}`, 0);
38
- }
39
- capFileAccountMap(map, "file_overflow", 1);
40
- expect(map.has("file_0")).toBe(false);
41
- expect(map.has("file_overflow")).toBe(true);
42
- expect(map.size).toBe(FILE_ACCOUNT_MAP_MAX_SIZE);
43
- });
44
-
45
- it("updates value of existing key when below cap", () => {
46
- const map = new Map<string, number>();
47
- capFileAccountMap(map, "file_a", 1);
48
- capFileAccountMap(map, "file_a", 2);
49
- expect(map.get("file_a")).toBe(2);
50
- expect(map.size).toBe(1);
51
- });
52
-
53
- it("handles rapid insertion under cap without eviction", () => {
54
- const map = new Map<string, number>();
55
- for (let i = 0; i < 500; i++) {
56
- capFileAccountMap(map, `file_${i}`, 0);
57
- }
58
- expect(map.size).toBe(500);
59
- expect(map.has("file_0")).toBe(true);
60
- expect(map.has("file_499")).toBe(true);
61
- });
22
+ it("exports FILE_ACCOUNT_MAP_MAX_SIZE = 1000", () => {
23
+ expect(FILE_ACCOUNT_MAP_MAX_SIZE).toBe(1000);
24
+ });
25
+
26
+ it("caps the map at FILE_ACCOUNT_MAP_MAX_SIZE entries", () => {
27
+ const map = new Map<string, number>();
28
+ for (let i = 0; i < FILE_ACCOUNT_MAP_MAX_SIZE + 100; i++) {
29
+ capFileAccountMap(map, `file_${i}`, i % 5);
30
+ }
31
+ expect(map.size).toBeLessThanOrEqual(FILE_ACCOUNT_MAP_MAX_SIZE);
32
+ });
33
+
34
+ it("evicts oldest entries first (FIFO)", () => {
35
+ const map = new Map<string, number>();
36
+ for (let i = 0; i < FILE_ACCOUNT_MAP_MAX_SIZE; i++) {
37
+ capFileAccountMap(map, `file_${i}`, 0);
38
+ }
39
+ capFileAccountMap(map, "file_overflow", 1);
40
+ expect(map.has("file_0")).toBe(false);
41
+ expect(map.has("file_overflow")).toBe(true);
42
+ expect(map.size).toBe(FILE_ACCOUNT_MAP_MAX_SIZE);
43
+ });
44
+
45
+ it("updates value of existing key when below cap", () => {
46
+ const map = new Map<string, number>();
47
+ capFileAccountMap(map, "file_a", 1);
48
+ capFileAccountMap(map, "file_a", 2);
49
+ expect(map.get("file_a")).toBe(2);
50
+ expect(map.size).toBe(1);
51
+ });
52
+
53
+ it("handles rapid insertion under cap without eviction", () => {
54
+ const map = new Map<string, number>();
55
+ for (let i = 0; i < 500; i++) {
56
+ capFileAccountMap(map, `file_${i}`, 0);
57
+ }
58
+ expect(map.size).toBe(500);
59
+ expect(map.has("file_0")).toBe(true);
60
+ expect(map.has("file_499")).toBe(true);
61
+ });
62
62
  });
63
63
 
64
64
  describe("pruneExpiredPendingOAuth TTL cleanup", () => {
65
- it("exports PENDING_OAUTH_TTL_MS = 10 minutes", () => {
66
- expect(PENDING_OAUTH_TTL_MS).toBe(10 * 60 * 1000);
67
- });
68
-
69
- it("removes entries older than TTL", () => {
70
- const map = new Map<string, PendingOAuthEntry>();
71
- const now = Date.now();
72
- map.set("expired", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 1000));
73
- map.set("fresh", makePendingEntry(now));
74
-
75
- pruneExpiredPendingOAuth(map);
76
-
77
- expect(map.has("expired")).toBe(false);
78
- expect(map.has("fresh")).toBe(true);
79
- });
80
-
81
- it("does not remove entries just inside the TTL boundary", () => {
82
- const map = new Map<string, PendingOAuthEntry>();
83
- const now = Date.now();
84
- map.set("boundary", makePendingEntry(now - PENDING_OAUTH_TTL_MS + 1000));
85
-
86
- pruneExpiredPendingOAuth(map);
87
-
88
- expect(map.has("boundary")).toBe(true);
89
- });
90
-
91
- it("handles empty map without error", () => {
92
- const map = new Map<string, PendingOAuthEntry>();
93
- expect(() => pruneExpiredPendingOAuth(map)).not.toThrow();
94
- });
95
-
96
- it("removes only expired entries, leaves fresh ones", () => {
97
- const map = new Map<string, PendingOAuthEntry>();
98
- const now = Date.now();
99
- map.set("old1", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 5000));
100
- map.set("old2", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 2000));
101
- map.set("new1", makePendingEntry(now - 1000));
102
- map.set("new2", makePendingEntry(now));
103
-
104
- pruneExpiredPendingOAuth(map);
105
-
106
- expect(map.size).toBe(2);
107
- expect(map.has("new1")).toBe(true);
108
- expect(map.has("new2")).toBe(true);
109
- });
65
+ it("exports PENDING_OAUTH_TTL_MS = 10 minutes", () => {
66
+ expect(PENDING_OAUTH_TTL_MS).toBe(10 * 60 * 1000);
67
+ });
68
+
69
+ it("removes entries older than TTL", () => {
70
+ const map = new Map<string, PendingOAuthEntry>();
71
+ const now = Date.now();
72
+ map.set("expired", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 1000));
73
+ map.set("fresh", makePendingEntry(now));
74
+
75
+ pruneExpiredPendingOAuth(map);
76
+
77
+ expect(map.has("expired")).toBe(false);
78
+ expect(map.has("fresh")).toBe(true);
79
+ });
80
+
81
+ it("does not remove entries just inside the TTL boundary", () => {
82
+ const map = new Map<string, PendingOAuthEntry>();
83
+ const now = Date.now();
84
+ map.set("boundary", makePendingEntry(now - PENDING_OAUTH_TTL_MS + 1000));
85
+
86
+ pruneExpiredPendingOAuth(map);
87
+
88
+ expect(map.has("boundary")).toBe(true);
89
+ });
90
+
91
+ it("handles empty map without error", () => {
92
+ const map = new Map<string, PendingOAuthEntry>();
93
+ expect(() => pruneExpiredPendingOAuth(map)).not.toThrow();
94
+ });
95
+
96
+ it("removes only expired entries, leaves fresh ones", () => {
97
+ const map = new Map<string, PendingOAuthEntry>();
98
+ const now = Date.now();
99
+ map.set("old1", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 5000));
100
+ map.set("old2", makePendingEntry(now - PENDING_OAUTH_TTL_MS - 2000));
101
+ map.set("new1", makePendingEntry(now - 1000));
102
+ map.set("new2", makePendingEntry(now));
103
+
104
+ pruneExpiredPendingOAuth(map);
105
+
106
+ expect(map.size).toBe(2);
107
+ expect(map.has("new1")).toBe(true);
108
+ expect(map.has("new2")).toBe(true);
109
+ });
110
110
  });