feat(cloud): harden sync/auth flow, SSE fallback, and update changelog

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Diego Souza
2026-02-08 16:45:31 +07:00
committed by decolua
co-authored by Cursor
parent 2e854bd4c9
commit 3d439839d9
10 changed files with 356 additions and 65 deletions
@@ -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,
};
};
+6 -1
View File
@@ -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: "/",
});
+2 -2
View File
@@ -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
+51 -17
View File
@@ -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();