jh-web-gateway 2.0.1 → 2.1.1

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/README.md CHANGED
@@ -407,7 +407,7 @@ Point any OpenAI-compatible tool at:
407
407
  |------|-------------|
408
408
  | `--headless` | Launch Chrome without a visible window (requires prior login) |
409
409
  | `--port <n>` | Override the configured port |
410
- | `--pages <n>` | Max concurrent browser pages (default: 3) |
410
+ | `--pages <n>` | Max concurrent browser pages (default: 1) |
411
411
 
412
412
  ## Token Refresh
413
413
 
@@ -64,12 +64,15 @@ async function findOrOpenJhPage(browser) {
64
64
  }
65
65
 
66
66
  // src/infra/config.ts
67
- import { readFile, writeFile, mkdir } from "fs/promises";
67
+ import { readFile, writeFile, mkdir, chmod } from "fs/promises";
68
68
  import { homedir } from "os";
69
69
  import { join } from "path";
70
70
  function getConfigPath() {
71
71
  return join(homedir(), ".jh-gateway", "config.json");
72
72
  }
73
+ function getConfigDir() {
74
+ return join(homedir(), ".jh-gateway");
75
+ }
73
76
  function getDefaultConfig() {
74
77
  return {
75
78
  cdpUrl: "http://127.0.0.1:9222",
@@ -162,15 +165,13 @@ function validateConfig(raw) {
162
165
  }
163
166
  async function loadConfig() {
164
167
  const configPath = getConfigPath();
165
- const configDir = join(homedir(), ".jh-gateway");
166
168
  let raw;
167
169
  try {
168
170
  raw = await readFile(configPath, "utf8");
169
171
  } catch (err) {
170
172
  if (isNodeError(err) && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
171
173
  const defaults = getDefaultConfig();
172
- await mkdir(configDir, { recursive: true });
173
- await writeFile(configPath, JSON.stringify(defaults, null, 2), "utf8");
174
+ await saveConfig(defaults);
174
175
  return defaults;
175
176
  }
176
177
  throw err;
@@ -183,13 +184,19 @@ async function loadConfig() {
183
184
  `Config validation error: config file at ${configPath} contains malformed JSON`
184
185
  );
185
186
  }
186
- return validateConfig(parsed);
187
+ const validated = validateConfig(parsed);
188
+ await enforceConfigPermissions(configPath, getConfigDir());
189
+ return validated;
187
190
  }
188
191
  async function saveConfig(config) {
189
192
  const configPath = getConfigPath();
190
- const configDir = join(homedir(), ".jh-gateway");
191
- await mkdir(configDir, { recursive: true });
192
- await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
193
+ const configDir = getConfigDir();
194
+ await mkdir(configDir, { recursive: true, mode: 448 });
195
+ await writeFile(configPath, JSON.stringify(config, null, 2), {
196
+ encoding: "utf8",
197
+ mode: 384
198
+ });
199
+ await enforceConfigPermissions(configPath, configDir);
193
200
  }
194
201
  async function updateConfig(partial) {
195
202
  const current = await loadConfig();
@@ -202,9 +209,26 @@ async function updateConfig(partial) {
202
209
  function isNodeError(err) {
203
210
  return err instanceof Error && "code" in err;
204
211
  }
212
+ async function enforceConfigPermissions(configPath, configDir) {
213
+ await chmod(configDir, 448).catch((err) => {
214
+ const message = err instanceof Error ? err.message : String(err);
215
+ console.warn(`[config] Could not enforce secure directory permissions: ${message}`);
216
+ });
217
+ await chmod(configPath, 384).catch((err) => {
218
+ const message = err instanceof Error ? err.message : String(err);
219
+ console.warn(`[config] Could not enforce secure file permissions: ${message}`);
220
+ });
221
+ }
205
222
 
206
223
  // src/core/auth-capture.ts
207
224
  var JH_HOST = "chat.ai.jh.edu";
225
+ function isJhUrl(url) {
226
+ try {
227
+ return new URL(url).hostname === JH_HOST;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
208
232
  function getTokenExpiry(token) {
209
233
  try {
210
234
  const parts = token.split(".");
@@ -238,24 +262,15 @@ async function captureCredentials(cdpUrl, timeoutMs = 12e4) {
238
262
  page = await context.newPage();
239
263
  }
240
264
  const targetPage = page;
265
+ const routePattern = "**/*";
241
266
  return new Promise((resolve, reject) => {
242
267
  let settled = false;
243
- const timer = setTimeout(() => {
244
- if (settled) return;
245
- settled = true;
246
- reject(
247
- new Error(
248
- `Credential capture timed out after ${timeoutMs / 1e3}s. Please log in to chat.ai.jh.edu and send a message to trigger authentication.`
249
- )
250
- );
251
- }, timeoutMs);
252
- targetPage.route("**/*", async (route) => {
268
+ let cleanedUp = false;
269
+ const routeHandler = async (route) => {
253
270
  const request = route.request();
254
271
  const headers = await request.headers();
255
272
  const authHeader = headers["authorization"] ?? headers["Authorization"] ?? "";
256
- if (!settled && authHeader.startsWith("Bearer ") && request.url().includes(JH_HOST)) {
257
- settled = true;
258
- clearTimeout(timer);
273
+ if (!settled && authHeader.startsWith("Bearer ") && isJhUrl(request.url())) {
259
274
  try {
260
275
  const bearerToken = authHeader.slice("Bearer ".length).trim();
261
276
  const rawCookies = await targetPage.context().cookies();
@@ -271,14 +286,45 @@ async function captureCredentials(cdpUrl, timeoutMs = 12e4) {
271
286
  expiresAt
272
287
  };
273
288
  await updateConfig({ credentials: captured });
274
- resolve(captured);
289
+ settleSuccess(captured);
275
290
  } catch (err) {
276
- reject(err);
291
+ settleFailure(err);
277
292
  }
278
293
  }
279
- await route.continue();
280
- }).then(() => {
281
- if (!targetPage.url().includes(JH_HOST)) {
294
+ await route.continue().catch((err) => {
295
+ const message = err instanceof Error ? err.message : String(err);
296
+ console.warn(`[auth-capture] Route continuation failed: ${message}`);
297
+ });
298
+ };
299
+ const cleanup = async () => {
300
+ if (cleanedUp) return;
301
+ cleanedUp = true;
302
+ clearTimeout(timer);
303
+ await targetPage.unroute(routePattern, routeHandler).catch((err) => {
304
+ const message = err instanceof Error ? err.message : String(err);
305
+ console.warn(`[auth-capture] Route cleanup failed: ${message}`);
306
+ });
307
+ };
308
+ const settleSuccess = (captured) => {
309
+ if (settled) return;
310
+ settled = true;
311
+ void cleanup().finally(() => resolve(captured));
312
+ };
313
+ const settleFailure = (err) => {
314
+ if (settled) return;
315
+ settled = true;
316
+ const error = err instanceof Error ? err : new Error(String(err));
317
+ void cleanup().finally(() => reject(error));
318
+ };
319
+ const timer = setTimeout(() => {
320
+ settleFailure(
321
+ new Error(
322
+ `Credential capture timed out after ${timeoutMs / 1e3}s. Please log in to chat.ai.jh.edu and send a message to trigger authentication.`
323
+ )
324
+ );
325
+ }, timeoutMs);
326
+ targetPage.route(routePattern, routeHandler).then(() => {
327
+ if (!isJhUrl(targetPage.url())) {
282
328
  targetPage.goto(`https://${JH_HOST}`, { waitUntil: "commit" }).catch(() => {
283
329
  });
284
330
  } else {
@@ -286,11 +332,7 @@ async function captureCredentials(cdpUrl, timeoutMs = 12e4) {
286
332
  });
287
333
  }
288
334
  }).catch((err) => {
289
- if (!settled) {
290
- settled = true;
291
- clearTimeout(timer);
292
- reject(err);
293
- }
335
+ settleFailure(err);
294
336
  });
295
337
  });
296
338
  }
@@ -316,7 +358,7 @@ async function captureCredentialsActive(cdpUrl, timeoutMs = 3e4) {
316
358
  const request = route.request();
317
359
  const headers = await request.headers();
318
360
  const authHeader = headers["authorization"] ?? headers["Authorization"] ?? "";
319
- if (!settled && authHeader.startsWith("Bearer ") && request.url().includes(JH_HOST)) {
361
+ if (!settled && authHeader.startsWith("Bearer ") && isJhUrl(request.url())) {
320
362
  settled = true;
321
363
  clearTimeout(timer);
322
364
  try {
@@ -360,6 +402,7 @@ async function captureCredentialsActive(cdpUrl, timeoutMs = 3e4) {
360
402
 
361
403
  // src/infra/gateway-auth.ts
362
404
  import { randomBytes } from "crypto";
405
+ import { timingSafeEqual } from "crypto";
363
406
  function generateApiKey() {
364
407
  return `jh-local-${randomBytes(16).toString("hex")}`;
365
408
  }
@@ -385,7 +428,7 @@ function authMiddleware(config) {
385
428
  }
386
429
  if (mode === "bearer") {
387
430
  const expected = `Bearer ${token}`;
388
- if (authHeader !== expected) {
431
+ if (!safeEquals(authHeader, expected)) {
389
432
  return c.json(
390
433
  {
391
434
  error: {
@@ -400,7 +443,7 @@ function authMiddleware(config) {
400
443
  }
401
444
  } else if (mode === "basic") {
402
445
  const expected = `Basic ${Buffer.from(`gateway:${token}`).toString("base64")}`;
403
- if (authHeader !== expected) {
446
+ if (!safeEquals(authHeader, expected)) {
404
447
  return c.json(
405
448
  {
406
449
  error: {
@@ -417,6 +460,12 @@ function authMiddleware(config) {
417
460
  return next();
418
461
  };
419
462
  }
463
+ function safeEquals(actual, expected) {
464
+ const actualBuffer = Buffer.from(actual);
465
+ const expectedBuffer = Buffer.from(expected);
466
+ if (actualBuffer.length !== expectedBuffer.length) return false;
467
+ return timingSafeEqual(actualBuffer, expectedBuffer);
468
+ }
420
469
 
421
470
  // src/infra/types.ts
422
471
  var MODEL_ENDPOINT_MAP = {
@@ -1667,9 +1716,11 @@ var PagePool = class {
1667
1716
  maxPages;
1668
1717
  maxWaitMs;
1669
1718
  initPromise = null;
1719
+ pagesCreating = 0;
1720
+ warmedUp = false;
1670
1721
  constructor(options = {}) {
1671
1722
  this.targetUrl = options.targetUrl ?? "https://chat.ai.jh.edu";
1672
- this.maxPages = options.maxPages ?? 3;
1723
+ this.maxPages = options.maxPages ?? 1;
1673
1724
  this.maxWaitMs = options.maxWaitMs ?? 12e4;
1674
1725
  }
1675
1726
  /** Initialize the pool with an existing browser connection and seed page */
@@ -1700,11 +1751,20 @@ var PagePool = class {
1700
1751
  * Acquire a page for use. Creates new pages on-demand up to maxPages.
1701
1752
  * Note: We intentionally don't lock here — allowing multiple requests to
1702
1753
  * grab the same page and queue on it is actually faster than creating new pages.
1754
+ *
1755
+ * On first init (before any request succeeds), page scaling is disabled to
1756
+ * avoid opening new Chrome tabs that may redirect through SSO and hang.
1757
+ * Call `markWarmedUp()` after the first successful request to enable scaling.
1703
1758
  */
1704
1759
  async acquire() {
1705
1760
  let pooled = this.pages.find((p2) => !p2.inUse);
1706
- if (!pooled && this.pages.length < this.maxPages && this.browser) {
1707
- pooled = await this.createPage();
1761
+ if (!pooled && this.warmedUp && this.pages.length + this.pagesCreating < this.maxPages && this.browser) {
1762
+ this.pagesCreating++;
1763
+ try {
1764
+ pooled = await this.createPage();
1765
+ } finally {
1766
+ this.pagesCreating--;
1767
+ }
1708
1768
  }
1709
1769
  if (!pooled) {
1710
1770
  pooled = this.pages.reduce(
@@ -1718,9 +1778,20 @@ var PagePool = class {
1718
1778
  queue: p.queue,
1719
1779
  release: () => {
1720
1780
  p.inUse = false;
1781
+ if (!this.warmedUp) {
1782
+ this.warmedUp = true;
1783
+ console.log(`[PagePool] Warm-up complete \u2014 page scaling enabled (max ${this.maxPages})`);
1784
+ }
1721
1785
  }
1722
1786
  };
1723
1787
  }
1788
+ /** Mark the pool as warmed up, enabling page scaling. */
1789
+ markWarmedUp() {
1790
+ if (!this.warmedUp) {
1791
+ this.warmedUp = true;
1792
+ console.log(`[PagePool] Warm-up complete \u2014 page scaling enabled (max ${this.maxPages})`);
1793
+ }
1794
+ }
1724
1795
  async createPage() {
1725
1796
  if (!this.browser) {
1726
1797
  throw new Error("PagePool not initialized");
@@ -1731,15 +1802,27 @@ var PagePool = class {
1731
1802
  throw new Error("No browser context available");
1732
1803
  }
1733
1804
  const page = await context.newPage();
1734
- await page.goto(this.targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
1735
- console.log(`[PagePool] New page ready: ${page.url()}`);
1736
- const pooled = {
1737
- page,
1738
- queue: new RequestQueue(this.maxWaitMs),
1739
- inUse: false
1740
- };
1741
- this.pages.push(pooled);
1742
- return pooled;
1805
+ try {
1806
+ await page.goto(this.targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
1807
+ const finalUrl = page.url();
1808
+ if (!finalUrl.includes("chat.ai.jh.edu")) {
1809
+ throw new Error(
1810
+ `New page redirected away from target: ${finalUrl} \u2014 browser session may have expired, restart without --headless to re-login.`
1811
+ );
1812
+ }
1813
+ console.log(`[PagePool] New page ready: ${finalUrl}`);
1814
+ const pooled = {
1815
+ page,
1816
+ queue: new RequestQueue(this.maxWaitMs),
1817
+ inUse: false
1818
+ };
1819
+ this.pages.push(pooled);
1820
+ return pooled;
1821
+ } catch (err) {
1822
+ await page.close().catch(() => {
1823
+ });
1824
+ throw err;
1825
+ }
1743
1826
  }
1744
1827
  /** Close all pages except the seed page */
1745
1828
  async drain() {
@@ -1755,6 +1838,99 @@ var PagePool = class {
1755
1838
  }
1756
1839
  };
1757
1840
 
1841
+ // src/core/token-refresher.ts
1842
+ var CredentialHolder = class {
1843
+ creds = null;
1844
+ /** Returns the current credentials, or `null` if none have been set yet. */
1845
+ get() {
1846
+ return this.creds;
1847
+ }
1848
+ /** Atomically replaces the stored credentials. */
1849
+ set(creds) {
1850
+ this.creds = creds;
1851
+ }
1852
+ };
1853
+ function shouldRefresh(nowMs, expiresAt, thresholdMs) {
1854
+ return expiresAt * 1e3 - nowMs < thresholdMs;
1855
+ }
1856
+ var BACKOFF_DELAYS = [5e3, 15e3, 3e4];
1857
+ var TokenRefresher = class {
1858
+ credentialHolder;
1859
+ cdpUrl;
1860
+ checkIntervalMs;
1861
+ refreshBeforeExpiryMs;
1862
+ maxRetries;
1863
+ intervalId = null;
1864
+ constructor(credentialHolder, cdpUrl, options) {
1865
+ this.credentialHolder = credentialHolder;
1866
+ this.cdpUrl = cdpUrl;
1867
+ this.checkIntervalMs = options?.checkIntervalMs ?? 6e4;
1868
+ this.refreshBeforeExpiryMs = options?.refreshBeforeExpiryMs ?? 3e5;
1869
+ this.maxRetries = options?.maxRetries ?? 3;
1870
+ }
1871
+ /** Start the background check interval. */
1872
+ start() {
1873
+ if (this.intervalId !== null) return;
1874
+ this.intervalId = setInterval(() => {
1875
+ void this.checkAndRefresh();
1876
+ }, this.checkIntervalMs);
1877
+ }
1878
+ /** Stop the background check interval. */
1879
+ stop() {
1880
+ if (this.intervalId !== null) {
1881
+ clearInterval(this.intervalId);
1882
+ this.intervalId = null;
1883
+ }
1884
+ }
1885
+ /** Check if a refresh is needed and perform it. Returns true if refreshed. */
1886
+ async checkAndRefresh() {
1887
+ const creds = this.credentialHolder.get();
1888
+ if (!creds || !creds.expiresAt) {
1889
+ return false;
1890
+ }
1891
+ if (!shouldRefresh(Date.now(), creds.expiresAt, this.refreshBeforeExpiryMs)) {
1892
+ return false;
1893
+ }
1894
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
1895
+ try {
1896
+ const newCreds = await captureCredentialsActive(this.cdpUrl);
1897
+ const gatewayCreds = {
1898
+ bearerToken: newCreds.bearerToken,
1899
+ cookie: newCreds.cookie,
1900
+ userAgent: newCreds.userAgent,
1901
+ expiresAt: newCreds.expiresAt
1902
+ };
1903
+ this.credentialHolder.set(gatewayCreds);
1904
+ await updateConfig({ credentials: gatewayCreds });
1905
+ const expiryDate = new Date(newCreds.expiresAt * 1e3).toISOString();
1906
+ console.log(`[TokenRefresher] Credentials refreshed successfully. New expiry: ${expiryDate}`);
1907
+ return true;
1908
+ } catch (err) {
1909
+ const delay = BACKOFF_DELAYS[attempt] ?? BACKOFF_DELAYS[BACKOFF_DELAYS.length - 1];
1910
+ if (attempt < this.maxRetries - 1) {
1911
+ console.warn(
1912
+ `[TokenRefresher] Refresh attempt ${attempt + 1}/${this.maxRetries} failed, retrying in ${delay / 1e3}s...`
1913
+ );
1914
+ await this.sleep(delay);
1915
+ } else {
1916
+ const msg = err instanceof Error ? err.message : String(err);
1917
+ console.warn(
1918
+ `
1919
+ \u26A0\uFE0F [TokenRefresher] All ${this.maxRetries} refresh attempts failed. ${msg}
1920
+ Continuing with current credentials. If requests start failing with 401,
1921
+ restart with \`jh-gateway start\` (without --headless) to re-login.
1922
+ `
1923
+ );
1924
+ }
1925
+ }
1926
+ }
1927
+ return false;
1928
+ }
1929
+ sleep(ms) {
1930
+ return new Promise((resolve) => setTimeout(resolve, ms));
1931
+ }
1932
+ };
1933
+
1758
1934
  // src/infra/chrome-manager.ts
1759
1935
  import { existsSync } from "fs";
1760
1936
  import { execSync, spawn } from "child_process";
@@ -1996,99 +2172,6 @@ Expected locations for ${process.platform}:
1996
2172
  }
1997
2173
  };
1998
2174
 
1999
- // src/core/token-refresher.ts
2000
- var CredentialHolder = class {
2001
- creds = null;
2002
- /** Returns the current credentials, or `null` if none have been set yet. */
2003
- get() {
2004
- return this.creds;
2005
- }
2006
- /** Atomically replaces the stored credentials. */
2007
- set(creds) {
2008
- this.creds = creds;
2009
- }
2010
- };
2011
- function shouldRefresh(nowMs, expiresAt, thresholdMs) {
2012
- return expiresAt * 1e3 - nowMs < thresholdMs;
2013
- }
2014
- var BACKOFF_DELAYS = [5e3, 15e3, 3e4];
2015
- var TokenRefresher = class {
2016
- credentialHolder;
2017
- cdpUrl;
2018
- checkIntervalMs;
2019
- refreshBeforeExpiryMs;
2020
- maxRetries;
2021
- intervalId = null;
2022
- constructor(credentialHolder, cdpUrl, options) {
2023
- this.credentialHolder = credentialHolder;
2024
- this.cdpUrl = cdpUrl;
2025
- this.checkIntervalMs = options?.checkIntervalMs ?? 6e4;
2026
- this.refreshBeforeExpiryMs = options?.refreshBeforeExpiryMs ?? 3e5;
2027
- this.maxRetries = options?.maxRetries ?? 3;
2028
- }
2029
- /** Start the background check interval. */
2030
- start() {
2031
- if (this.intervalId !== null) return;
2032
- this.intervalId = setInterval(() => {
2033
- void this.checkAndRefresh();
2034
- }, this.checkIntervalMs);
2035
- }
2036
- /** Stop the background check interval. */
2037
- stop() {
2038
- if (this.intervalId !== null) {
2039
- clearInterval(this.intervalId);
2040
- this.intervalId = null;
2041
- }
2042
- }
2043
- /** Check if a refresh is needed and perform it. Returns true if refreshed. */
2044
- async checkAndRefresh() {
2045
- const creds = this.credentialHolder.get();
2046
- if (!creds || !creds.expiresAt) {
2047
- return false;
2048
- }
2049
- if (!shouldRefresh(Date.now(), creds.expiresAt, this.refreshBeforeExpiryMs)) {
2050
- return false;
2051
- }
2052
- for (let attempt = 0; attempt < this.maxRetries; attempt++) {
2053
- try {
2054
- const newCreds = await captureCredentialsActive(this.cdpUrl);
2055
- const gatewayCreds = {
2056
- bearerToken: newCreds.bearerToken,
2057
- cookie: newCreds.cookie,
2058
- userAgent: newCreds.userAgent,
2059
- expiresAt: newCreds.expiresAt
2060
- };
2061
- this.credentialHolder.set(gatewayCreds);
2062
- await updateConfig({ credentials: gatewayCreds });
2063
- const expiryDate = new Date(newCreds.expiresAt * 1e3).toISOString();
2064
- console.log(`[TokenRefresher] Credentials refreshed successfully. New expiry: ${expiryDate}`);
2065
- return true;
2066
- } catch (err) {
2067
- const delay = BACKOFF_DELAYS[attempt] ?? BACKOFF_DELAYS[BACKOFF_DELAYS.length - 1];
2068
- if (attempt < this.maxRetries - 1) {
2069
- console.warn(
2070
- `[TokenRefresher] Refresh attempt ${attempt + 1}/${this.maxRetries} failed, retrying in ${delay / 1e3}s...`
2071
- );
2072
- await this.sleep(delay);
2073
- } else {
2074
- const msg = err instanceof Error ? err.message : String(err);
2075
- console.warn(
2076
- `
2077
- \u26A0\uFE0F [TokenRefresher] All ${this.maxRetries} refresh attempts failed. ${msg}
2078
- Continuing with current credentials. If requests start failing with 401,
2079
- restart with \`jh-gateway start\` (without --headless) to re-login.
2080
- `
2081
- );
2082
- }
2083
- }
2084
- }
2085
- return false;
2086
- }
2087
- sleep(ms) {
2088
- return new Promise((resolve) => setTimeout(resolve, ms));
2089
- }
2090
- };
2091
-
2092
2175
  export {
2093
2176
  getChromeWebSocketUrl,
2094
2177
  connectToChrome,
@@ -2104,9 +2187,9 @@ export {
2104
2187
  isTokenExpired,
2105
2188
  startServer,
2106
2189
  PagePool,
2107
- ChromeManager,
2108
2190
  CredentialHolder,
2109
2191
  shouldRefresh,
2110
- TokenRefresher
2192
+ TokenRefresher,
2193
+ ChromeManager
2111
2194
  };
2112
- //# sourceMappingURL=chunk-J7PNSI42.js.map
2195
+ //# sourceMappingURL=chunk-7H2RJZN3.js.map