vigor-roblox 1.0.3 → 1.0.4

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/dist/index.js CHANGED
@@ -1838,10 +1838,85 @@ function partition(arr, pred) {
1838
1838
  return { pass, fail };
1839
1839
  }
1840
1840
  // ----------------------------------------------------------------
1841
+ // CSRF Token Manager
1842
+ // ----------------------------------------------------------------
1843
+ class CsrfTokenManager {
1844
+ // cookie → token 매핑 (쿠키별로 독립 토큰 관리)
1845
+ tokenMap = new Map();
1846
+ // 쿠키별 진행 중인 갱신 Promise (중복 갱신 방지)
1847
+ pendingMap = new Map();
1848
+ /**
1849
+ * 저장된 토큰을 반환합니다. 없으면 null.
1850
+ */
1851
+ get(cookie) {
1852
+ return this.tokenMap.get(cookie) ?? null;
1853
+ }
1854
+ /**
1855
+ * 토큰을 수동으로 저장합니다.
1856
+ * (403 응답 헤더의 x-csrf-token 값을 받아서 저장할 때 사용)
1857
+ */
1858
+ set(cookie, token) {
1859
+ this.tokenMap.set(cookie, token);
1860
+ }
1861
+ /**
1862
+ * 토큰을 무효화합니다.
1863
+ * (재시도 전 갱신이 필요할 때 호출)
1864
+ */
1865
+ invalidate(cookie) {
1866
+ this.tokenMap.delete(cookie);
1867
+ }
1868
+ /**
1869
+ * Roblox의 CSRF 토큰 갱신 방식:
1870
+ * POST /v1/logout 등 아무 인증 엔드포인트에 빈 body로 요청하면
1871
+ * 403 응답과 함께 x-csrf-token 헤더로 새 토큰을 내려줍니다.
1872
+ *
1873
+ * 동일 쿠키에 대해 동시에 여러 갱신 요청이 오면
1874
+ * 하나만 실제로 요청하고 나머지는 그 결과를 공유합니다.
1875
+ */
1876
+ async refresh(cookie) {
1877
+ const existing = this.pendingMap.get(cookie);
1878
+ if (existing)
1879
+ return existing;
1880
+ const pending = (async () => {
1881
+ try {
1882
+ // Roblox는 POST에 body가 없어도 403 + x-csrf-token을 내려줌
1883
+ const response = await fetch('https://auth.roblox.com/v2/logout', {
1884
+ method: 'POST',
1885
+ headers: {
1886
+ 'Cookie': `.ROBLOSECURITY=${cookie}`,
1887
+ 'User-Agent': 'Roblox/WinInet',
1888
+ 'Content-Length': '0',
1889
+ },
1890
+ });
1891
+ const token = response.headers.get('x-csrf-token');
1892
+ if (!token)
1893
+ throw new Error('CSRF token not found in response headers');
1894
+ this.tokenMap.set(cookie, token);
1895
+ return token;
1896
+ }
1897
+ finally {
1898
+ this.pendingMap.delete(cookie);
1899
+ }
1900
+ })();
1901
+ this.pendingMap.set(cookie, pending);
1902
+ return pending;
1903
+ }
1904
+ /**
1905
+ * 저장된 토큰을 반환하되, 없으면 자동으로 갱신 후 반환합니다.
1906
+ */
1907
+ async getOrRefresh(cookie) {
1908
+ const cached = this.get(cookie);
1909
+ if (cached)
1910
+ return cached;
1911
+ return this.refresh(cookie);
1912
+ }
1913
+ }
1914
+ // ----------------------------------------------------------------
1841
1915
  // Factory
1842
1916
  // ----------------------------------------------------------------
1843
1917
  function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1844
1918
  const cookiePool = cookiesList.map(cookie => ({ cookie, lastUsed: 0 }));
1919
+ const csrfManager = new CsrfTokenManager();
1845
1920
  function pickCookie() {
1846
1921
  const entry = cookiePool.reduce((a, b) => a.lastUsed < b.lastUsed ? a : b);
1847
1922
  entry.lastUsed = Date.now();
@@ -1854,6 +1929,47 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1854
1929
  });
