From 3d439839d9cd9f103dcf64cf436ad522f73e277e Mon Sep 17 00:00:00 2001 From: Diego Souza Date: Sun, 8 Feb 2026 16:45:31 +0700 Subject: [PATCH] feat(cloud): harden sync/auth flow, SSE fallback, and update changelog Co-authored-by: Cursor --- .env.example | 6 ++ CHANGELOG.md | 84 +++++++++++++++-- README.md | 62 ++++++++++++- open-sse/handlers/chatCore.js | 93 ++++++++++++++++++- .../dashboard/endpoint/EndpointPageClient.js | 61 ++++++------ src/app/api/auth/login/route.js | 7 +- src/app/api/cloud/auth/route.js | 4 +- src/app/api/sync/cloud/route.js | 68 ++++++++++---- src/shared/services/cloudSyncScheduler.js | 7 +- src/sse/handlers/chat.js | 29 +++++- 10 files changed, 356 insertions(+), 65 deletions(-) diff --git a/.env.example b/.env.example index 238a5b2..584bafb 100644 --- a/.env.example +++ b/.env.example @@ -14,9 +14,15 @@ NODE_ENV=production API_KEY_SECRET=endpoint-proxy-api-key-secret MACHINE_ID_SALT=endpoint-proxy-salt ENABLE_REQUEST_LOGS=false +AUTH_COOKIE_SECURE=false +REQUIRE_API_KEY=false # Cloud sync variables # Must point to this running instance so internal sync jobs can call /api/sync/cloud. +# Server-side preferred variables: +BASE_URL=http://localhost:20128 +CLOUD_URL=https://9router.com +# Backward-compatible/public variables: NEXT_PUBLIC_BASE_URL=http://localhost:20128 NEXT_PUBLIC_CLOUD_URL=https://9router.com diff --git a/CHANGELOG.md b/CHANGELOG.md index ed47eb8..1d9d3e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,86 @@ +# v0.2.66 (2026-02-06) + +## Features +- Added Cursor provider end-to-end support, including OAuth import flow and translator/executor integration (`137f315`, `0a026c7`). +- Enhanced auth/settings flow with `requireLogin` control and `hasPassword` state handling in dashboard/login APIs (`249fc28`). +- Improved usage/quota UX with richer provider limit cards, new quota table, and clearer reset/countdown display (`32aefe5`). +- Added model support for custom providers in UI/combos/model selection (`a7a52be`). +- Expanded model/provider catalog: + - Codex updates: GPT-5.3 support, translation fixes, thinking levels (`127475d`) + - Added Claude Opus 4.6 model (`e8aa3e2`) + - Added MiniMax Coding (CN) provider (`7c609d7`) + - Added iFlow Kimi K2.5 model (`9e357a7`) + - Updated CLI tools with Droid/OpenClaw cards and base URL visibility improvements (`a2122e3`) +- Added auto-validation for provider API keys when saving settings (`b275dfd`). +- Added Docker/runtime deployment docs and architecture documentation updates (`5e4a15b`). + +## Fixes +- Improved local-network compatibility by allowing auth cookie flow over HTTP deployments (`0a394d0`). +- Improved Antigravity quota/stream handling and Droid CLI compatibility behavior (`3c65e0c`, `c612741`, `8c6e3b8`). +- Fixed GitHub Copilot model mapping/selection issues (`95fd950`). +- Hardened local DB behavior with corrupt JSON recovery and schema-shape migration safeguards (`e6ef852`). +- Fixed logout/login edge cases: + - Prevent unintended auto-login after logout (`49df3dc`) + - Avoid infinite loading on failed `/api/settings` responses (`01c9410`) + +# v0.2.56 (2026-02-04) + +## Features +- Added Anthropic-compatible provider support across providers API/UI flow (`da5bdef`). +- Added provider icons to dashboard provider pages/lists (`60bd686`, `8ceb8f2`). +- Enhanced usage tracking pipeline across response handlers/streams with buffered accounting improvements (`a33924b`, `df0e1d6`, `7881db8`). + +## Fixes +- Fixed usage conversion and related provider limits presentation issues (`e6e44ac`). + +# v0.2.52 (2026-02-02) + +## Features +- Implemented Codex Cursor compatibility and Next.js 16 proxy migration updates (`e9b0a73`, `7b864a9`, `1c6dd6d`). +- Added OpenAI-compatible provider nodes with CRUD/validation/test coverage in API and UI (`0a28f9f`). +- Added token expiration and key-validity checks in provider test flow (`686585d`). +- Added Kiro token refresh support in shared token refresh service (`f2ca6f0`). +- Added non-streaming response translation support for multiple formats (`63f2da8`). +- Updated Kiro OAuth wiring and auth-related UI assets/components (`31cc79a`). + +## Fixes +- Fixed cloud translation/request compatibility path (`c7219d0`). +- Fixed Kiro auth modal/flow issues (`85b7bb9`). +- Included Antigravity stability fixes in translator/executor flow (`2393771`, `8c37b39`). + +# v0.2.43 (2026-01-27) + +## Fixes +- Fixed CLI tools model selection behavior (`a015266`). +- Fixed Kiro translator request handling (`d3dd868`). + +# v0.2.36 (2026-01-19) + +## Features +- Added the Usage dashboard page and related usage stats components (`3804357`). +- Integrated outbound proxy support in Open SSE fetch pipeline (`0943387`). +- Improved OpenAI compatibility and build stability across endpoint/profile/providers flows (`d9b8e48`). + +## Fixes +- Fixed combo fallback behavior (`e6ca119`). +- Resolved SonarQube findings, Next.js image warnings, and build/lint cleanups (`7058b06`, `0848dd5`). + +# v0.2.31 (2026-01-18) + +## Fixes +- Fixed Kiro token refresh and executor behavior (`6b22b1f`, `1d481c2`). +- Fixed Kiro request translation handling (`eff52f7`, `da15660`). + # v0.2.27 (2026-01-15) ## Features -- Added Kiro Provider with generous free quota +- Added Kiro provider support with OAuth flow (`26b61e5`). -## Bug Fixes -- Fixed Codex Provider bugs +## Fixes +- Fixed Codex provider behavior (`26b61e5`). # v0.2.21 (2026-01-12) ## Changes -- Update ReadMe -- Fix bug **antigravity** - +- README updates. +- Antigravity bug fixes. diff --git a/README.md b/README.md index fa3ff0f..641eca5 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,14 @@ Seamless translation between formats: - Secure encrypted storage - Access your setup from anywhere +#### Cloud Runtime Notes + +- Prefer server-side cloud variables in production: + - `BASE_URL` (internal callback URL used by sync scheduler) + - `CLOUD_URL` (cloud sync endpoint base) +- `NEXT_PUBLIC_BASE_URL` and `NEXT_PUBLIC_CLOUD_URL` are still supported for compatibility/UI, but server runtime now prioritizes `BASE_URL`/`CLOUD_URL`. +- Cloud sync requests now use timeout + fail-fast behavior to avoid UI hanging when cloud DNS/network is unavailable. + ### 📊 Usage Analytics - Track token usage per provider and model @@ -636,11 +644,15 @@ docker stop 9router && docker rm 9router | `PORT` | framework default | Service port (`20128` in examples) | | `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) | | `NODE_ENV` | runtime default | Set `production` for deploy | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Internal base URL used by cloud sync jobs | -| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Cloud sync endpoint base URL | +| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs | +| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) | | `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys | | `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing | | `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) | +| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) | | `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls | Notes: @@ -728,8 +740,19 @@ Notes: - Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` **Cloud sync errors** -- Verify `NEXT_PUBLIC_BASE_URL` points to your running instance -- Verify `NEXT_PUBLIC_CLOUD_URL` points to your expected cloud endpoint +- Verify `BASE_URL` points to your running instance (example: `http://localhost:20128`) +- Verify `CLOUD_URL` points to your expected cloud endpoint (example: `https://9router.com`) +- Keep `NEXT_PUBLIC_*` values aligned with server-side values when possible. + +**Cloud endpoint `stream=false` returns 500 (`Unexpected token 'd'...`)** +- Symptom usually appears on public cloud endpoint (`https://9router.com/v1`) for non-streaming calls. +- Root cause: upstream returns SSE payload (`data: ...`) while client expects JSON. +- Workaround: use `stream=true` for cloud direct calls. +- Local 9Router runtime includes SSE→JSON fallback for non-streaming calls when upstream returns `text/event-stream`. + +**Cloud says connected, but request still fails with `Invalid API key`** +- Create a fresh key from local dashboard (`/api/keys`) and run cloud sync (`Enable Cloud` then `Sync Now`). +- Old/non-synced keys can still return `401` on cloud even if local endpoint works. **First login not working** - Check `INITIAL_PASSWORD` in `.env` @@ -789,6 +812,37 @@ Authorization: Bearer your-api-key - `POST /v1beta/models/{...path}` (Gemini-style `generateContent`) - `POST /v1/api/chat` (Ollama-style transform path) +### Cloud Validation Scripts + +Added test scripts under `tester/security/`: + +- `tester/security/test-docker-hardening.sh` + - Builds Docker image and validates hardening checks (`/api/cloud/auth` auth guard, `REQUIRE_API_KEY`, secure auth cookie behavior). +- `tester/security/test-cloud-openai-compatible.sh` + - Sends a direct OpenAI-compatible request to cloud endpoint (`https://9router.com/v1/chat/completions`) with provided model/key. +- `tester/security/test-cloud-sync-and-call.sh` + - End-to-end flow: create local key -> enable/sync cloud -> call cloud endpoint with retry. + - Includes fallback check with `stream=true` to distinguish auth errors from non-streaming parse issues. + +Security note for cloud test scripts: + +- Never hardcode real API keys in scripts/commits. +- Provide keys only via environment variables: + - `API_KEY`, `CLOUD_API_KEY`, or `OPENAI_API_KEY` (supported by `test-cloud-openai-compatible.sh`) +- Example: + +```bash +OPENAI_API_KEY="your-cloud-key" bash tester/security/test-cloud-openai-compatible.sh +``` + +Expected behavior from recent validation: + +- Local runtime (`http://127.0.0.1:20128/v1/chat/completions`): works with `stream=false` and `stream=true`. +- Docker runtime (same API path exposed by container): hardening checks pass, cloud auth guard works, strict API key mode works when enabled. +- Public cloud endpoint (`https://9router.com/v1/chat/completions`): + - `stream=true`: expected to succeed (SSE chunks returned). + - `stream=false`: may fail with `500` + parse error (`Unexpected token 'd'`) when upstream returns SSE content to a non-streaming client path. + ### Dashboard and Management API - Auth/settings: `/api/auth/login`, `/api/auth/logout`, `/api/settings`, `/api/settings/require-login` diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index bd9d274..f502c4b 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -225,6 +225,81 @@ function extractUsageFromResponse(responseBody, provider) { return null; } +/** + * Convert OpenAI-style SSE chunks into a single non-streaming JSON response. + * Used as a fallback when upstream returns text/event-stream for stream=false. + */ +function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { + const lines = String(rawSSE || "").split("\n"); + const chunks = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + try { + chunks.push(JSON.parse(payload)); + } catch { + // Ignore malformed SSE lines and continue best-effort parsing. + } + } + + if (chunks.length === 0) return null; + + const first = chunks[0]; + const contentParts = []; + const reasoningParts = []; + let finishReason = "stop"; + let usage = null; + + for (const chunk of chunks) { + const choice = chunk?.choices?.[0]; + const delta = choice?.delta || {}; + + if (typeof delta.content === "string" && delta.content.length > 0) { + contentParts.push(delta.content); + } + if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { + reasoningParts.push(delta.reasoning_content); + } + if (choice?.finish_reason) { + finishReason = choice.finish_reason; + } + if (chunk?.usage && typeof chunk.usage === "object") { + usage = chunk.usage; + } + } + + const message = { + role: "assistant", + content: contentParts.join("") + }; + if (reasoningParts.length > 0) { + message.reasoning_content = reasoningParts.join(""); + } + + const result = { + id: first.id || `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: first.created || Math.floor(Date.now() / 1000), + model: first.model || fallbackModel || "unknown", + choices: [ + { + index: 0, + message, + finish_reason: finishReason + } + ] + }; + + if (usage) { + result.usage = usage; + } + + return result; +} + /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -406,7 +481,21 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Non-streaming response if (!stream) { trackPendingRequest(model, provider, connectionId, false); - const responseBody = await providerResponse.json(); + const contentType = providerResponse.headers.get("content-type") || ""; + let responseBody; + + if (contentType.includes("text/event-stream")) { + // Upstream returned SSE even though stream=false; convert best-effort to JSON. + const sseText = await providerResponse.text(); + const parsedFromSSE = parseSSEToOpenAIResponse(sseText, model); + if (!parsedFromSSE) { + appendRequestLog({ model, provider, connectionId, status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { }); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); + } + responseBody = parsedFromSSE; + } else { + responseBody = await providerResponse.json(); + } // Notify success - caller can clear error status if needed if (onRequestSuccess) { @@ -507,4 +596,4 @@ export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { if (!expiresAt) return false; const expiresAtMs = new Date(expiresAt).getTime(); return expiresAtMs - Date.now() < bufferMs; -} \ No newline at end of file +} diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 2df7d21..23b9253 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -6,6 +6,7 @@ import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +const CLOUD_ACTION_TIMEOUT_MS = 15000; export default function APIPageClient({ machineId }) { const [keys, setKeys] = useState([]); @@ -29,6 +30,28 @@ export default function APIPageClient({ machineId }) { loadCloudSettings(); }, []); + const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch("/api/sync/cloud", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, status: res.status, data }; + } catch (error) { + if (error?.name === "AbortError") { + return { ok: false, status: 408, data: { error: "Cloud request timeout" } }; + } + return { ok: false, status: 500, data: { error: error.message || "Cloud request failed" } }; + } finally { + clearTimeout(timeoutId); + } + }; + const loadCloudSettings = async () => { try { const res = await fetch("/api/settings"); @@ -67,14 +90,8 @@ export default function APIPageClient({ machineId }) { setCloudSyncing(true); setSyncStep("syncing"); try { - const res = await fetch("/api/sync/cloud", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "enable" }) - }); - - const data = await res.json(); - if (res.ok) { + const { ok, data } = await postCloudAction("enable"); + if (ok) { setSyncStep("verifying"); if (data.verified) { @@ -111,25 +128,19 @@ export default function APIPageClient({ machineId }) { try { // Step 1: Sync latest data from cloud - await fetch("/api/sync/cloud", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "sync" }) - }); + await postCloudAction("sync"); setSyncStep("disabling"); // Step 2: Disable cloud - const disableRes = await fetch("/api/sync/cloud", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "disable" }) - }); + const { ok, data } = await postCloudAction("disable"); - if (disableRes.ok) { + if (ok) { setCloudEnabled(false); setCloudStatus({ type: "success", message: "Cloud disabled" }); setShowDisableModal(false); + } else { + setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" }); } } catch (error) { console.log("Error disabling cloud:", error); @@ -145,14 +156,8 @@ export default function APIPageClient({ machineId }) { setCloudSyncing(true); try { - const res = await fetch("/api/sync/cloud", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "sync" }) - }); - - const data = await res.json(); - if (res.ok) { + const { ok, data } = await postCloudAction("sync"); + if (ok) { setCloudStatus({ type: "success", message: "Synced successfully" }); } else { setCloudStatus({ type: "error", message: data.error }); @@ -599,4 +604,4 @@ export default function APIPageClient({ machineId }) { APIPageClient.propTypes = { machineId: PropTypes.string.isRequired, -}; \ No newline at end of file +}; diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js index 03853b7..eda29f8 100644 --- a/src/app/api/auth/login/route.js +++ b/src/app/api/auth/login/route.js @@ -26,6 +26,11 @@ export async function POST(request) { } if (isValid) { + const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true"; + const forwardedProto = request.headers.get("x-forwarded-proto"); + const isHttpsRequest = forwardedProto === "https"; + const useSecureCookie = forceSecureCookie || isHttpsRequest; + const token = await new SignJWT({ authenticated: true }) .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("24h") @@ -34,7 +39,7 @@ export async function POST(request) { const cookieStore = await cookies(); cookieStore.set("auth_token", token, { httpOnly: true, - secure: false, // Allow HTTP for local network access + secure: useSecureCookie, sameSite: "lax", path: "/", }); diff --git a/src/app/api/cloud/auth/route.js b/src/app/api/cloud/auth/route.js index 0c4fa78..52abd5f 100644 --- a/src/app/api/cloud/auth/route.js +++ b/src/app/api/cloud/auth/route.js @@ -6,7 +6,7 @@ export async function POST(request) { try { const authHeader = request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { - // return NextResponse.json({ error: "Missing API key" }, { status: 401 }); + return NextResponse.json({ error: "Missing API key" }, { status: 401 }); } const apiKey = authHeader.slice(7); @@ -14,7 +14,7 @@ export async function POST(request) { // Validate API key const isValid = await validateApiKey(apiKey); if (!isValid) { - // return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); + return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); } // Get active provider connections diff --git a/src/app/api/sync/cloud/route.js b/src/app/api/sync/cloud/route.js index e29e56a..87dd9f9 100644 --- a/src/app/api/sync/cloud/route.js +++ b/src/app/api/sync/cloud/route.js @@ -5,7 +5,18 @@ import fs from "fs/promises"; import path from "path"; import os from "os"; -const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +const CLOUD_URL = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL; +const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000); + +async function fetchWithTimeout(url, options = {}, timeoutMs = CLOUD_SYNC_TIMEOUT_MS) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} /** * POST /api/sync/cloud @@ -54,28 +65,38 @@ export async function POST(request) { * @param {string|null} createdKey - Key created during enable */ export async function syncToCloud(machineId, createdKey = null) { + if (!CLOUD_URL) { + return { error: "NEXT_PUBLIC_CLOUD_URL is not configured" }; + } + // Get current data from db const providers = await getProviderConnections(); const modelAliases = await getModelAliases(); const combos = await getCombos(); const apiKeys = await getApiKeys(); - // Send to Cloud - const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - providers, - modelAliases, - combos, - apiKeys - }) - }); + let response; + try { + // Send to Cloud + response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providers, + modelAliases, + combos, + apiKeys + }) + }); + } catch (error) { + const isTimeout = error?.name === "AbortError"; + return { error: isTimeout ? "Cloud sync timeout" : "Cloud sync request failed" }; + } if (!response.ok) { const errorText = await response.text(); console.log("Cloud sync failed:", errorText); - return NextResponse.json({ error: "Cloud sync failed" }, { status: 502 }); + return { error: "Cloud sync failed" }; } const result = await response.json(); @@ -119,7 +140,7 @@ async function syncAndVerify(machineId, createdKey, existingKeys) { } try { - const pingResponse = await fetch(`${CLOUD_URL}/${machineId}/v1/verify`, { + const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, { method: "GET", headers: { "Authorization": `Bearer ${apiKey}`, @@ -152,9 +173,22 @@ async function syncAndVerify(machineId, createdKey, existingKeys) { * Disable Cloud - delete cache and update Claude CLI settings */ async function handleDisable(machineId, request) { - const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, { - method: "DELETE" - }); + if (!CLOUD_URL) { + return NextResponse.json({ error: "NEXT_PUBLIC_CLOUD_URL is not configured" }, { status: 500 }); + } + + let response; + try { + response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, { + method: "DELETE" + }); + } catch (error) { + const isTimeout = error?.name === "AbortError"; + return NextResponse.json( + { error: isTimeout ? "Cloud disable timeout" : "Failed to reach cloud service" }, + { status: 502 } + ); + } if (!response.ok) { const errorText = await response.text(); diff --git a/src/shared/services/cloudSyncScheduler.js b/src/shared/services/cloudSyncScheduler.js index 59848f5..44de248 100644 --- a/src/shared/services/cloudSyncScheduler.js +++ b/src/shared/services/cloudSyncScheduler.js @@ -1,6 +1,11 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { isCloudEnabled } from "@/lib/localDb"; +const INTERNAL_BASE_URL = + process.env.BASE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + "http://localhost:20128"; + /** * Cloud sync scheduler */ @@ -83,7 +88,7 @@ export class CloudSyncScheduler { await this.initializeMachineId(); // Call internal API route which handles both sync and token update - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"}/api/sync/cloud`, { + const response = await fetch(`${INTERNAL_BASE_URL}/api/sync/cloud`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ machineId: this.machineId, action: "sync" }) diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 096dc43..e38e7e4 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -1,4 +1,10 @@ -import { getProviderCredentials, markAccountUnavailable, clearAccountError } from "../services/auth.js"; +import { + getProviderCredentials, + markAccountUnavailable, + clearAccountError, + extractApiKey, + isValidApiKey, +} from "../services/auth.js"; import { getModelInfo, getComboModels } from "../services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; @@ -42,14 +48,29 @@ export async function handleChat(request, clientRawRequest = null) { log.request("POST", `${url.pathname} | ${modelStr} | ${msgCount} msgs${toolCount ? ` | ${toolCount} tools` : ""}${effort ? ` | effort=${effort}` : ""}`); // Log API key (masked) - const apiKey = request.headers.get("Authorization"); - if (apiKey) { - const masked = log.maskKey(apiKey.replace("Bearer ", "")); + const authHeader = request.headers.get("Authorization"); + const apiKey = extractApiKey(request); + if (authHeader && apiKey) { + const masked = log.maskKey(apiKey); log.debug("AUTH", `API Key: ${masked}`); } else { log.debug("AUTH", "No API key provided (local mode)"); } + // Optional strict API key mode for /v1 endpoints. + // Keep disabled by default to preserve local-mode compatibility. + if (process.env.REQUIRE_API_KEY === "true") { + if (!apiKey) { + log.warn("AUTH", "Missing API key while REQUIRE_API_KEY=true"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + log.warn("AUTH", "Invalid API key while REQUIRE_API_KEY=true"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + if (!modelStr) { log.warn("CHAT", "Missing model"); return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");