@juspay/neurolink 9.86.4 → 9.86.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/auth/tokenStore.d.ts +6 -0
  3. package/dist/auth/tokenStore.js +36 -4
  4. package/dist/browser/neurolink.min.js +417 -417
  5. package/dist/cli/commands/auth.js +3 -0
  6. package/dist/cli/commands/proxy.js +310 -107
  7. package/dist/lib/auth/tokenStore.d.ts +6 -0
  8. package/dist/lib/auth/tokenStore.js +36 -4
  9. package/dist/lib/proxy/accountCooldown.d.ts +5 -0
  10. package/dist/lib/proxy/accountCooldown.js +93 -0
  11. package/dist/lib/proxy/accountQuota.d.ts +3 -4
  12. package/dist/lib/proxy/accountQuota.js +75 -44
  13. package/dist/lib/proxy/accountSelection.d.ts +8 -0
  14. package/dist/lib/proxy/accountSelection.js +26 -0
  15. package/dist/lib/proxy/proxyConfig.js +24 -0
  16. package/dist/lib/proxy/proxyPaths.js +2 -0
  17. package/dist/lib/proxy/requestLogger.js +2 -0
  18. package/dist/lib/proxy/snapshotPersistence.js +1 -1
  19. package/dist/lib/proxy/sseInterceptor.js +16 -0
  20. package/dist/lib/proxy/streamOutcome.d.ts +3 -0
  21. package/dist/lib/proxy/streamOutcome.js +27 -0
  22. package/dist/lib/proxy/tokenRefresh.d.ts +8 -0
  23. package/dist/lib/proxy/tokenRefresh.js +92 -15
  24. package/dist/lib/proxy/updateState.js +17 -4
  25. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +18 -3
  26. package/dist/lib/server/routes/claudeProxyRoutes.js +624 -223
  27. package/dist/lib/types/auth.d.ts +1 -1
  28. package/dist/lib/types/cli.d.ts +2 -0
  29. package/dist/lib/types/proxy.d.ts +49 -8
  30. package/dist/lib/types/subscription.d.ts +5 -0
  31. package/dist/proxy/accountCooldown.d.ts +5 -0
  32. package/dist/proxy/accountCooldown.js +92 -0
  33. package/dist/proxy/accountQuota.d.ts +3 -4
  34. package/dist/proxy/accountQuota.js +75 -44
  35. package/dist/proxy/accountSelection.d.ts +8 -0
  36. package/dist/proxy/accountSelection.js +25 -0
  37. package/dist/proxy/proxyConfig.js +24 -0
  38. package/dist/proxy/proxyPaths.js +2 -0
  39. package/dist/proxy/requestLogger.js +2 -0
  40. package/dist/proxy/snapshotPersistence.js +1 -1
  41. package/dist/proxy/sseInterceptor.js +16 -0
  42. package/dist/proxy/streamOutcome.d.ts +3 -0
  43. package/dist/proxy/streamOutcome.js +26 -0
  44. package/dist/proxy/tokenRefresh.d.ts +8 -0
  45. package/dist/proxy/tokenRefresh.js +92 -15
  46. package/dist/proxy/updateState.js +17 -4
  47. package/dist/server/routes/claudeProxyRoutes.d.ts +18 -3
  48. package/dist/server/routes/claudeProxyRoutes.js +624 -223
  49. package/dist/types/auth.d.ts +1 -1
  50. package/dist/types/cli.d.ts +2 -0
  51. package/dist/types/proxy.d.ts +49 -8
  52. package/dist/types/subscription.d.ts +5 -0
  53. package/package.json +1 -1
@@ -1,16 +1,25 @@
1
1
  import { logger } from "../utils/logger.js";
2
2
  import { tokenStore } from "../auth/tokenStore.js";
3
+ import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
3
4
  const REFRESH_URL = "https://api.anthropic.com/v1/oauth/token";
4
5
  const REFRESH_URL_FALLBACK = "https://console.anthropic.com/v1/oauth/token";
5
6
  const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
6
- const BUFFER_MS = 60 * 60 * 1000;
7
+ const BUFFER_MS = 5 * 60 * 1000;
8
+ const SUCCESS_CACHE_MS = 60_000;
7
9
  const USER_AGENT = "claude-cli/2.1.80 (external, cli)";