1855
1930
  }
1856
1931
  const dataInterceptor = pickKey('data');
1932
+ // ----------------------------------------------------------------
1933
+ // CSRF 인터셉터 팩토리
1934
+ // ----------------------------------------------------------------
1935
+ // cookie를 인자로 받아 해당 쿠키 전용 CSRF 인터셉터를 생성합니다.
1936
+ // 동작 방식:
1937
+ // before → 캐시된 토큰이 있으면 X-CSRF-Token 헤더에 주입
1938
+ // onError → 403 응답이고 헤더에 x-csrf-token이 있으면
1939
+ // 토큰을 갱신하고 요청을 재시작(restart)
1940
+ function makeCsrfInterceptor(getCookie) {
1941
+ return vigor.builder.fetch.interceptors()
1942
+ .before(async (ctx, api) => {
1943
+ const cookie = getCookie();
1944
+ const token = await csrfManager.getOrRefresh(cookie);
1945
+ api.setHeaders({
1946
+ ...ctx.options.headers,
1947
+ 'X-CSRF-Token': token,
1948
+ });
1949
+ })
1950
+ .onError(async (ctx, api) => {
1951
+ // VigorFetchError + FETCH_FAILED + status 403 여부 확인
1952
+ const cause = ctx.error;
1953
+ if (cause instanceof VigorFetchError &&
1954
+ cause.code === 'FETCH_FAILED' &&
1955
+ cause.data?.status === 403) {
1956
+ const response = cause.data.response;
1957
+ const newToken = response.headers.get('x-csrf-token');
1958
+ const cookie = getCookie();
1959
+ if (newToken) {
1960
+ // 새 토큰 저장 후 재시도
1961
+ csrfManager.set(cookie, newToken);
1962
+ api.restart?.();
1963
+ }
1964
+ else {
1965
+ // 헤더에 토큰이 없으면 기존 토큰 무효화 후 강제 갱신 후 재시도
1966
+ csrfManager.invalidate(cookie);
1967
+ await csrfManager.refresh(cookie);
1968
+ api.restart?.();
1969
+ }
1970
+ }
1971
+ });
1972
+ }
1857
1973
  const cookieInterceptor = vigor.builder.fetch.interceptors()
1858
1974
  .before((ctx, api) => {
1859
1975
  api.setHeaders({
@@ -1868,6 +1984,9 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1868
1984
  'User-Agent': 'Roblox/WinInet',
1869
1985
  });
1870
1986
  });
1987
+ // CSRF가 필요한 POST 엔드포인트에 붙일 공용 인터셉터
1988
+ // pickCookie()로 현재 선택된 쿠키를 기준으로 토큰 관리
1989
+ const csrfInterceptor = makeCsrfInterceptor(pickCookie);
1871
1990
  const usersApi = vigor.fetch('https://users.roblox.com/v1')
1872
1991
  .interceptors(cookieInterceptor)
1873
1992
  .interceptors(winInetInterceptor)
@@ -1904,11 +2023,12 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1904
2023
  .retryConfig(c => c
1905
2024
  .settings(s => s.attempt(4))
1906
2025
  .algorithms(a => a.backoff().initial(500).multiplier(2)));
1907
- // friends.roblox.com — used for friend list lookups, sending friend
1908
- // requests, and unfriending. Same cookie + WinInet headers as usersApi.
2026
+ // friends.roblox.com — CSRF 인터셉터 포함
2027
+ // sendFriendRequest / unfriend POST라 CSRF 토큰 필요
1909
2028
  const friendsApi = vigor.fetch('https://friends.roblox.com/v1')
1910
2029
  .interceptors(cookieInterceptor)
1911
2030
  .interceptors(winInetInterceptor)
2031
+ .interceptors(csrfInterceptor) // ← CSRF 추가
1912
2032
  .retryConfig(c => c
1913
2033
  .settings(s => s.attempt(5))
1914
2034
  .algorithms(a => a.backoff().initial(500).multiplier(2)));
