jh-web-gateway 2.0.1 → 2.1.0

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.
@@ -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,6 +1716,7 @@ var PagePool = class {
1667
1716
  maxPages;
1668
1717
  maxWaitMs;
1669
1718
  initPromise = null;
1719
+ pagesCreating = 0;
1670
1720
  constructor(options = {}) {
1671
1721
  this.targetUrl = options.targetUrl ?? "https://chat.ai.jh.edu";
1672
1722
  this.maxPages = options.maxPages ?? 3;
@@ -1703,8 +1753,13 @@ var PagePool = class {
1703
1753
  */
1704
1754
  async acquire() {
1705
1755
  let pooled = this.pages.find((p2) => !p2.inUse);
1706
- if (!pooled && this.pages.length < this.maxPages && this.browser) {
1707
- pooled = await this.createPage();
1756
+ if (!pooled && this.pages.length + this.pagesCreating < this.maxPages && this.browser) {
1757
+ this.pagesCreating++;
1758
+ try {
1759
+ pooled = await this.createPage();
1760
+ } finally {
1761
+ this.pagesCreating--;
1762
+ }
1708
1763
  }
1709
1764
  if (!pooled) {
1710
1765
  pooled = this.pages.reduce(
@@ -1731,15 +1786,27 @@ var PagePool = class {
1731
1786
  throw new Error("No browser context available");
1732
1787
  }
1733
1788
  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;
1789
+ try {
1790
+ await page.goto(this.targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
1791
+ const finalUrl = page.url();
1792
+ if (!finalUrl.includes("chat.ai.jh.edu")) {
1793
+ throw new Error(
1794
+ `New page redirected away from target: ${finalUrl} \u2014 browser session may have expired, restart without --headless to re-login.`
1795
+ );
1796
+ }
1797
+ console.log(`[PagePool] New page ready: ${finalUrl}`);
1798
+ const pooled = {
1799
+ page,
1800
+ queue: new RequestQueue(this.maxWaitMs),
1801
+ inUse: false
1802
+ };
1803
+ this.pages.push(pooled);
1804
+ return pooled;
1805
+ } catch (err) {
1806
+ await page.close().catch(() => {
1807
+ });
1808
+ throw err;
1809
+ }
1743
1810
  }
1744
1811
  /** Close all pages except the seed page */
1745
1812
  async drain() {
@@ -1755,6 +1822,99 @@ var PagePool = class {
1755
1822
  }
1756
1823
  };
1757
1824
 
1825
+ // src/core/token-refresher.ts
1826
+ var CredentialHolder = class {
1827
+ creds = null;
1828
+ /** Returns the current credentials, or `null` if none have been set yet. */
1829
+ get() {
1830
+ return this.creds;
1831
+ }
1832
+ /** Atomically replaces the stored credentials. */
1833
+ set(creds) {
1834
+ this.creds = creds;
1835
+ }
1836
+ };
1837
+ function shouldRefresh(nowMs, expiresAt, thresholdMs) {
1838
+ return expiresAt * 1e3 - nowMs < thresholdMs;
1839
+ }
1840
+ var BACKOFF_DELAYS = [5e3, 15e3, 3e4];
1841
+ var TokenRefresher = class {
1842
+ credentialHolder;
1843
+ cdpUrl;
1844
+ checkIntervalMs;
1845
+ refreshBeforeExpiryMs;
1846
+ maxRetries;
1847
+ intervalId = null;
1848
+ constructor(credentialHolder, cdpUrl, options) {
1849
+ this.credentialHolder = credentialHolder;
1850
+ this.cdpUrl = cdpUrl;
1851
+ this.checkIntervalMs = options?.checkIntervalMs ?? 6e4;
1852
+ this.refreshBeforeExpiryMs = options?.refreshBeforeExpiryMs ?? 3e5;
1853
+ this.maxRetries = options?.maxRetries ?? 3;
1854
+ }
1855
+ /** Start the background check interval. */
1856
+ start() {
1857
+ if (this.intervalId !== null) return;
1858
+ this.intervalId = setInterval(() => {
1859
+ void this.checkAndRefresh();
1860
+ }, this.checkIntervalMs);
1861
+ }
1862
+ /** Stop the background check interval. */
1863
+ stop() {
1864
+ if (this.intervalId !== null) {
1865
+ clearInterval(this.intervalId);
1866
+ this.intervalId = null;
1867
+ }
1868
+ }
1869
+ /** Check if a refresh is needed and perform it. Returns true if refreshed. */
1870
+ async checkAndRefresh() {
1871
+ const creds = this.credentialHolder.get();
1872
+ if (!creds || !creds.expiresAt) {
1873
+ return false;
1874
+ }
1875
+ if (!shouldRefresh(Date.now(), creds.expiresAt, this.refreshBeforeExpiryMs)) {
1876
+ return false;
1877
+ }
1878
+ for (let attempt = 0; attempt < this.maxRetries; attempt++) {
1879
+ try {
1880
+ const newCreds = await captureCredentialsActive(this.cdpUrl);
1881
+ const gatewayCreds = {
1882
+ bearerToken: newCreds.bearerToken,
1883
+ cookie: newCreds.cookie,
1884
+ userAgent: newCreds.userAgent,
1885
+ expiresAt: newCreds.expiresAt
1886
+ };
1887
+ this.credentialHolder.set(gatewayCreds);
1888
+ await updateConfig({ credentials: gatewayCreds });
1889
+ const expiryDate = new Date(newCreds.expiresAt * 1e3).toISOString();
1890
+ console.log(`[TokenRefresher] Credentials refreshed successfully. New expiry: ${expiryDate}`);
1891
+ return true;
1892
+ } catch (err) {
1893
+ const delay = BACKOFF_DELAYS[attempt] ?? BACKOFF_DELAYS[BACKOFF_DELAYS.length - 1];
1894
+ if (attempt < this.maxRetries - 1) {
1895
+ console.warn(
1896
+ `[TokenRefresher] Refresh attempt ${attempt + 1}/${this.maxRetries} failed, retrying in ${delay / 1e3}s...`
1897
+ );
1898
+ await this.sleep(delay);
1899
+ } else {
1900
+ const msg = err instanceof Error ? err.message : String(err);
1901
+ console.warn(
1902
+ `
1903
+ \u26A0\uFE0F [TokenRefresher] All ${this.maxRetries} refresh attempts failed. ${msg}
1904
+ Continuing with current credentials. If requests start failing with 401,
1905
+ restart with \`jh-gateway start\` (without --headless) to re-login.
1906
+ `
1907
+ );
1908
+ }
1909
+ }
1910
+ }
1911
+ return false;
1912
+ }
1913
+ sleep(ms) {
1914
+ return new Promise((resolve) => setTimeout(resolve, ms));
1915
+ }
1916
+ };
1917
+
1758
1918
  // src/infra/chrome-manager.ts
1759
1919
  import { existsSync } from "fs";
1760
1920
  import { execSync, spawn } from "child_process";
@@ -1996,99 +2156,6 @@ Expected locations for ${process.platform}:
1996
2156
  }
1997
2157
  };
1998
2158
 
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
2159
  export {
2093
2160
  getChromeWebSocketUrl,
2094
2161
  connectToChrome,
@@ -2104,9 +2171,9 @@ export {
2104
2171
  isTokenExpired,
2105
2172
  startServer,
2106
2173
  PagePool,
2107
- ChromeManager,
2108
2174
  CredentialHolder,
2109
2175
  shouldRefresh,
2110
- TokenRefresher
2176
+ TokenRefresher,
2177
+ ChromeManager
2111
2178
  };
2112
- //# sourceMappingURL=chunk-J7PNSI42.js.map
2179
+ //# sourceMappingURL=chunk-TNKXXCTQ.js.map