10
+ const refreshesInFlight = new Map();
8
11
  export function needsRefresh(account) {
9
12
  return !!(account.expiresAt &&
10
13
  account.expiresAt <= Date.now() + BUFFER_MS &&
11
14
  account.refreshToken);
12
15
  }
13
- export async function refreshToken(account) {
16
+ function isRefreshCredentialRejection(status) {
17
+ return status === 400 || status === 401 || status === 403 || status === 404;
18
+ }
19
+ export function isPermanentRefreshFailure(result) {
20
+ return isRefreshCredentialRejection(result.status);
21
+ }
22
+ async function performTokenRefresh(account) {
14
23
  if (!account.refreshToken) {
15
24
  return { success: false, error: "No refresh token available" };
16
25
  }
@@ -24,6 +33,7 @@ export async function refreshToken(account) {
24
33
  "User-Agent": USER_AGENT,
25
34
  };
26
35
  const urls = [REFRESH_URL, REFRESH_URL_FALLBACK];
36
+ let terminalFailure;
27
37
  for (const url of urls) {
28
38
  try {
29
39
  const resp = await fetch(url, {
@@ -38,21 +48,26 @@ export async function refreshToken(account) {
38
48
  status: resp.status,
39
49
  error: errorBody.slice(0, 500),
40
50
  });
41
- // If primary URL returned a non-ok status, try fallback
51
+ const failure = {
52
+ success: false,
53
+ error: errorBody,
54
+ status: resp.status,
55
+ };
56
+ if (isRefreshCredentialRejection(resp.status)) {
57
+ terminalFailure = failure;
58
+ }
59
+ // If primary URL returned a non-ok status, try fallback.
42
60
  if (url === REFRESH_URL) {
43
61
  continue;
44
62
  }
45
- return { success: false, error: errorBody, status: resp.status };
63
+ return terminalFailure ?? failure;
46
64
  }
47
65
  const data = (await resp.json());
48
- const previousExpiresAt = account.expiresAt;
49
66
  account.token = data.access_token;
50
67
  account.expiresAt =
51
68
  data.expires_in !== undefined
52
69
  ? Date.now() + data.expires_in * 1000
53
- : previousExpiresAt && previousExpiresAt > Date.now()
54
- ? previousExpiresAt
55
- : Date.now() + 55 * 60 * 1000;
70
+ : Date.now() + 55 * 60 * 1000;
56
71
  if (data.refresh_token) {
57
72
  account.refreshToken = data.refresh_token;
58
73
  }
@@ -67,11 +82,77 @@ export async function refreshToken(account) {
67
82
  if (url === REFRESH_URL) {
68
83
  continue;
69
84
  }
70
- return { success: false, error: String(e) };
85
+ return terminalFailure ?? { success: false, error: String(e) };
71
86
  }
72
87
  }
73
88
  // Should not reach here, but guard against empty urls array
74
- return { success: false, error: "No refresh URLs available" };
89
+ return (terminalFailure ?? {
90
+ success: false,
91
+ error: "No refresh URLs available",
92
+ });
93
+ }
94
+ /**
95
+ * Serialize refreshes by refresh-token value. OAuth refresh tokens may rotate,
96
+ * so concurrent refreshes with the same old token can make all but the first
97
+ * request fail with invalid_grant. A short success cache covers callers that
98
+ * loaded either the old token or the newly rotated token around persistence.
99
+ */
100
+ export async function refreshToken(account) {
101
+ if (!account.refreshToken) {
102
+ return { success: false, error: "No refresh token available" };
103
+ }
104
+ const lockKey = account.refreshToken;
105
+ let sharedPromise = refreshesInFlight.get(lockKey);
106
+ if (!sharedPromise) {
107
+ const refreshAccount = { ...account };
108
+ const createdPromise = performTokenRefresh(refreshAccount).then((result) => {
109
+ const shared = {
110
+ result,
111
+ token: refreshAccount.token,
112
+ refreshToken: refreshAccount.refreshToken,
113
+ expiresAt: refreshAccount.expiresAt,
114
+ };
115
+ if (result.success) {
116
+ const cachedKeys = new Set([lockKey]);
117
+ const rotatedKey = refreshAccount.refreshToken;
118
+ if (rotatedKey &&
119
+ rotatedKey !== lockKey &&
120
+ !refreshesInFlight.has(rotatedKey)) {
121
+ refreshesInFlight.set(rotatedKey, createdPromise);
122
+ cachedKeys.add(rotatedKey);
123
+ }
124
+ const timer = setTimeout(() => {
125
+ for (const key of cachedKeys) {
126
+ if (refreshesInFlight.get(key) === createdPromise) {
127
+ refreshesInFlight.delete(key);
128
+ }
129
+ }
130
+ }, SUCCESS_CACHE_MS);
131
+ timer.unref?.();
132
+ }
133
+ else if (refreshesInFlight.get(lockKey) === createdPromise) {
134
+ refreshesInFlight.delete(lockKey);
135
+ }
136
+ return shared;
137
+ }, (error) => {
138
+ if (refreshesInFlight.get(lockKey) === createdPromise) {
139
+ refreshesInFlight.delete(lockKey);
140
+ }
141
+ throw error;
142
+ });
143
+ refreshesInFlight.set(lockKey, createdPromise);
144
+ sharedPromise = createdPromise;
145
+ }
146
+ const shared = await sharedPromise;
147
+ if (shared.result.success) {
148
+ account.token = shared.token;
149
+ account.refreshToken = shared.refreshToken;
150
+ account.expiresAt = shared.expiresAt;
151
+ }
152
+ return shared.result;
153
+ }
154
+ export function clearRefreshStateForTests() {
155
+ refreshesInFlight.clear();
75
156
  }
76
157
  export async function persistTokens(target, account) {
77
158
  if (typeof target !== "string" && "providerKey" in target) {
@@ -92,11 +173,7 @@ async function persistLegacyCredentials(credPath, account) {
92
173
  refreshToken: account.refreshToken,
93
174
  };
94
175
  existing.updatedAt = Date.now();
95
- const tmpPath = credPath + ".tmp";
96
- await fs.writeFile(tmpPath, JSON.stringify(existing, null, 2), {
97
- mode: 0o600,
98
- });
99
- await fs.rename(tmpPath, credPath);
176
+ await writeJsonSnapshotAtomically(credPath, existing, 0o600);
100
177
  }
101
178
  catch (err) {
102
179
  logger.warn("[token-refresh] Failed to persist legacy credentials", {
@@ -9,6 +9,7 @@
9
9
  import fs from "fs";
10
10
  import os from "os";
11
11
  import path from "path";
12
+ import { randomUUID } from "node:crypto";
12
13
  // ============================================
13
14
  // Constants
14
15
  // ============================================
@@ -92,10 +93,22 @@ export function loadUpdateState(stateFilePath) {
92
93
  export function saveUpdateState(state, stateFilePath) {
93
94
  const filePath = resolveStatePath(stateFilePath);
94
95
  ensureParentDir(filePath);
95
- // Atomic write: write to temp file then rename to prevent corruption on crash
96
- const tmpPath = filePath + ".tmp";
97
- fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
98
- fs.renameSync(tmpPath, filePath);
96
+ // A process-unique temp path prevents concurrent update checks from renaming
97
+ // each other's file. The guard is single-owner now, but this also keeps
98
+ // explicit update commands safe when they overlap.
99
+ const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
100
+ try {
101
+ fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), { mode: 0o600 });
102
+ fs.renameSync(tmpPath, filePath);
103
+ }
104
+ finally {
105
+ try {
106
+ fs.rmSync(tmpPath, { force: true });
107
+ }
108
+ catch {
109
+ // Best-effort cleanup after a successful rename or interrupted write.
110
+ }
111
+ }
99
112
  }
100
113
  /**
101
114
  * Check whether a version is currently suppressed (i.e., suppressed AND within the 24-hour window).
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
13
13
  import type { ModelRouter } from "../../proxy/modelRouter.js";
14
- import type { AccountCooldownPlan, AccountQuota, ParsedClaudeError, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState } from "../../types/index.js";
14
+ import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
15
+ import type { AccountAllowlist, AccountCooldownPlan, AccountQuota, ParsedClaudeError, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, StreamTerminalOutcome } from "../../types/index.js";
15
16
  /** Resolve the configured primary's stable key to its current index in the
16
17
  * request's enabledAccounts list. Returns 0 (insertion-order fallback) when
17
18
  * no key is configured or the key cannot be matched (account disabled/
@@ -43,7 +44,7 @@ declare function resetEpochToMs(resetEpoch: number | undefined, now: number): nu
43
44
  * burst / acceleration limit) is transient: honor retry-after as a floor,
44
45
  * allow a couple of jittered same-account retries, then a short cooldown.
45
46
  */
46
- declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number): AccountCooldownPlan;
47
+ declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number, unifiedStatus?: string | undefined): AccountCooldownPlan;
47
48
  /**
48
49
  * Seed each account's runtime quota from the persisted snapshots in
49
50
  * ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
@@ -72,6 +73,15 @@ declare function seedRuntimeQuotasFromDisk(accounts: ProxyPassthroughAccount[]):
72
73
  * last resort.
73
74
  */
74
75
  declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number): ProxyPassthroughAccount[];
76
+ declare function trackUpstreamReadableStream(source: ReadableStream<Uint8Array>): {
77
+ stream: ReadableStream<Uint8Array>;
78
+ outcome: Promise<StreamTerminalOutcome>;
79
+ };
80
+ declare function getStreamFailureDetails(outcome: StreamTerminalOutcome): {
81
+ status: number;
82
+ errorType: string;
83
+ message: string;
84
+ } | undefined;
75
85
  /**
76
86
  * Create Claude-compatible proxy routes.
77
87
  *
@@ -82,7 +92,7 @@ declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now:
82
92
  * @param basePath - Base path prefix (default: "" since Claude API uses /v1/...).
83
93
  * @returns RouteGroup with Claude-compatible endpoints.
84
94
  */
85
- export declare function createClaudeProxyRoutes(modelRouter?: ModelRouter, basePath?: string, accountStrategy?: "round-robin" | "fill-first", passthroughMode?: boolean, primaryAccountKey?: string): RouteGroup;
95
+ export declare function createClaudeProxyRoutes(modelRouter?: ModelRouter, basePath?: string, accountStrategy?: "round-robin" | "fill-first", passthroughMode?: boolean, primaryAccountKey?: string, accountAllowlist?: AccountAllowlist): RouteGroup;
86
96
  export declare function getTransientSameAccountRetryDelayMs(retryNumber: number): number;
87
97
  /**
88
98
  * Parse a Claude error payload when available.
@@ -107,12 +117,17 @@ export declare const __testHooks: {
107
117
  resolveHomeIndex: typeof resolveHomeIndex;
108
118
  maybeResetPrimaryToHome: typeof maybeResetPrimaryToHome;
109
119
  planCooldownFor429: typeof planCooldownFor429;
120
+ isPermanentRefreshFailure: typeof isPermanentRefreshFailure;
121
+ getStreamFailureDetails: typeof getStreamFailureDetails;
122
+ trackUpstreamReadableStream: typeof trackUpstreamReadableStream;
110
123
  orderAccountsByQuota: typeof orderAccountsByQuota;
111
124
  resetEpochToMs: typeof resetEpochToMs;
112
125
  seedRuntimeQuotasFromDisk: typeof seedRuntimeQuotasFromDisk;
113
126
  getAccountRuntimeState: (key: string) => RuntimeAccountState | undefined;
114
127
  setConfiguredPrimaryAccountKey: (key: string | undefined) => void;
115
128
  getConfiguredPrimaryAccountKey: () => string | undefined;
129
+ setConfiguredAccountAllowlist: (allowlist: AccountAllowlist | undefined) => void;
130
+ getConfiguredAccountAllowlist: () => string[] | undefined;
116
131
  setPrimaryAccountIndex: (index: number) => void;
117
132
  getPrimaryAccountIndex: () => number;
118
133
  setAccountRuntimeState: (key: string, state: Partial<RuntimeAccountState>) => void;