package/dist/index.mjs CHANGED
@@ -1836,10 +1836,85 @@ function partition(arr, pred) {
1836
1836
  return { pass, fail };
1837
1837
  }
1838
1838
  // ----------------------------------------------------------------
1839
+ // CSRF Token Manager
1840
+ // ----------------------------------------------------------------
1841
+ class CsrfTokenManager {
1842
+ // cookie → token 매핑 (쿠키별로 독립 토큰 관리)
1843
+ tokenMap = new Map();
1844
+ // 쿠키별 진행 중인 갱신 Promise (중복 갱신 방지)
1845
+ pendingMap = new Map();
1846
+ /**
1847
+ * 저장된 토큰을 반환합니다. 없으면 null.
1848
+ */
1849
+ get(cookie) {
1850
+ return this.tokenMap.get(cookie) ?? null;
1851
+ }
1852
+ /**
1853
+ * 토큰을 수동으로 저장합니다.
1854
+ * (403 응답 헤더의 x-csrf-token 값을 받아서 저장할 때 사용)
1855
+ */
1856
+ set(cookie, token) {
1857
+ this.tokenMap.set(cookie, token);
1858
+ }
1859
+ /**
1860
+ * 토큰을 무효화합니다.
1861
+ * (재시도 전 갱신이 필요할 때 호출)
1862
+ */
1863
+ invalidate(cookie) {
1864
+ this.tokenMap.delete(cookie);
1865
+ }
1866
+ /**
1867
+ * Roblox의 CSRF 토큰 갱신 방식:
1868
+ * POST /v1/logout 등 아무 인증 엔드포인트에 빈 body로 요청하면
1869
+ * 403 응답과 함께 x-csrf-token 헤더로 새 토큰을 내려줍니다.
1870
+ *
1871
+ * 동일 쿠키에 대해 동시에 여러 갱신 요청이 오면
1872
+ * 하나만 실제로 요청하고 나머지는 그 결과를 공유합니다.
1873
+ */
1874
+ async refresh(cookie) {
1875
+ const existing = this.pendingMap.get(cookie);
1876
+ if (existing)
1877
+ return existing;
1878
+ const pending = (async () => {
1879
+ try {
1880
+ // Roblox는 POST에 body가 없어도 403 + x-csrf-token을 내려줌
1881
+ const response = await fetch('https://auth.roblox.com/v2/logout', {
1882
+ method: 'POST',
1883
+ headers: {
1884
+ 'Cookie': `.ROBLOSECURITY=${cookie}`,
1885
+ 'User-Agent': 'Roblox/WinInet',
1886
+ 'Content-Length': '0',
1887
+ },
1888
+ });
1889
+ const token = response.headers.get('x-csrf-token');
1890
+ if (!token)
1891
+ throw new Error('CSRF token not found in response headers');
1892
+ this.tokenMap.set(cookie, token);
1893
+ return token;
1894
+ }
1895
+ finally {
1896
+ this.pendingMap.delete(cookie);
1897
+ }
1898
+ })();
1899
+ this.pendingMap.set(cookie, pending);
1900
+ return pending;
1901
+ }
1902
+ /**
1903
+ * 저장된 토큰을 반환하되, 없으면 자동으로 갱신 후 반환합니다.
1904
+ */
1905
+ async getOrRefresh(cookie) {
1906
+ const cached = this.get(cookie);
1907
+ if (cached)
1908
+ return cached;
1909
+ return this.refresh(cookie);
1910
+ }
1911
+ }
1912
+ // ----------------------------------------------------------------
1839
1913
  // Factory
1840
1914
  // ----------------------------------------------------------------
1841
1915
  function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1842
1916
  const cookiePool = cookiesList.map(cookie => ({ cookie, lastUsed: 0 }));
1917
+ const csrfManager = new CsrfTokenManager();
1843
1918
  function pickCookie() {
1844
1919
  const entry = cookiePool.reduce((a, b) => a.lastUsed < b.lastUsed ? a : b);
1845
1920
  entry.lastUsed = Date.now();
@@ -1852,6 +1927,47 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1852
1927
  });
1853
1928
  }
