mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat(cloud): harden sync/auth flow, SSE fallback, and update changelog
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
84
CHANGELOG.md
84
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.
|
||||
|
||||
62
README.md
62
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`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: "/",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user