| 185 | } |
| 186 | |
| 187 | async _handleRequest(req, res) { |
| 188 | const authHeader = req.headers['authorization'] || ''; |
| 189 | const provided = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; |
| 190 | const provBuf = Buffer.from(provided, 'utf8'); |
| 191 | // Primary token plus any grace tokens from settings.json::proxy.previous_tokens. |
| 192 | // The grace list is the recovery path for the rare case where settings.json was |
| 193 | // wiped externally (logout, manual rm) while long-lived CC sessions still hold |
| 194 | // the pre-wipe token in their fork-time env. Operator writes the lost token into |
| 195 | // previous_tokens; once those sessions close, the operator can clear the array. |
| 196 | // Reading directly from settings.json (instead of an env shim) keeps the |
| 197 | // single source of truth on disk — no python bridge in the daemon hook. |
| 198 | // Defense in depth: even though start() filters non-strings before persisting, |
| 199 | // settings.json can be hand-edited between requests, so re-validate every read. |
| 200 | // A non-string slipping into Buffer.from below would throw ERR_INVALID_ARG_TYPE |
| 201 | // and unhandled-reject through the auth path. |
| 202 | const previous = readSettings().proxy?.previous_tokens; |
| 203 | const extras = Array.isArray(previous) |
| 204 | ? previous |
| 205 | .map((t) => (typeof t === 'string' ? t.trim() : '')) |
| 206 | .filter((t) => isValidReusableProxyToken(t)) |
| 207 | : []; |
| 208 | const candidates = [this.token, ...extras] |
| 209 | .filter((t) => isValidReusableProxyToken(t)) |
| 210 | .map((t) => t.trim()); |
| 211 | let valid = false; |
| 212 | for (const cand of candidates) { |
| 213 | const expBuf = Buffer.from(cand, 'utf8'); |
| 214 | if (provBuf.length === expBuf.length && crypto.timingSafeEqual(provBuf, expBuf)) { |
| 215 | valid = true; |
| 216 | break; |
| 217 | } |
| 218 | } |
| 219 | if (!valid) { |
| 220 | return sendJson(res, 401, { error: 'Unauthorized' }); |
| 221 | } |
| 222 | |
| 223 | const url = new URL(req.url, `http://127.0.0.1:${this.actualPort}`); |
| 224 | const routeKey = `${req.method} ${url.pathname}`; |
| 225 | |
| 226 | const paramMatch = this._matchRoute(req.method, url.pathname); |
| 227 | |
| 228 | if (!paramMatch) { |
| 229 | return sendJson(res, 404, { error: 'Not found', path: url.pathname }); |
| 230 | } |
| 231 | |
| 232 | const { handler, params } = paramMatch; |
| 233 | |
| 234 | try { |
| 235 | const body = (req.method === 'POST' || req.method === 'PUT') ? await parseBody(req) : {}; |
| 236 | const query = Object.fromEntries(url.searchParams); |
| 237 | const headers = req.headers; |
| 238 | const result = await handler({ body, query, params, headers }); |
| 239 | if (result && result.stream) { |
| 240 | await this._streamResponse(res, result); |
| 241 | } else { |
| 242 | sendJson(res, result.status || 200, result.body || result, result.headers); |
| 243 | } |
| 244 | } catch (err) { |