1854
1929
  const dataInterceptor = pickKey('data');
1930
+ // ----------------------------------------------------------------
1931
+ // CSRF 인터셉터 팩토리
1932
+ // ----------------------------------------------------------------
1933
+ // cookie를 인자로 받아 해당 쿠키 전용 CSRF 인터셉터를 생성합니다.
1934
+ // 동작 방식:
1935
+ // before → 캐시된 토큰이 있으면 X-CSRF-Token 헤더에 주입
1936
+ // onError → 403 응답이고 헤더에 x-csrf-token이 있으면
1937
+ // 토큰을 갱신하고 요청을 재시작(restart)
1938
+ function makeCsrfInterceptor(getCookie) {
1939
+ return vigor.builder.fetch.interceptors()
1940
+ .before(async (ctx, api) => {
1941
+ const cookie = getCookie();
1942
+ const token = await csrfManager.getOrRefresh(cookie);
1943
+ api.setHeaders({
1944
+ ...ctx.options.headers,
1945
+ 'X-CSRF-Token': token,
1946
+ });
1947
+ })
1948
+ .onError(async (ctx, api) => {
1949
+ // VigorFetchError + FETCH_FAILED + status 403 여부 확인
1950
+ const cause = ctx.error;
1951
+ if (cause instanceof VigorFetchError &&
1952
+ cause.code === 'FETCH_FAILED' &&
1953
+ cause.data?.status === 403) {
1954
+ const response = cause.data.response;
1955
+ const newToken = response.headers.get('x-csrf-token');
1956
+ const cookie = getCookie();
1957
+ if (newToken) {
1958
+ // 새 토큰 저장 후 재시도
1959
+ csrfManager.set(cookie, newToken);
1960
+ api.restart?.();
1961
+ }
1962
+ else {
1963
+ // 헤더에 토큰이 없으면 기존 토큰 무효화 후 강제 갱신 후 재시도
1964
+ csrfManager.invalidate(cookie);
1965
+ await csrfManager.refresh(cookie);
1966
+ api.restart?.();
1967
+ }
1968
+ }
1969
+ });
1970
+ }
1855
1971
  const cookieInterceptor = vigor.builder.fetch.interceptors()
1856
1972
  .before((ctx, api) => {
1857
1973
  api.setHeaders({
@@ -1866,6 +1982,9 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1866
1982
  'User-Agent': 'Roblox/WinInet',
1867
1983
  });
1868
1984
  });
1985
+ // CSRF가 필요한 POST 엔드포인트에 붙일 공용 인터셉터
1986
+ // pickCookie()로 현재 선택된 쿠키를 기준으로 토큰 관리
1987
+ const csrfInterceptor = makeCsrfInterceptor(pickCookie);
1869
1988
  const usersApi = vigor.fetch('https://users.roblox.com/v1')
1870
1989
  .interceptors(cookieInterceptor)
1871
1990
  .interceptors(winInetInterceptor)
@@ -1902,11 +2021,12 @@ function createRobloxApi({ cache, cookies: cookiesList, ipgeolocationKey, }) {
1902
2021
  .retryConfig(c => c
1903
2022
  .settings(s => s.attempt(4))
1904
2023
  .algorithms(a => a.backoff().initial(500).multiplier(2)));
1905
- // friends.roblox.com — used for friend list lookups, sending friend
1906
- // requests, and unfriending. Same cookie + WinInet headers as usersApi.
2024
+ // friends.roblox.com — CSRF 인터셉터 포함
2025
+ // sendFriendRequest / unfriend POST라 CSRF 토큰 필요
1907
2026
  const friendsApi = vigor.fetch('https://friends.roblox.com/v1')
1908
2027
  .interceptors(cookieInterceptor)
1909
2028
  .interceptors(winInetInterceptor)
2029
+ .interceptors(csrfInterceptor) // ← CSRF 추가
1910
2030
  .retryConfig(c => c
1911
2031
  .settings(s => s.attempt(5))
1912
2032
  .algorithms(a => a.backoff().initial(500).multiplier(2)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigor-roblox",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",