feat(xiaomi-mimo): server-assisted desktop login, five account clusters, v2.6 models
Reproduce the MiMo Desktop login surface server-side so headless/Docker deployments can link a Xiaomi account without the Desktop client. The account session (passToken) is captured during the proxied login and stored per connection. - Five account clusters (cn/sgp/ams/ru/in): per-region mimo-server host and SSO sid, unknown region falls back to sgp - mimo-v2.6-pro/flash/pro-ultraspeed dual-route models: account-service route when desktop credentials exist, cloud API (sk- key) otherwise; drops obsolete mimo-x-*-preview ids - Desktop ServiceTokenManager 2-phase handshake (single serviceLogin with target sid, raw 64-bit nonce preserved), per-region session cache - reasoning_effort bridged to output_config.effort; i18n runtime now observes characterData mutations so React text rewrites get translated - Security hardening on the login proxy: session travels only in the httpOnly cookie (never in the URL), proxy branch requires dashboard auth, authorization/proxy-authorization never forwarded upstream, and upstream Set-Cookie is not replayed onto the app origin
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
import { DefaultExecutor } from "./default.js";
|
import { DefaultExecutor } from "./default.js";
|
||||||
import { getMimoAccountCookie, invalidateMimoAccountCookieCache, MIMO_API_BASE, MIMO_API_UA } from "../shared/mimoAccount.js";
|
import { getMimoAccountCookie, invalidateMimoAccountCookieCache, resolveMimoServerBase, MIMO_API_UA } from "../shared/mimoAccount.js";
|
||||||
|
|
||||||
// Desktop-exclusive Preview models. These are served by the account service's
|
// Dual-route v2.6 models.
|
||||||
// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key).
|
// v2.6 models dynamically route to the account service when desktop session credentials
|
||||||
// See shared/mimoAccount.js for the session handshake.
|
// (mimoPassToken or account cookie) are present to consume weekly quota, falling back to
|
||||||
const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]);
|
// the cloud API (sk- key) otherwise.
|
||||||
|
const ACCOUNT_MODELS = new Set([
|
||||||
|
"mimo-v2.6-pro",
|
||||||
|
"mimo-v2.6-flash",
|
||||||
|
"mimo-v2.6-pro-ultraspeed",
|
||||||
|
]);
|
||||||
|
|
||||||
// Session cookie resolved in execute() (async) and read back by buildHeaders()
|
// Session cookie resolved in execute() (async) and read back by buildHeaders()
|
||||||
// (sync — BaseExecutor.execute does not await it). Carried on the per-request
|
// (sync — BaseExecutor.execute does not await it). Carried on the per-request
|
||||||
@@ -23,15 +28,24 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
super("xiaomi-mimo");
|
super("xiaomi-mimo");
|
||||||
}
|
}
|
||||||
|
|
||||||
static isPreviewModel(model) {
|
static isAccountRoute(model, credentials) {
|
||||||
return PREVIEW_MODELS.has(bareModel(model));
|
const bare = bareModel(model);
|
||||||
|
if (!ACCOUNT_MODELS.has(bare)) return false;
|
||||||
|
return Boolean(
|
||||||
|
credentials?.[COOKIE_KEY] ||
|
||||||
|
credentials?.providerSpecificData?.mimoPassToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isAccountRoute(model, credentials) {
|
||||||
|
return XiaomiMimoExecutor.isAccountRoute(model, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||||
// Preview models live on the account-service route, which is not one of the
|
// Account route models live on the account-service route, which is not one of the
|
||||||
// declared transports — resolve it before the default runtimeTransport path.
|
// declared transports — resolve it before the default runtimeTransport path.
|
||||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
if (this.isAccountRoute(model, credentials)) {
|
||||||
return `${MIMO_API_BASE}/api/route/chat/completions`;
|
return `${resolveMimoServerBase(credentials?.providerSpecificData)}/api/route/chat/completions`;
|
||||||
}
|
}
|
||||||
// Cloud API models keep default handling, so a Claude-format client reaches
|
// Cloud API models keep default handling, so a Claude-format client reaches
|
||||||
// the /anthropic/v1/messages transport.
|
// the /anthropic/v1/messages transport.
|
||||||
@@ -39,8 +53,8 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildHeaders(credentials, stream = true, url, model) {
|
buildHeaders(credentials, stream = true, url, model) {
|
||||||
if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) {
|
if (this.isAccountRoute(model, credentials) && credentials?.[COOKIE_KEY]) {
|
||||||
// Preview models authenticate with the account-session cookie, not the key.
|
// Account route models authenticate with the account-session cookie, not the key.
|
||||||
return {
|
return {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: stream ? "text/event-stream" : "application/json",
|
Accept: stream ? "text/event-stream" : "application/json",
|
||||||
@@ -52,17 +66,22 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
// super runs stripUnsupportedParams, which flattens Preview content-part
|
// super runs stripUnsupportedParams, which flattens content-part
|
||||||
// arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js).
|
// arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js).
|
||||||
const out = super.transformRequest(model, body, stream, credentials);
|
const out = super.transformRequest(model, body, stream, credentials);
|
||||||
|
|
||||||
// Preview models: thinking/params get defaults only — never override what the
|
// Account route models: bridge reasoning_effort to official output_config.effort
|
||||||
// caller set explicitly. (body.model is already `xiaomi/<id>` via upstreamModelId.)
|
// (matches MiMo Desktop app.asar behavior).
|
||||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
if (this.isAccountRoute(model, credentials)) {
|
||||||
if (out.thinking == null) out.thinking = { type: "enabled" };
|
const rawEffort = out.reasoning_effort || body?.reasoning_effort || body?.output_config?.effort;
|
||||||
|
if (rawEffort) {
|
||||||
|
delete out.reasoning_effort;
|
||||||
|
const norm = String(rawEffort).toLowerCase() === "xhigh" ? "high" : String(rawEffort).toLowerCase();
|
||||||
|
out.output_config = { ...(out.output_config || {}), effort: norm };
|
||||||
|
}
|
||||||
|
|
||||||
if (out.temperature == null) out.temperature = 1.0;
|
if (out.temperature == null) out.temperature = 1.0;
|
||||||
if (out.top_p == null) out.top_p = 0.95;
|
if (out.top_p == null) out.top_p = 0.95;
|
||||||
if (!out.max_tokens) out.max_tokens = 4096;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
@@ -70,13 +89,11 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
|
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const { model, credentials, proxyOptions = null } = args;
|
const { model, credentials, proxyOptions = null } = args;
|
||||||
if (!XiaomiMimoExecutor.isPreviewModel(model)) return super.execute(args);
|
if (!this.isAccountRoute(model, credentials)) return super.execute(args);
|
||||||
|
|
||||||
const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions);
|
const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions);
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
throw new Error(
|
return super.execute(args);
|
||||||
"Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
credentials[COOKIE_KEY] = cookie;
|
credentials[COOKIE_KEY] = cookie;
|
||||||
const result = await super.execute(args);
|
const result = await super.execute(args);
|
||||||
@@ -94,6 +111,6 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __test__ = { PREVIEW_MODELS, bareModel, COOKIE_KEY };
|
export const __test__ = { ACCOUNT_MODELS, bareModel, COOKIE_KEY };
|
||||||
|
|
||||||
export default XiaomiMimoExecutor;
|
export default XiaomiMimoExecutor;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { CLAUDE_API_HEADERS } from "../shared.js";
|
|||||||
|
|
||||||
// Dual auth (same pattern as kimi):
|
// Dual auth (same pattern as kimi):
|
||||||
// - API key (sk-...) → cloud API on api.xiaomimimo.com
|
// - API key (sk-...) → cloud API on api.xiaomimimo.com
|
||||||
// - Desktop account/OAuth → same cloud host, plus the Desktop-exclusive Preview
|
// - Desktop account/OAuth → same cloud host, plus the dual-route v2.6 models
|
||||||
// models served by the account-service route on mimo-server-cn.xiaomimimo.com
|
// served by the account-service route (mimo-server-<cluster>.xiaomimimo.com),
|
||||||
// (authorized by a Xiaomi account session cookie, not the key).
|
// authorized by a Xiaomi account session cookie, not the key.
|
||||||
// Endpoint is picked per model in the executor, same as opencode-go's /responses split.
|
// Endpoint is picked per model in the executor, same as opencode-go's /responses split.
|
||||||
export default {
|
export default {
|
||||||
id: "xiaomi-mimo",
|
id: "xiaomi-mimo",
|
||||||
@@ -30,6 +30,16 @@ export default {
|
|||||||
category: "oauth",
|
category: "oauth",
|
||||||
authModes: ["oauth", "apikey"],
|
authModes: ["oauth", "apikey"],
|
||||||
hasOAuth: true,
|
hasOAuth: true,
|
||||||
|
// Keys are cluster-specific. MiMo Desktop declares five regions
|
||||||
|
// (CN/SGP/AMS/RU/IN) — host + sid follow mimo-server-<code> / mimo<code>.
|
||||||
|
regions: [
|
||||||
|
{ id: "cn", label: "China (中国大陆)" },
|
||||||
|
{ id: "sgp", label: "Singapore (新加坡)" },
|
||||||
|
{ id: "ams", label: "Europe · Amsterdam (欧洲)" },
|
||||||
|
{ id: "ru", label: "Russia (俄罗斯)" },
|
||||||
|
{ id: "in", label: "India (印度)" },
|
||||||
|
],
|
||||||
|
defaultRegion: "sgp",
|
||||||
serviceKinds: ["llm", "tts"],
|
serviceKinds: ["llm", "tts"],
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
||||||
@@ -50,10 +60,10 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
models: [
|
models: [
|
||||||
// Desktop-exclusive — served by the account-service route, which only accepts
|
// Cloud API & Desktop dual-route models (prefers the desktop account quota when available)
|
||||||
// OpenAI format, so supportedFormats pins them to the openai transport.
|
{ id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", upstreamModelId: "xiaomi/mimo-v2.6-pro", supportedFormats: ["openai"] },
|
||||||
{ id: "mimo-x-pro-preview", name: "MiMo-X-Pro-Preview", upstreamModelId: "xiaomi/mimo-x-pro-preview", supportedFormats: ["openai"] },
|
{ id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", upstreamModelId: "xiaomi/mimo-v2.6-flash", supportedFormats: ["openai"] },
|
||||||
{ id: "mimo-x-flash-preview", name: "MiMo-X-Flash-Preview", upstreamModelId: "xiaomi/mimo-x-flash-preview", supportedFormats: ["openai"] },
|
{ id: "mimo-v2.6-pro-ultraspeed", name: "MiMo V2.6 Pro UltraSpeed", upstreamModelId: "xiaomi/mimo-v2.6-pro-ultraspeed", supportedFormats: ["openai"] },
|
||||||
// Cloud API models (api.xiaomimimo.com/v1)
|
// Cloud API models (api.xiaomimimo.com/v1)
|
||||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||||
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const PATTERN_THINKING = [
|
|||||||
{ provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] },
|
{ provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] },
|
||||||
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
|
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
|
||||||
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
|
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
|
||||||
|
{ pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] },
|
||||||
// DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max
|
// DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max
|
||||||
// all 200 via output_config.effort; "none" is a 400 on the anthropic route
|
// all 200 via output_config.effort; "none" is a 400 on the anthropic route
|
||||||
// (disable thinking instead). none kept for the picker = disable.
|
// (disable thinking instead). none kept for the picker = disable.
|
||||||
|
|||||||
@@ -8,20 +8,42 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
|||||||
* Xiaomi MiMo account-session helpers (used for weekly quota).
|
* Xiaomi MiMo account-session helpers (used for weekly quota).
|
||||||
*
|
*
|
||||||
* The weekly quota endpoint lives on the account service domain and is authorized
|
* The weekly quota endpoint lives on the account service domain and is authorized
|
||||||
* by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors
|
* by an account session cookie, NOT the sk- API key. Acquiring that cookie is a
|
||||||
* MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via
|
* 1:1 port of MiMo Desktop's ServiceTokenManager (app.asar) — the GOLD STANDARD:
|
||||||
* the passportapi SSO, then authorized for the `mimopc` service, and finally stamped
|
|
||||||
* by the mimo-server /api/sts callback into a `serviceToken` cookie.
|
|
||||||
*
|
*
|
||||||
* Flow (verified against MiMo Desktop traffic):
|
* getServiceToken(sid) / refreshServiceToken(sid):
|
||||||
* 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc)
|
* PHASE 1: GET https://account.xiaomi.com/pass/serviceLogin
|
||||||
* 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity
|
* ?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
|
||||||
* 3. GET {location}&clientSign=... -> account-level serviceToken
|
* Cookie: {userId, passToken, cUserId}
|
||||||
* 4. GET account /pass/serviceLogin?sid=mimopc&callback=<sts>&_json=true
|
* -> {code, location, ssecurity, nonce, bSecondValidation, notificationUrl}
|
||||||
* 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope)
|
* -> code !== 0 is an error (never silent)
|
||||||
|
* PHASE 2: GET {location}&clientSign=sha1(nonce & ssecurity), follow the
|
||||||
|
* redirect chain absorbing Set-Cookie -> serviceToken
|
||||||
|
*
|
||||||
|
* sid is per-cluster (SID_BY_REGION): CN = mimopc, SGP = mimosgp.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE = "https://mimo-server-cn.xiaomimimo.com";
|
// Account-service cluster hosts. MiMo Desktop declares five regions
|
||||||
|
// (rn = {CN, SGP, RU, IN, EU}); the EU cluster is deployed in Amsterdam.
|
||||||
|
// Host + sid naming is unified: mimo-server-<code> / sid = mimo<code>
|
||||||
|
// (ams is the only non-country code). Verified live via /api/user/xiaomi/me.
|
||||||
|
const API_BASE_BY_REGION = {
|
||||||
|
cn: "https://mimo-server-cn.xiaomimimo.com",
|
||||||
|
sgp: "https://mimo-server-sgp.xiaomimimo.com",
|
||||||
|
ams: "https://mimo-server-ams.xiaomimimo.com",
|
||||||
|
ru: "https://mimo-server-ru.xiaomimimo.com",
|
||||||
|
in: "https://mimo-server-in.xiaomimimo.com",
|
||||||
|
};
|
||||||
|
const DEFAULT_API_BASE = API_BASE_BY_REGION.sgp;
|
||||||
|
|
||||||
|
// Cluster service sid — 1:1 with the host code: mimo<code>.
|
||||||
|
// Unknown/absent region falls back to SGP (the international/open cluster).
|
||||||
|
const SID_BY_REGION = { cn: "mimopc", sgp: "mimosgp", ams: "mimoams", ru: "mimoru", in: "mimoin" };
|
||||||
|
function sidForRegion(region) {
|
||||||
|
const r = String(region || "").toLowerCase();
|
||||||
|
return SID_BY_REGION[r] || SID_BY_REGION.sgp;
|
||||||
|
}
|
||||||
|
const API_BASE = DEFAULT_API_BASE;
|
||||||
const ACCOUNT_HOST = "account.xiaomi.com";
|
const ACCOUNT_HOST = "account.xiaomi.com";
|
||||||
const API_UA =
|
const API_UA =
|
||||||
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||||
@@ -112,65 +134,106 @@ function cookieHeader(jar) {
|
|||||||
.join("; ");
|
.join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the account-service base URL for a connection.
|
||||||
|
* @param {object|null} providerSpecificData - may carry `region` ("cn"|"sgp"|"ams"|"ru"|"in")
|
||||||
|
*/
|
||||||
|
export function resolveMimoServerBase(providerSpecificData = null) {
|
||||||
|
const region = String(providerSpecificData?.region || "").toLowerCase();
|
||||||
|
return API_BASE_BY_REGION[region] || DEFAULT_API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange a passToken for a mimo-server service session cookie.
|
* Exchange a passToken for a mimo-server service session cookie.
|
||||||
|
* Primary path mirrors the Desktop ServiceTokenManager (app.asar):
|
||||||
|
* PHASE 1: GET /pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
|
||||||
|
* Cookie {userId,passToken,cUserId} -> {code,location,ssecurity,nonce}
|
||||||
|
* PHASE 2: GET {location}&clientSign=sha1(nonce&ssecurity), follow the chain
|
||||||
|
* (manual, absorbing Set-Cookie) -> serviceToken
|
||||||
|
* sid is per-cluster (SID_BY_REGION): cn=mimopc, sgp=mimosgp, ams=mimoams, ru=mimoru, in=mimoin.
|
||||||
* @returns {Promise<string|null>} Cookie header value, or null on failure.
|
* @returns {Promise<string|null>} Cookie header value, or null on failure.
|
||||||
*/
|
*/
|
||||||
async function acquireServiceCookie(passJar, proxyOptions) {
|
async function acquireServiceCookie(passJar, proxyOptions, apiBase = DEFAULT_API_BASE, region = "sgp") {
|
||||||
|
const r = String(region || "").toLowerCase();
|
||||||
|
// Hard constraint: CN is ALWAYS direct (ignores proxy even if set)
|
||||||
|
const effectiveProxy = r === "cn" ? null : proxyOptions;
|
||||||
|
const sid = sidForRegion(r);
|
||||||
|
const viaDesktop = await acquireViaDesktopPhases(passJar, effectiveProxy, apiBase, sid);
|
||||||
|
if (viaDesktop) console.log(`[mimoAccount] desktop 2-phase OK (sid=${sid})`);
|
||||||
|
return viaDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireViaDesktopPhases(passJar, proxyOptions, apiBase, sid) {
|
||||||
|
const failLog = (reason) => console.log(`[mimoAccount] desktopPhase fail: ${reason}`);
|
||||||
const jar = { ...passJar };
|
const jar = { ...passJar };
|
||||||
const ck = () => cookieHeader(jar);
|
|
||||||
|
|
||||||
// 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc)
|
// PHASE 1 — single serviceLogin call with the TARGET sid (no passportapi
|
||||||
const r1 = await proxyAwareFetch(
|
// prelude; ssecurity/nonce come straight from this response).
|
||||||
`${API_BASE}/api/user/xiaomi/me`,
|
// Desktop only sends: userId, passToken, cUserId (no extra cookies)
|
||||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
const p1Jar = {};
|
||||||
|
if (jar.userId) p1Jar.userId = jar.userId;
|
||||||
|
if (jar.passToken) p1Jar.passToken = jar.passToken;
|
||||||
|
if (jar.cUserId) p1Jar.cUserId = jar.cUserId;
|
||||||
|
|
||||||
|
const p1Url = `https://${ACCOUNT_HOST}/pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=${encodeURIComponent(sid)}&_json=true`;
|
||||||
|
const p1 = await proxyAwareFetch(
|
||||||
|
p1Url,
|
||||||
|
{ headers: { Cookie: cookieHeader(p1Jar), "User-Agent": SSO_UA, Accept: "application/json" } },
|
||||||
proxyOptions,
|
proxyOptions,
|
||||||
);
|
);
|
||||||
const redirect = r1.headers.get("location");
|
const raw = await p1.text();
|
||||||
if (!redirect) return null;
|
const clean = raw.replace(/^&&&START&&&/, "");
|
||||||
const stsCallback = new URL(redirect).searchParams.get("callback");
|
// Nonce > 2^53 loses precision in JSON.parse — extract raw literal for signing
|
||||||
if (!stsCallback) return null;
|
const rawNonce = clean.match(/"nonce"\s*:\s*(\d+)/)?.[1];
|
||||||
|
let j = null;
|
||||||
|
try { j = JSON.parse(clean); } catch { /* handled below */ }
|
||||||
|
if (rawNonce && j) j.nonce = rawNonce;
|
||||||
|
|
||||||
// 2. passportapi SSO phase 1 -> nonce + ssecurity
|
if (!j || typeof j.code !== "number" || j.code !== 0 || !j.location || !j.nonce || !j.ssecurity) {
|
||||||
const sso1 = await proxyAwareFetch(
|
failLog(
|
||||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`,
|
`phase1 sid=${sid} http=${p1.status} code=${j?.code ?? "?"} hasLoc=${!!j?.location}`
|
||||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
+ ` secondValidation=${j?.bSecondValidation ?? "?"} notificationUrl=${j?.notificationUrl ? "present" : "no"}`
|
||||||
proxyOptions,
|
+ ` body=${JSON.stringify(raw.slice(0, 200))}`,
|
||||||
);
|
);
|
||||||
const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, ""));
|
return null;
|
||||||
const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null);
|
}
|
||||||
if (!nonce || !j1.location) return null;
|
absorbSetCookie(jar, p1);
|
||||||
|
|
||||||
// 3. passportapi SSO phase 2 -> account-level serviceToken
|
// PHASE 2 — clientSign the redirect, follow the redirect chain server-side.
|
||||||
const sso2 = await proxyAwareFetch(
|
// ⚠️ CRITICAL DESKTOP SPEC (app.asar / SSO_curl.cpp line 728: cookies.clear()):
|
||||||
`${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`,
|
// Phase 2 MUST NOT send ANY Cookie header! The server returns 200 OK with Set-Cookie: serviceToken!
|
||||||
{ redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } },
|
const sep = j.location.includes("?") ? "&" : "?";
|
||||||
proxyOptions,
|
let current = `${j.location}${sep}clientSign=${signatureClientSign(rawNonce || j.nonce, j.ssecurity)}`;
|
||||||
);
|
|
||||||
absorbSetCookie(jar, sso2);
|
|
||||||
|
|
||||||
// 4. mimopc SSO -> sts callback carrying a ticket
|
for (let hop = 0; hop < 8; hop++) {
|
||||||
const sso3 = await proxyAwareFetch(
|
const res = await proxyAwareFetch(
|
||||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`,
|
current,
|
||||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
{ redirect: "manual", headers: { "User-Agent": SSO_UA } },
|
||||||
proxyOptions,
|
proxyOptions,
|
||||||
);
|
);
|
||||||
const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, ""));
|
absorbSetCookie(jar, res);
|
||||||
absorbSetCookie(jar, sso3);
|
const loc = res.headers.get("location");
|
||||||
if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null;
|
if (res.status >= 300 && res.status < 400 && loc) {
|
||||||
|
current = new URL(loc, current).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// 5. sts callback -> Set-Cookie: serviceToken (mimopc scope)
|
const sidKey = `${sid}_serviceToken`;
|
||||||
const sts = await proxyAwareFetch(
|
if (!jar.serviceToken && jar[sidKey]) {
|
||||||
j3.location,
|
jar.serviceToken = jar[sidKey];
|
||||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
}
|
||||||
proxyOptions,
|
|
||||||
);
|
|
||||||
absorbSetCookie(jar, sts);
|
|
||||||
|
|
||||||
const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"];
|
if (!jar.serviceToken) {
|
||||||
if (!jar.serviceToken) return null;
|
failLog(`phase2 no serviceToken sid=${sid} jar=[${Object.keys(jar).join(",")}]`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const k of needed) if (jar[k]) out[k] = jar[k];
|
for (const [k, v] of Object.entries(jar)) {
|
||||||
|
if (!v) continue;
|
||||||
|
if (k === "serviceToken" || k === "userId" || /_(ph|slh)$/.test(k)) out[k] = v;
|
||||||
|
}
|
||||||
return cookieHeader(out);
|
return cookieHeader(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,13 +242,15 @@ async function acquireServiceCookie(passJar, proxyOptions) {
|
|||||||
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
|
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
|
||||||
*/
|
*/
|
||||||
async function getServiceCookie(providerSpecificData, proxyOptions) {
|
async function getServiceCookie(providerSpecificData, proxyOptions) {
|
||||||
|
const apiBase = resolveMimoServerBase(providerSpecificData);
|
||||||
const passJar = providerSpecificData?.mimoPassToken
|
const passJar = providerSpecificData?.mimoPassToken
|
||||||
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
|
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
|
||||||
: await readDesktopAccountCookies();
|
: await readDesktopAccountCookies();
|
||||||
if (!passJar) return { cookie: null, reason: "no-pass-token" };
|
if (!passJar) return { cookie: null, reason: "no-pass-token" };
|
||||||
|
|
||||||
// One cached session per passToken — accounts/connections rotate independently.
|
// One cached session per passToken+cluster — accounts/connections rotate
|
||||||
const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex");
|
// independently, and the same passToken maps to different sessions per region.
|
||||||
|
const key = crypto.createHash("sha256").update(`${apiBase}|${passJar.passToken}`).digest("hex");
|
||||||
|
|
||||||
const cached = _cache.get(key);
|
const cached = _cache.get(key);
|
||||||
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
|
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
|
||||||
@@ -202,8 +267,9 @@ async function getServiceCookie(providerSpecificData, proxyOptions) {
|
|||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
return await acquireServiceCookie(passJar, proxyOptions);
|
return await acquireServiceCookie(passJar, proxyOptions, apiBase, providerSpecificData?.region);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.log(`[mimoAccount] acquire threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
|
||||||
return null; // network/parse failure — callers degrade, never throw
|
return null; // network/parse failure — callers degrade, never throw
|
||||||
} finally {
|
} finally {
|
||||||
_inflight.delete(key);
|
_inflight.delete(key);
|
||||||
@@ -234,7 +300,8 @@ export async function getMimoAccountCookie(providerSpecificData = null, proxyOpt
|
|||||||
try {
|
try {
|
||||||
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
|
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
|
||||||
return cookie;
|
return cookie;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.log(`[mimoAccount] getMimoAccountCookie threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +317,7 @@ export async function getMimoAccountUsage(providerSpecificData = null, proxyOpti
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await proxyAwareFetch(
|
const res = await proxyAwareFetch(
|
||||||
`${API_BASE}/api/user/usage`,
|
`${resolveMimoServerBase(providerSpecificData)}/api/user/usage`,
|
||||||
{ headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) },
|
{ headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) },
|
||||||
proxyOptions,
|
proxyOptions,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ const STRIP_RULES = [
|
|||||||
{ provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] },
|
{ provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] },
|
||||||
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
|
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
|
||||||
{ provider: "cloudflare-ai", flattenContent: true },
|
{ provider: "cloudflare-ai", flattenContent: true },
|
||||||
// MiMo Desktop Preview models (account-service route): content must be plain string,
|
|
||||||
// rejects OpenAI content-part array. Cloud models keep their parts (mimo-v2-omni is multi-modal).
|
|
||||||
{ provider: "xiaomi-mimo", match: /preview/i, flattenContent: true },
|
|
||||||
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
|
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
|
||||||
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
|
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
|
||||||
// advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144),
|
// advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144),
|
||||||
|
|||||||
@@ -1390,5 +1390,30 @@
|
|||||||
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。",
|
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。",
|
||||||
"✓ Confirm Add": "✓ 确认添加",
|
"✓ Confirm Add": "✓ 确认添加",
|
||||||
"📝 Configure providers in dashboard or use environment variables": "📝 在仪表盘中配置提供商或使用环境变量",
|
"📝 Configure providers in dashboard or use environment variables": "📝 在仪表盘中配置提供商或使用环境变量",
|
||||||
"🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。"
|
"🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。",
|
||||||
|
"Reading local MiMo Desktop credentials...": "正在读取本地 MiMo 桌面版凭证...",
|
||||||
|
"Desktop Plan · Local credentials": "Desktop Plan · 本地凭证",
|
||||||
|
"This account is already connected (no need to import again)": "该账号已连接(无需重复导入)",
|
||||||
|
"Untested": "未测试",
|
||||||
|
"Re-sync local credentials": "重新同步本地凭证",
|
||||||
|
"Connect with local credentials": "使用本地凭证连接",
|
||||||
|
"or": "或",
|
||||||
|
"Browser Login": "网页登录",
|
||||||
|
"No Desktop required": "无需桌面客户端",
|
||||||
|
"Weekly quota": "周额度",
|
||||||
|
"Waiting for login...": "等待登录中...",
|
||||||
|
"Reopen login window": "重新打开登录窗口",
|
||||||
|
"Choose cluster & sign in": "选择集群并登录",
|
||||||
|
"Login session expired — please retry.": "登录会话已过期,请重试。",
|
||||||
|
"Failed to save credentials": "保存凭据失败",
|
||||||
|
"Import failed": "导入失败",
|
||||||
|
"No local Desktop credentials found": "未检测到本地桌面凭证",
|
||||||
|
"You can still sign in via browser — no Desktop client needed.": "仍可通过网页登录,无需桌面客户端。",
|
||||||
|
"Select account cluster": "选择小米账号集群",
|
||||||
|
"Choose the region cluster of your Xiaomi account:": "请选择你的小米账号所在地区集群:",
|
||||||
|
"China (Mainland)": "中国大陆",
|
||||||
|
"Singapore": "新加坡",
|
||||||
|
"Europe · Amsterdam": "欧洲 · 阿姆斯特丹",
|
||||||
|
"Russia": "俄罗斯",
|
||||||
|
"India": "印度"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,19 @@ import { createProviderConnection } from "@/models";
|
|||||||
*/
|
*/
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId } = await request.json();
|
const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId, region } = await request.json();
|
||||||
|
|
||||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
const key = typeof apiKey === "string" ? apiKey.trim() : "";
|
||||||
|
const sessionOnly = !key && !!mimoPassToken;
|
||||||
|
|
||||||
|
if (!key && !mimoPassToken) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "API key is required" },
|
{ error: "API key is required" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = apiKey.trim();
|
if (key && !key.startsWith("sk-")) {
|
||||||
if (!key.startsWith("sk-")) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid key format — expected sk- prefix" },
|
{ error: "Invalid key format — expected sk- prefix" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -29,47 +31,55 @@ export async function POST(request) {
|
|||||||
|
|
||||||
const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, "");
|
const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, "");
|
||||||
|
|
||||||
// Validate the key against the models endpoint
|
// Validate the key against the models endpoint (skipped for session-only)
|
||||||
let validated = false;
|
let validated = false;
|
||||||
let modelCount = 0;
|
let modelCount = 0;
|
||||||
try {
|
if (key) {
|
||||||
const resp = await fetch(`${effectiveBaseUrl}/models`, {
|
try {
|
||||||
method: "GET",
|
const resp = await fetch(`${effectiveBaseUrl}/models`, {
|
||||||
headers: {
|
method: "GET",
|
||||||
Authorization: `Bearer ${key}`,
|
headers: {
|
||||||
"X-Mimo-Source": "mimocode-cli",
|
Authorization: `Bearer ${key}`,
|
||||||
},
|
"X-Mimo-Source": "mimocode-cli",
|
||||||
signal: AbortSignal.timeout(10000),
|
},
|
||||||
});
|
signal: AbortSignal.timeout(10000),
|
||||||
if (resp.ok) {
|
});
|
||||||
const data = await resp.json();
|
if (resp.ok) {
|
||||||
modelCount = Array.isArray(data?.data) ? data.data.length : 0;
|
const data = await resp.json();
|
||||||
validated = true;
|
modelCount = Array.isArray(data?.data) ? data.data.length : 0;
|
||||||
|
validated = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — still allow import (key may be valid but network blocked)
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Network error — still allow import (key may be valid but network blocked)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validated) {
|
if (key && !validated) {
|
||||||
// Soft-fail: store the key but mark as untested
|
// Soft-fail: store the key but mark as untested
|
||||||
console.log("[xiaomi-mimo] key validation failed, storing as untested");
|
console.log("[xiaomi-mimo] key validation failed, storing as untested");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedup: if a connection with the same uid or same key already exists, update it
|
// Dedup: same uid, same key, or same session identity+region
|
||||||
const { getProviderConnections, updateProviderConnection } = await import("@/models");
|
const { getProviderConnections, updateProviderConnection } = await import("@/models");
|
||||||
|
const normRegion = (typeof region === "string" && region) || undefined;
|
||||||
const existing = (await getProviderConnections()).find(
|
const existing = (await getProviderConnections()).find(
|
||||||
(c) => c.provider === "xiaomi-mimo" && (
|
(c) => c.provider === "xiaomi-mimo" && (
|
||||||
(uid && c.email === `${uid}@xiaomi`) ||
|
(uid && c.email === `${uid}@xiaomi`) ||
|
||||||
c.accessToken === key
|
(key && c.accessToken === key) ||
|
||||||
|
(sessionOnly && mimoUserId &&
|
||||||
|
c.providerSpecificData?.mimoUserId === mimoUserId &&
|
||||||
|
(normRegion ? (c.providerSpecificData?.region || "cn") === normRegion : true))
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const updated = await updateProviderConnection(existing.id, {
|
const updated = await updateProviderConnection(existing.id, {
|
||||||
accessToken: key,
|
accessToken: key || existing.accessToken,
|
||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
...existing.providerSpecificData,
|
...existing.providerSpecificData,
|
||||||
uid: uid || existing.providerSpecificData?.uid || null,
|
uid: uid || existing.providerSpecificData?.uid || null,
|
||||||
baseUrl: effectiveBaseUrl,
|
baseUrl: key ? effectiveBaseUrl : (existing.providerSpecificData?.baseUrl || effectiveBaseUrl),
|
||||||
|
region: normRegion || existing.providerSpecificData?.region || "cn",
|
||||||
|
authMethod: sessionOnly ? "session" : (existing.providerSpecificData?.authMethod || "api_key"),
|
||||||
// Per-account session credential — enables multi-account rotation.
|
// Per-account session credential — enables multi-account rotation.
|
||||||
mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null,
|
mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null,
|
||||||
mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null,
|
mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null,
|
||||||
@@ -94,25 +104,29 @@ export async function POST(request) {
|
|||||||
|
|
||||||
const connection = await createProviderConnection({
|
const connection = await createProviderConnection({
|
||||||
provider: "xiaomi-mimo",
|
provider: "xiaomi-mimo",
|
||||||
authType: "api_key",
|
// "oauth" is the official authType for imported credential connections
|
||||||
accessToken: key,
|
// ([action]/route.js) — the list card and filters key off it; never
|
||||||
|
// invent new values ("session" hid the row from the provider card).
|
||||||
|
authType: sessionOnly ? "oauth" : "api_key",
|
||||||
|
accessToken: key || null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
// API keys don't expire on a fixed schedule; use a long horizon
|
// API keys don't expire on a fixed schedule; use a long horizon
|
||||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
email: uid ? `${uid}@xiaomi` : null,
|
email: uid ? `${uid}@xiaomi` : null,
|
||||||
displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo",
|
displayName: uid ? `Xiaomi ${uid}${sessionOnly ? " (Session)" : ""}` : "Xiaomi MiMo",
|
||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
uid: uid || null,
|
uid: uid || null,
|
||||||
baseUrl: effectiveBaseUrl,
|
baseUrl: effectiveBaseUrl,
|
||||||
authMethod: "api_key",
|
authMethod: sessionOnly ? "session" : "api_key",
|
||||||
provider: "API Key",
|
provider: sessionOnly ? "Session Login" : "API Key",
|
||||||
|
region: normRegion || "cn",
|
||||||
modelCount,
|
modelCount,
|
||||||
// Per-account session credential — enables multi-account rotation.
|
// Per-account session credential — enables multi-account rotation.
|
||||||
mimoPassToken: mimoPassToken || null,
|
mimoPassToken: mimoPassToken || null,
|
||||||
mimoUserId: mimoUserId || null,
|
mimoUserId: mimoUserId || null,
|
||||||
mimoCUserId: mimoCUserId || null,
|
mimoCUserId: mimoCUserId || null,
|
||||||
},
|
},
|
||||||
testStatus: validated ? "active" : "untested",
|
testStatus: validated ? "active" : (sessionOnly ? "active" : "untested"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
140
src/app/api/oauth/xiaomi-mimo/login/start/route.js
Normal file
140
src/app/api/oauth/xiaomi-mimo/login/start/route.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { request as httpRequest } from "node:http";
|
||||||
|
import { beginSession, encodeSessionCookie, rewriteMimoBases, absorbSetCookies as absorbResponseCookies, originOf, loginUpstreamFetch, SESSION_COOKIE } from "@/lib/mimoLoginSession";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/oauth/xiaomi-mimo/login/start
|
||||||
|
* Body: { region: "cn" | "sgp" | "ams" | "ru" | "in" }
|
||||||
|
*
|
||||||
|
* Walks the first two hops of the Desktop login surface server-side
|
||||||
|
* (me -> 302 account/pass/serviceLogin -> 302 /fe/service/login) and hands
|
||||||
|
* the browser a same-origin pageUrl carrying the 9r_mimo_login session cookie.
|
||||||
|
* All subsequent account.xiaomi.com traffic flows through src/proxy.js.
|
||||||
|
*
|
||||||
|
* Egress resolution: MIMO_LOGIN_PROXY env > (region=sgp: probe common LOCAL
|
||||||
|
* HTTP proxy ports — v2rayN/clash defaults) > direct. The resolved URL rides
|
||||||
|
* the session cookie so every hop/XHR uses the same exit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_UA =
|
||||||
|
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||||
|
const SSO_UA = "MiClaw/1.0";
|
||||||
|
const LOCAL_PROXY_PORTS = [10808, 10809, 7890, 7891, 1080, 1081, 8080, 8888];
|
||||||
|
|
||||||
|
/** First local port answering a CONNECT to account.xiaomi.com (or null). */
|
||||||
|
function probeLocalHttpProxy(timeoutMs = 500) {
|
||||||
|
const attempts = LOCAL_PROXY_PORTS.map(
|
||||||
|
(port) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (v) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
// Promise.any picks the first FULFILLED value — failures must reject,
|
||||||
|
// otherwise an instant ECONNREFUSED from a closed candidate port would
|
||||||
|
// "win" with null before the real proxy answers.
|
||||||
|
if (v) resolve(v);
|
||||||
|
else reject(new Error(`no-proxy-${port}`));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const req = httpRequest({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
method: "CONNECT",
|
||||||
|
path: "account.xiaomi.com:443",
|
||||||
|
timeout: timeoutMs,
|
||||||
|
});
|
||||||
|
req.on("connect", (res, socket) => {
|
||||||
|
socket.destroy();
|
||||||
|
done(res.statusCode === 200 || res.statusCode === 202 ? `http://127.0.0.1:${port}` : null);
|
||||||
|
});
|
||||||
|
req.on("timeout", () => { req.destroy(); done(null); });
|
||||||
|
req.on("error", () => done(null));
|
||||||
|
req.on("response", () => done(null));
|
||||||
|
req.end();
|
||||||
|
} catch {
|
||||||
|
done(null);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Promise.any(attempts).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hop(sess, url, ua) {
|
||||||
|
return loginUpstreamFetch(url, {
|
||||||
|
redirect: "manual",
|
||||||
|
headers: { "User-Agent": ua, Accept: "text/html,application/json,*/*" },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
}, sess);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
let region = "cn";
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const r = String(body?.region || "").toLowerCase();
|
||||||
|
// Known MiMo Desktop clusters (cn/sgp/ams/ru/in) — default cn.
|
||||||
|
if (r === "cn" || r === "sgp" || r === "ams" || r === "ru" || r === "in") region = r;
|
||||||
|
} catch { /* empty body — default cn */ }
|
||||||
|
|
||||||
|
const sess = beginSession(region);
|
||||||
|
|
||||||
|
// Egress — non-CN clusters may need an overseas exit for the login page's
|
||||||
|
// geo-decided features (e.g. Google sign-in); CN is always direct.
|
||||||
|
let egress = null;
|
||||||
|
let egressSource = "direct";
|
||||||
|
if (region !== "cn") {
|
||||||
|
const found = await probeLocalHttpProxy();
|
||||||
|
if (found) {
|
||||||
|
egress = found;
|
||||||
|
egressSource = "local-probe";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sess.proxyUrl = egress;
|
||||||
|
|
||||||
|
// Hop 1: me -> account SSO (callback carries the sts callback for THIS cluster)
|
||||||
|
const meRes = await hop(sess, `${sess.upstreamBase}/api/user/xiaomi/me`, API_UA);
|
||||||
|
absorbResponseCookies(sess, meRes, `${sess.upstreamBase}/api/user/xiaomi/me`);
|
||||||
|
const ssoLoc = meRes.headers.get("location");
|
||||||
|
if (!ssoLoc || !/account\.xiaomi\.com/.test(ssoLoc)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unexpected me response (${meRes.status}) — no account redirect` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hop 2: serviceLogin -> /fe/service/login SPA (also seeds deviceId cookies)
|
||||||
|
const loginRes = await hop(sess, ssoLoc, SSO_UA);
|
||||||
|
absorbResponseCookies(sess, loginRes, ssoLoc);
|
||||||
|
const pageLoc = loginRes.headers.get("location");
|
||||||
|
if (!pageLoc) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unexpected serviceLogin response (${loginRes.status})` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin path for the SPA (middleware proxies native prefixes).
|
||||||
|
const pageUrl = new URL(pageLoc, "https://account.xiaomi.com");
|
||||||
|
const origin = originOf(request);
|
||||||
|
// Session travels ONLY in the httpOnly cookie — never in the URL (history,
|
||||||
|
// logs, Referer). /login/status re-arms the cookie on every poll, so a
|
||||||
|
// dropped-cookie browser still recovers on the next poll cycle.
|
||||||
|
const proxiedPath = rewriteMimoBases(pageUrl.pathname + pageUrl.search, "toProxy", origin);
|
||||||
|
const egressLog = egress ? egress.replace(/\/\/[^@/]+@/, "//***@") : "";
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start region=${sess.region} origin=${origin} egress=${egressSource}${egressLog ? ` (${egressLog})` : ""} page=${pageUrl.pathname}`);
|
||||||
|
|
||||||
|
const res = NextResponse.json({ success: true, state: sess.state, pageUrl: proxiedPath, region });
|
||||||
|
res.cookies.set(SESSION_COOKIE, encodeSessionCookie(sess), {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 15 * 60,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start error:`, error?.message || error);
|
||||||
|
return NextResponse.json({ error: error?.message || "login start failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/oauth/xiaomi-mimo/login/status/route.js
Normal file
45
src/app/api/oauth/xiaomi-mimo/login/status/route.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sessionFromRequest, readSessionIdentity, attachSessionCookie } from "@/lib/mimoLoginSession";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/oauth/xiaomi-mimo/login/status?state=...
|
||||||
|
* Polls the server-side login session (state lives in the httpOnly session
|
||||||
|
* cookie — route handlers and the proxy don't share module memory). When a
|
||||||
|
* passToken is in the jar, probes /api/user/xiaomi/me once to confirm the
|
||||||
|
* session works, then returns the identity for the client to persist.
|
||||||
|
*/
|
||||||
|
export async function GET(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const state = url.searchParams.get("state") || "";
|
||||||
|
const sess = sessionFromRequest(request);
|
||||||
|
if (!sess || (state && sess.state !== state)) {
|
||||||
|
return NextResponse.json({ status: "expired" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sess.status !== "done") {
|
||||||
|
// AUTHORIZATION = passToken in the jar (captured during the proxied login
|
||||||
|
// XHRs). No serviceToken exchange — weekly-quota API moved; re-wire later.
|
||||||
|
if (readSessionIdentity(sess)) sess.status = "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sess.status !== "done") {
|
||||||
|
// Re-arm the session cookie on every poll — the modal may sit on the login
|
||||||
|
// form much longer than the 15min TTL, and only proxied responses used to
|
||||||
|
// refresh it (browser silently drops an expired cookie before the POST).
|
||||||
|
return attachSessionCookie(NextResponse.json({ status: "pending", region: sess.region }), sess);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = readSessionIdentity(sess);
|
||||||
|
if (!id) {
|
||||||
|
return attachSessionCookie(
|
||||||
|
NextResponse.json({ status: "error", error: "session captured but passToken missing" }),
|
||||||
|
sess,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { status: "done", region: sess.region, ...id };
|
||||||
|
// One-shot: don't let the identity linger past the client reading it.
|
||||||
|
const res = NextResponse.json(payload);
|
||||||
|
res.cookies.set("9r_mimo_login", "", { path: "/", httpOnly: true, maxAge: 0 });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -191,6 +191,9 @@ function isPublicApi(pathname) {
|
|||||||
return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared with src/proxy.js — the mimo login branch must respect dashboard auth.
|
||||||
|
export { isAuthenticated };
|
||||||
|
|
||||||
export const __test__ = {
|
export const __test__ = {
|
||||||
isLocalRequest,
|
isLocalRequest,
|
||||||
isPublicLlmApi,
|
isPublicLlmApi,
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export function onLocaleChange(callback) {
|
|||||||
|
|
||||||
// Process text node
|
// Process text node
|
||||||
function processTextNode(node) {
|
function processTextNode(node) {
|
||||||
if (!node.nodeValue || !node.nodeValue.trim()) return;
|
const current = node.nodeValue;
|
||||||
|
if (!current || !current.trim()) return;
|
||||||
|
|
||||||
// Skip if parent is script, style, code, or structural elements
|
// Skip if parent is script, style, code, or structural elements
|
||||||
const parent = node.parentElement;
|
const parent = node.parentElement;
|
||||||
@@ -82,14 +83,20 @@ function processTextNode(node) {
|
|||||||
|
|
||||||
if (skipTags.includes(tagName)) return;
|
if (skipTags.includes(tagName)) return;
|
||||||
|
|
||||||
// Store original text if not already stored
|
// React reuses text nodes and rewrites their value on re-render (a
|
||||||
if (!node._originalText) {
|
// characterData mutation, no childList event). When the current value is
|
||||||
node._originalText = node.nodeValue;
|
// neither our last translation nor the recorded original, it is fresh
|
||||||
|
// source text — re-capture it as the new original before translating.
|
||||||
|
const isOurTranslation = node._translated != null && current === node._translated;
|
||||||
|
const isSameAsOriginal = current === node._originalText;
|
||||||
|
if (!isOurTranslation && !isSameAsOriginal) {
|
||||||
|
node._originalText = current;
|
||||||
}
|
}
|
||||||
|
if (node._originalText == null) node._originalText = current;
|
||||||
|
|
||||||
// Use original text for translation
|
// Translate from the recorded original so locale switches stay idempotent
|
||||||
const original = node._originalText;
|
const translated = translate(node._originalText);
|
||||||
const translated = translate(original);
|
node._translated = translated;
|
||||||
|
|
||||||
// Only update if different to avoid unnecessary DOM mutations
|
// Only update if different to avoid unnecessary DOM mutations
|
||||||
if (translated !== node.nodeValue) {
|
if (translated !== node.nodeValue) {
|
||||||
@@ -130,9 +137,16 @@ export async function initRuntimeI18n() {
|
|||||||
// Process existing DOM
|
// Process existing DOM
|
||||||
processElement(document.body);
|
processElement(document.body);
|
||||||
|
|
||||||
// Watch for new nodes
|
// Watch for new nodes AND in-place text rewrites. React reuses text nodes on
|
||||||
|
// re-render (only nodeValue changes → a characterData mutation with no
|
||||||
|
// childList event), so observing childList alone leaves later-updated labels
|
||||||
|
// untranslated.
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
mutations.forEach((mutation) => {
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === "characterData") {
|
||||||
|
processTextNode(mutation.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
mutation.addedNodes.forEach((node) => {
|
mutation.addedNodes.forEach((node) => {
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
processElement(node);
|
processElement(node);
|
||||||
@@ -146,6 +160,7 @@ export async function initRuntimeI18n() {
|
|||||||
observer.observe(document.body, {
|
observer.observe(document.body, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
726
src/lib/mimoLoginSession.js
Normal file
726
src/lib/mimoLoginSession.js
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
/**
|
||||||
|
* Server-side Xiaomi account session login (mimics MiMo Desktop's login surface).
|
||||||
|
*
|
||||||
|
* Flow (reverse-engineered from Desktop traffic / mimoAccount.js):
|
||||||
|
* 1. GET {mimo-server}/api/user/xiaomi/me -> 302 account /pass/serviceLogin?sid=mimopc&callback={sts}
|
||||||
|
* 2. GET account /pass/serviceLogin -> 302 /fe/service/login (the SPA)
|
||||||
|
* 3. Browser (via the src/proxy.js reverse proxy) completes login on the REAL
|
||||||
|
* page (password / whatever the page offers) — every account.xiaomi.com
|
||||||
|
* request passes through the proxy; Set-Cookie lands in OUR jar (which
|
||||||
|
* travels in the httpOnly 9r_mimo_login cookie between hops).
|
||||||
|
* 4. SPA navigates to the sts callback -> rewritten to /__mimo_login/mimo/*,
|
||||||
|
* middleware takes over and follows the chain server-side:
|
||||||
|
* sts -> Set-Cookie serviceToken -> me (200 JSON = logged in).
|
||||||
|
* 5. passToken/userId/cUserId read from the jar -> stored on the connection.
|
||||||
|
*
|
||||||
|
* Edge-safe: no Node-only APIs (used from both middleware and API routes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACCOUNT_HOST = "account.xiaomi.com";
|
||||||
|
export const SESSION_COOKIE = "9r_mimo_login";
|
||||||
|
const SESSION_TTL_MS = 15 * 60 * 1000;
|
||||||
|
const API_UA =
|
||||||
|
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||||
|
const SSO_UA = "MiClaw/1.0";
|
||||||
|
const BROWSER_UA =
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
// Paths that belong to the 9router app itself — never proxy these upstream,
|
||||||
|
// even while a login session is active. Everything else is fair game: the
|
||||||
|
// login SPA hits evolving endpoints (/pass2/config, /v3/...), so a static
|
||||||
|
// allowlist rots fast. (Edge-safe: plain strings only.)
|
||||||
|
const APP_PREFIXES = [
|
||||||
|
"/_next/", "/api/", "/dashboard", "/v1/", "/v1beta/",
|
||||||
|
"/login", "/landing", "/__mimo_login/", "/i18n/", "/icons/", "/providers/",
|
||||||
|
];
|
||||||
|
const APP_FILES = new Set([
|
||||||
|
"/favicon.svg", "/favicon.ico", "/file.svg", "/globe.svg", "/next.svg",
|
||||||
|
"/vercel.svg", "/window.svg", "/sw.js", "/robots.txt", "/manifest.webmanifest",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const APP_PATH_MATCHES = (pathname) =>
|
||||||
|
APP_FILES.has(pathname) ||
|
||||||
|
APP_PREFIXES.some((p) => pathname === p || pathname.startsWith(p.endsWith("/") ? p : p + "/"));
|
||||||
|
|
||||||
|
const MIMO_BASES = {
|
||||||
|
cn: "https://mimo-server-cn.xiaomimimo.com",
|
||||||
|
sgp: "https://mimo-server-sgp.xiaomimimo.com",
|
||||||
|
ams: "https://mimo-server-ams.xiaomimimo.com",
|
||||||
|
ru: "https://mimo-server-ru.xiaomimimo.com",
|
||||||
|
in: "https://mimo-server-in.xiaomimimo.com",
|
||||||
|
};
|
||||||
|
// Unknown/absent region falls back to SGP (the international/open cluster).
|
||||||
|
const DEFAULT_REGION = "sgp";
|
||||||
|
// passToken-prefix -> last failure ts (60s backoff for the service exchange)
|
||||||
|
const _exchangeBackoff = new Map();
|
||||||
|
|
||||||
|
export function resolveMimoRegionBase(region) {
|
||||||
|
const r = String(region || "").toLowerCase();
|
||||||
|
return MIMO_BASES[r] || MIMO_BASES[DEFAULT_REGION];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-facing origin for this request. Prefer the Host header — request.url
|
||||||
|
* / nextUrl may carry the bind address (0.0.0.0), which must never leak into
|
||||||
|
* rewritten callbacks (start and proxy must agree on the exact same origin,
|
||||||
|
* and both see the same Host header).
|
||||||
|
*/
|
||||||
|
export function originOf(request) {
|
||||||
|
const proto = request.nextUrl?.protocol
|
||||||
|
|| (request.headers?.get?.("x-forwarded-proto") || "http");
|
||||||
|
const host = request.headers?.get?.("host") || request.nextUrl?.host;
|
||||||
|
return host ? `${proto}//${host}` : (request.nextUrl?.origin || "http://localhost:20131");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session (incl. the accumulated cookie jar) travels in the SESSION_COOKIE
|
||||||
|
// itself — Next runs route handlers and the proxy in separate bundles, so a
|
||||||
|
// module-level Map is NOT shared between them. Cookie-carried state works
|
||||||
|
// regardless of runtime topology. httpOnly + SameSite=Lax, TTL-bounded.
|
||||||
|
|
||||||
|
export function beginSession(region) {
|
||||||
|
const normalizedRegion = String(region || "").toLowerCase();
|
||||||
|
return {
|
||||||
|
state: crypto.randomUUID(),
|
||||||
|
region: normalizedRegion in MIMO_BASES ? normalizedRegion : DEFAULT_REGION,
|
||||||
|
proxyUrl: null, // resolved by the start route (env | local-probe for sgp | null)
|
||||||
|
jar: new Map(), // "name|domain|path" -> { name, value, domain, path }
|
||||||
|
status: "pending",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
upstreamBase: resolveMimoRegionBase(region),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEEP_ON_OVERFLOW = /^(passToken|userId|cUserId|serviceToken|.*_serviceToken|.*_ph|.*_slh|deviceId)$/;
|
||||||
|
// Browser hard limit for one cookie value is 4096 bytes. base64url inflates
|
||||||
|
// ~1.34x, so the JSON payload must stay under ~2800 to be safe.
|
||||||
|
const COOKIE_JSON_BUDGET = 2800;
|
||||||
|
|
||||||
|
function b64urlEncode(str) {
|
||||||
|
const bytes = new TextEncoder().encode(str);
|
||||||
|
let bin = "";
|
||||||
|
for (const b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s) {
|
||||||
|
let t = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
while (t.length % 4) t += "=";
|
||||||
|
const bin = atob(t);
|
||||||
|
return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSessionCookie(sess) {
|
||||||
|
const entries = [...sess.jar.values()].map((c) => [c.name, c.value, c.domain, c.path]);
|
||||||
|
const base = { s: sess.state, r: sess.region, t: sess.createdAt, p: sess.proxyUrl || "" };
|
||||||
|
let payload = JSON.stringify({ ...base, j: entries });
|
||||||
|
if (payload.length > COOKIE_JSON_BUDGET) {
|
||||||
|
// Cookie budget: drop everything but identity/session essentials.
|
||||||
|
payload = JSON.stringify({ ...base, j: entries.filter(([name]) => KEEP_ON_OVERFLOW.test(name)) });
|
||||||
|
}
|
||||||
|
return `v2.${b64urlEncode(payload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeSessionCookie(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
if (value.startsWith("v2.")) {
|
||||||
|
payload = JSON.parse(b64urlDecode(value.slice(3)));
|
||||||
|
} else if (value.startsWith("v1.")) {
|
||||||
|
payload = JSON.parse(decodeURIComponent(value.slice(3))); // legacy
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload.t !== "number") return null;
|
||||||
|
if (Date.now() - payload.t > SESSION_TTL_MS) return null;
|
||||||
|
const jar = new Map();
|
||||||
|
for (const raw of payload.j || []) {
|
||||||
|
if (!Array.isArray(raw) || raw.length < 4) continue;
|
||||||
|
const [name, val, domain, path] = raw;
|
||||||
|
jar.set(`${name}|${domain}|${path}`, { name, value: val, domain, path });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: payload.s,
|
||||||
|
region: payload.r in MIMO_BASES ? payload.r : DEFAULT_REGION,
|
||||||
|
proxyUrl: typeof payload.p === "string" && payload.p ? payload.p : null,
|
||||||
|
jar,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: payload.t,
|
||||||
|
upstreamBase: resolveMimoRegionBase(payload.r),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the session straight from an incoming request (any runtime). */
|
||||||
|
export function sessionFromRequest(request) {
|
||||||
|
const raw = request.cookies?.get?.(SESSION_COOKIE)?.value
|
||||||
|
?? parseCookieHeader(request.headers?.get?.("cookie"))?.[SESSION_COOKIE];
|
||||||
|
return decodeSessionCookie(raw || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieHeader(header) {
|
||||||
|
if (!header) return null;
|
||||||
|
const out = {};
|
||||||
|
for (const part of header.split(";")) {
|
||||||
|
const i = part.indexOf("=");
|
||||||
|
if (i < 0) continue;
|
||||||
|
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append the re-encoded session to any Response (proxy writes go through here). */
|
||||||
|
export function attachSessionCookie(response, sess) {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.append(
|
||||||
|
"Set-Cookie",
|
||||||
|
`${SESSION_COOKIE}=${encodeSessionCookie(sess)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
|
||||||
|
);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearedSessionCookie() {
|
||||||
|
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- cookie jar (server-side) ----------
|
||||||
|
|
||||||
|
function jarKey(c) {
|
||||||
|
return `${c.name}|${c.domain}|${c.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainMatch(cookieDomain, host) {
|
||||||
|
const d = String(cookieDomain || "").replace(/^\./, "").toLowerCase();
|
||||||
|
const h = String(host || "").toLowerCase();
|
||||||
|
return h === d || h.endsWith("." + d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse one Set-Cookie header value. Returns { cookie, expired } or null. */
|
||||||
|
function parseSetCookie(raw, requestUrl) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split(";");
|
||||||
|
const nv = /^([^=]+)=([\s\S]*)$/.exec(parts[0].trim());
|
||||||
|
if (!nv) return null;
|
||||||
|
const name = nv[1].trim();
|
||||||
|
const value = (nv[2] || "").trim();
|
||||||
|
const url = new URL(requestUrl);
|
||||||
|
const cookie = {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
domain: url.hostname,
|
||||||
|
path: (url.pathname || "/").replace(/[^/]*$/, "") || "/",
|
||||||
|
};
|
||||||
|
let expired = value === "EXPIRED";
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const f = parts[i].trim();
|
||||||
|
const eq = f.indexOf("=");
|
||||||
|
const k = (eq >= 0 ? f.slice(0, eq) : f).trim().toLowerCase();
|
||||||
|
const v = eq >= 0 ? f.slice(eq + 1).trim() : "";
|
||||||
|
if (k === "domain" && v) cookie.domain = v.replace(/^\./, "");
|
||||||
|
else if (k === "path" && v) cookie.path = v;
|
||||||
|
else if (k === "expires") {
|
||||||
|
if (/expired/i.test(v)) expired = true;
|
||||||
|
else {
|
||||||
|
const t = Date.parse(v);
|
||||||
|
if (!Number.isNaN(t) && t <= Date.now()) expired = true;
|
||||||
|
}
|
||||||
|
} else if (k === "max-age" && Number(v) <= 0) expired = true;
|
||||||
|
}
|
||||||
|
return { cookie, expired };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absorbSetCookies(sess, res, requestUrl) {
|
||||||
|
for (const raw of res.headers.getSetCookie?.() || []) {
|
||||||
|
const parsed = parseSetCookie(raw, requestUrl);
|
||||||
|
if (!parsed) continue;
|
||||||
|
const key = jarKey(parsed.cookie);
|
||||||
|
if (parsed.expired || !parsed.cookie.value) sess.jar.delete(key);
|
||||||
|
else sess.jar.set(key, parsed.cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieHeaderFor(sess, targetUrl) {
|
||||||
|
const u = new URL(targetUrl);
|
||||||
|
const out = [];
|
||||||
|
for (const c of sess.jar.values()) {
|
||||||
|
if (!domainMatch(c.domain, u.hostname)) continue;
|
||||||
|
if (!(u.pathname || "/").startsWith(c.path)) continue;
|
||||||
|
out.push(`${c.name}=${c.value}`);
|
||||||
|
}
|
||||||
|
return out.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract identity cookies from the account host jar. */
|
||||||
|
export function readSessionIdentity(sess) {
|
||||||
|
const pick = (name) => {
|
||||||
|
for (const c of sess.jar.values()) {
|
||||||
|
if (c.name === name && domainMatch(c.domain, ACCOUNT_HOST)) return c.value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const passToken = pick("passToken");
|
||||||
|
if (!passToken || passToken === "EXPIRED") return null;
|
||||||
|
return { passToken, userId: pick("userId"), cUserId: pick("cUserId") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeem the captured passToken for a mimo-server service session using the
|
||||||
|
* BATTLE-TESTED desktop handshake (serviceLogin sid=mimopc + clientSign) that
|
||||||
|
* powers every existing CN desktop connection — instead of the interactive
|
||||||
|
* /api/sts webview callback, which rejects server-side calls (401).
|
||||||
|
* Merges the resulting serviceCookie into sess.jar, then confirms via me.
|
||||||
|
* @returns {Promise<boolean>} true when the me probe answers 200 (logged in).
|
||||||
|
*/
|
||||||
|
export async function ensureServiceSession(sess) {
|
||||||
|
const id = readSessionIdentity(sess);
|
||||||
|
if (!id) return false;
|
||||||
|
const T = () => new Date().toISOString().slice(11, 23);
|
||||||
|
const log = (m) => console.log(`${T()} [mimo-login][exchange] ${m}`);
|
||||||
|
// Backoff: a failed full 5-step chain must not re-run on every 2.5s poll.
|
||||||
|
const bkKey = id.passToken.slice(0, 24);
|
||||||
|
const lastFail = _exchangeBackoff.get(bkKey);
|
||||||
|
if (lastFail && Date.now() - lastFail < 60_000) {
|
||||||
|
log("exchange in backoff (60s), skip");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mod = await import("../../open-sse/shared/mimoAccount.js");
|
||||||
|
const proxyOptions = sess.proxyUrl ? { enabled: true, url: sess.proxyUrl } : null;
|
||||||
|
log(`exchanging passToken (region=${sess.region}, egress=${sess.proxyUrl || "direct"}) ...`);
|
||||||
|
const serviceCookie = await mod.getMimoAccountCookie(
|
||||||
|
{
|
||||||
|
region: sess.region,
|
||||||
|
mimoPassToken: id.passToken,
|
||||||
|
mimoUserId: id.userId,
|
||||||
|
mimoCUserId: id.cUserId,
|
||||||
|
},
|
||||||
|
proxyOptions,
|
||||||
|
);
|
||||||
|
if (!serviceCookie) {
|
||||||
|
log("exchange failed: no service cookie");
|
||||||
|
_exchangeBackoff.set(bkKey, Date.now());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Flatten "a=b; c=d" into the jar under the mimo-server host.
|
||||||
|
_exchangeBackoff.delete(bkKey);
|
||||||
|
const host = new URL(sess.upstreamBase).hostname;
|
||||||
|
for (const pair of String(serviceCookie).split(";")) {
|
||||||
|
const eq = pair.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const name = pair.slice(0, eq).trim();
|
||||||
|
const value = pair.slice(eq + 1).trim();
|
||||||
|
if (!name || !value) continue;
|
||||||
|
sess.jar.set(`${name}|${host}|/`, { name, value, domain: host, path: "/" });
|
||||||
|
}
|
||||||
|
log(`serviceCookie merged, jar=[${[...sess.jar.keys()].map((k) => k.split("|")[0]).join(",").slice(0, 160)}]`);
|
||||||
|
|
||||||
|
const meUrl = `${sess.upstreamBase}/api/user/xiaomi/me`;
|
||||||
|
const res = await fetchUpstream(
|
||||||
|
sess,
|
||||||
|
meUrl,
|
||||||
|
{ method: "GET", headers: { "User-Agent": API_UA } },
|
||||||
|
cookieHeaderFor(sess, meUrl),
|
||||||
|
);
|
||||||
|
absorbSetCookies(sess, res, meUrl);
|
||||||
|
log(`me confirm http=${res.status}`);
|
||||||
|
return res.status === 200;
|
||||||
|
} catch (e) {
|
||||||
|
log(`exchange error: ${e?.message || e}`);
|
||||||
|
_exchangeBackoff.set(bkKey, Date.now());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- URL rewriting (mimo-server base <-> /__mimo_login/mimo) ----------
|
||||||
|
|
||||||
|
function encodingVariants(s) {
|
||||||
|
// The callback/followup params appear raw, url-encoded once, twice...
|
||||||
|
const out = [s];
|
||||||
|
let cur = s;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
cur = encodeURIComponent(cur);
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite mimo-server base URLs (any encoding depth) in a string.
|
||||||
|
* direction "toProxy": mimo-base -> `${origin}/__mimo_login/mimo`
|
||||||
|
* direction "toUpstream": reverse.
|
||||||
|
*/
|
||||||
|
export function rewriteMimoBases(text, direction, origin, upstreamBase = null) {
|
||||||
|
if (!text) return text;
|
||||||
|
let out = String(text);
|
||||||
|
const proxyBase = `${origin}/__mimo_login/mimo`;
|
||||||
|
const bases = [...new Set(Object.values(MIMO_BASES))];
|
||||||
|
const fromBases = direction === "toProxy" ? bases : [proxyBase];
|
||||||
|
const toBase = direction === "toProxy" ? proxyBase : (upstreamBase || bases[0]);
|
||||||
|
for (const fromBase of fromBases) {
|
||||||
|
const variants = [
|
||||||
|
fromBase,
|
||||||
|
fromBase.replace("https://", "http://"),
|
||||||
|
fromBase.replace(/^https?:/, ""),
|
||||||
|
fromBase.replaceAll("/", "\\/"),
|
||||||
|
fromBase.replace("https://", "http://").replaceAll("/", "\\/"),
|
||||||
|
]; // + protocol-relative + json escaped slashes
|
||||||
|
const toVariants = [
|
||||||
|
toBase,
|
||||||
|
toBase.replace("https://", "http://"),
|
||||||
|
toBase,
|
||||||
|
toBase.replaceAll("/", "\\/"),
|
||||||
|
toBase.replaceAll("/", "\\/"),
|
||||||
|
];
|
||||||
|
for (let vIdx = 0; vIdx < variants.length; vIdx++) {
|
||||||
|
const fromList = encodingVariants(variants[vIdx]);
|
||||||
|
const toList = encodingVariants(toVariants[vIdx]);
|
||||||
|
for (let i = 0; i < fromList.length; i++) {
|
||||||
|
out = out.split(fromList[i]).join(toList[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// account.xiaomi.com absolute URLs: keep navigation (e.g. identity/authStart
|
||||||
|
// 2FA prompts) on our origin — every path on that host is already proxied
|
||||||
|
// natively. Reverse applies to request URLs/bodies before hitting upstream.
|
||||||
|
const acctOrigins = ["https://account.xiaomi.com", "http://account.xiaomi.com", "//account.xiaomi.com"];
|
||||||
|
if (direction === "toProxy") {
|
||||||
|
const toL = encodingVariants(origin);
|
||||||
|
for (const a of acctOrigins) {
|
||||||
|
const fromL = encodingVariants(a);
|
||||||
|
for (let i = 0; i < fromL.length; i++) out = out.split(fromL[i]).join(toL[i]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const toA = encodingVariants("https://account.xiaomi.com");
|
||||||
|
const toAEscaped = encodingVariants("https:\\/\\/account.xiaomi.com");
|
||||||
|
const fromL = encodingVariants(origin);
|
||||||
|
const fromLProto = encodingVariants(origin.replace(/^https?:/, ""));
|
||||||
|
const fromLEscaped = encodingVariants(origin.replaceAll("/", "\\/"));
|
||||||
|
for (let i = 0; i < fromL.length; i++) {
|
||||||
|
out = out.split(fromL[i]).join(toA[i]);
|
||||||
|
out = out.split(fromLProto[i]).join(toA[i]);
|
||||||
|
out = out.split(fromLEscaped[i]).join(toAEscaped[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverse the proxy rewrite in an incoming URL before hitting upstream. */
|
||||||
|
export function deRewriteUrl(rawUrl, origin, upstreamBase = null) {
|
||||||
|
return rewriteMimoBases(rawUrl, "toUpstream", origin, upstreamBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAccountProxyPath(pathname) {
|
||||||
|
// Inverted: proxy EVERYTHING except the app's own paths (only consulted
|
||||||
|
// while a login session cookie/param is present).
|
||||||
|
return !APP_PATH_MATCHES(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMimoTakeoverPath(pathname) {
|
||||||
|
return pathname.startsWith("/__mimo_login/mimo/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map /__mimo_login/mimo/* back to the upstream mimo-server path. */
|
||||||
|
export function takeoverUpstreamPath(pathname) {
|
||||||
|
return pathname.slice("/__mimo_login/mimo".length) || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- upstream fetch with optional egress proxy ----------
|
||||||
|
//
|
||||||
|
// The login page's feature set (Google sign-in etc.) is geo-decided by the
|
||||||
|
// egress IP of THESE requests. The proxy applies ONLY when the user picked
|
||||||
|
// region=sgp — the start route resolves it (via local-port probe) and rides it
|
||||||
|
// on the session cookie as sess.proxyUrl. CN sessions never proxy (null -> direct).
|
||||||
|
|
||||||
|
let _pafPromise = null;
|
||||||
|
let _socksPromise = null;
|
||||||
|
|
||||||
|
/** fetch-compatible wrapper over socks-proxy-agent (undici ProxyAgent has no socks support). */
|
||||||
|
async function socksFetch(url, init, proxyUrl) {
|
||||||
|
if (!_socksPromise) {
|
||||||
|
_socksPromise = import("socks-proxy-agent")
|
||||||
|
.then((m) => m.SocksProxyAgent || m.default?.SocksProxyAgent || m.default)
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] socks-proxy-agent unavailable:`, e?.message || e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const SocksProxyAgent = await _socksPromise;
|
||||||
|
if (!SocksProxyAgent) throw new Error("socks agent unavailable");
|
||||||
|
|
||||||
|
const nodeUrl = new URL(url);
|
||||||
|
// Literal specifiers on both branches — webpack forbids fully dynamic import().
|
||||||
|
const protoMod = nodeUrl.protocol === "http:" ? await import("node:http") : await import("node:https");
|
||||||
|
const lib = protoMod.default ?? protoMod;
|
||||||
|
const { Readable } = await import("node:stream");
|
||||||
|
|
||||||
|
let headers = {};
|
||||||
|
const raw = init?.headers;
|
||||||
|
if (raw instanceof Headers) for (const [k, v] of raw) headers[k] = v;
|
||||||
|
else if (raw) headers = { ...raw };
|
||||||
|
|
||||||
|
let body = init?.body;
|
||||||
|
if (body && typeof body !== "string" && !Buffer.isBuffer(body)) body = Buffer.from(body);
|
||||||
|
if (body) headers["content-length"] = String(Buffer.byteLength(body));
|
||||||
|
|
||||||
|
const agent = new SocksProxyAgent(proxyUrl);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = lib.request(
|
||||||
|
nodeUrl,
|
||||||
|
{ method: init?.method || "GET", agent, headers, timeout: 20000 },
|
||||||
|
(res) => {
|
||||||
|
const outHeaders = new Headers();
|
||||||
|
for (const [k, v] of Object.entries(res.headers || {})) {
|
||||||
|
if (Array.isArray(v)) v.forEach((x) => outHeaders.append(k, String(x)));
|
||||||
|
else if (v != null) outHeaders.set(k, String(v));
|
||||||
|
}
|
||||||
|
resolve(new Response(Readable.toWeb(res), { status: res.statusCode || 200, headers: outHeaders }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const signal = init?.signal;
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) req.destroy(new Error("aborted"));
|
||||||
|
else signal.addEventListener("abort", () => req.destroy(new Error("aborted")), { once: true });
|
||||||
|
}
|
||||||
|
req.on("timeout", () => req.destroy(new Error("socks fetch timeout")));
|
||||||
|
req.on("error", reject);
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginFetch(url, init, sessionProxyUrl = null) {
|
||||||
|
if (!sessionProxyUrl) return fetch(url, init); // direct — no agent machinery needed
|
||||||
|
const proxyOptions = { enabled: true, url: sessionProxyUrl };
|
||||||
|
try {
|
||||||
|
if (/^socks/i.test(sessionProxyUrl)) return await socksFetch(url, init, sessionProxyUrl);
|
||||||
|
if (!_pafPromise) {
|
||||||
|
_pafPromise = import("../../open-sse/utils/proxyFetch.js")
|
||||||
|
.then((m) => m.proxyAwareFetch)
|
||||||
|
.catch((e) => {
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxyAwareFetch unavailable (runtime?), direct only:`, e?.message || e);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const paf = await _pafPromise;
|
||||||
|
if (paf) return await paf(url, init, proxyOptions);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxied fetch failed, falling back to direct:`, e?.message || e);
|
||||||
|
}
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public alias — start/status routes share the same egress path. */
|
||||||
|
export const loginUpstreamFetch = (url, init, sess = null) => loginFetch(url, init, sess?.proxyUrl || null);
|
||||||
|
|
||||||
|
// ---------- upstream proxying ----------
|
||||||
|
|
||||||
|
async function fetchUpstream(sess, url, init, cookieValue) {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (cookieValue) headers.set("Cookie", cookieValue);
|
||||||
|
return loginFetch(url, { ...init, headers, redirect: "manual", signal: AbortSignal.timeout(20000) }, sess?.proxyUrl || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserCookieHeader(req) {
|
||||||
|
return req.headers.get("cookie") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers never forwarded to the upstream account host.
|
||||||
|
const STRIP_UPSTREAM_HEADERS = new Set([
|
||||||
|
"host", "cookie", "connection", "content-length", "transfer-encoding",
|
||||||
|
"keep-alive", "upgrade", "expect", "proxy-connection",
|
||||||
|
// Credentials for 9router itself — must never reach a third-party upstream.
|
||||||
|
"authorization", "proxy-authorization",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy one account.xiaomi.com request from the browser.
|
||||||
|
* Captures Set-Cookie into the server jar, strips frame-blocking headers,
|
||||||
|
* rewrites Location/body mimo-base references back into the proxy space.
|
||||||
|
*/
|
||||||
|
export async function proxyAccountRequest(sess, req, origin) {
|
||||||
|
const u = new URL(req.url);
|
||||||
|
u.searchParams.delete("__9r_sess"); // never forward our session to upstream
|
||||||
|
const upstream = new URL(u.pathname + u.search, `https://${ACCOUNT_HOST}`);
|
||||||
|
|
||||||
|
// The SPA's callback params were rewritten to our proxy — undo before upstream
|
||||||
|
// so signature (_sign) validation on the real callback still passes.
|
||||||
|
const upstreamStr = deRewriteUrl(upstream.toString(), origin, sess.upstreamBase);
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
// Forward EVERYTHING the browser sent (minus hop-by-hop + host/cookie) so no
|
||||||
|
// custom SDK header gets silently dropped. Then fix up cross-origin fields:
|
||||||
|
// the SPA talks to account.xiaomi.com, so Origin/Referer must be rewritten
|
||||||
|
// from our origin to the account origin — keeping the original path/query
|
||||||
|
// (a wrong Referer path trips Xiaomi's login risk control, error 10025).
|
||||||
|
for (const [k, v] of req.headers) {
|
||||||
|
if (STRIP_UPSTREAM_HEADERS.has(k.toLowerCase())) continue;
|
||||||
|
headers[k] = v;
|
||||||
|
}
|
||||||
|
const ourOrigin = origin;
|
||||||
|
const fixOriginUrl = (val) => {
|
||||||
|
if (!val) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(val);
|
||||||
|
if (u.origin === ourOrigin) {
|
||||||
|
u.protocol = "https:";
|
||||||
|
u.host = ACCOUNT_HOST;
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
if (u.hostname === ACCOUNT_HOST) return u.toString();
|
||||||
|
return null; // some other origin — let our forced account values win
|
||||||
|
} catch { return null; }
|
||||||
|
};
|
||||||
|
const rawOrigin = headers.origin || headers.Origin || null;
|
||||||
|
delete headers.origin;
|
||||||
|
delete headers.Origin;
|
||||||
|
// Real browsers only send Origin on XHR POSTs — preserve that shape, but
|
||||||
|
// point it at the account host (never leak localhost upstream).
|
||||||
|
if (rawOrigin) headers.Origin = `https://${ACCOUNT_HOST}`;
|
||||||
|
const fixedReferer = fixOriginUrl(headers.referer || headers.Referer);
|
||||||
|
headers.Referer = fixedReferer
|
||||||
|
? deRewriteUrl(fixedReferer, origin, sess.upstreamBase)
|
||||||
|
: `https://${ACCOUNT_HOST}/fe/service/login`;
|
||||||
|
delete headers.referer;
|
||||||
|
|
||||||
|
const init = { method: req.method || "GET", headers };
|
||||||
|
if (init.method !== "GET" && init.method !== "HEAD") {
|
||||||
|
let buf = Buffer.from(await req.arrayBuffer());
|
||||||
|
// The SPA reads callback params from location.search (which we rewrote to
|
||||||
|
// our origin) and can echo them in the POST BODY — reverse that too, or
|
||||||
|
// Xiaomi rejects with 10025 "Callback连接不合法".
|
||||||
|
const ctBody = String(headers["Content-Type"] || headers["content-type"] || "");
|
||||||
|
if (buf.length && /urlencoded|json|text/i.test(ctBody)) {
|
||||||
|
const before = buf.toString("utf8");
|
||||||
|
const after = rewriteMimoBases(before, "toUpstream", origin, sess.upstreamBase);
|
||||||
|
if (after !== before) {
|
||||||
|
buf = Buffer.from(after, "utf8");
|
||||||
|
headers["Content-Length"] = String(buf.length);
|
||||||
|
} else if (/__mimo_login|localhost/.test(before)) {
|
||||||
|
// Anomaly: a local callback shape we cannot rewrite — must never reach Xiaomi.
|
||||||
|
console.log(`${new Date().toISOString().slice(11, 23)} [mimo-login] body STILL local, not matchable: ${before.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init.body = buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow same-host redirects server-side (they carry Set-Cookie we must keep).
|
||||||
|
let current = upstreamStr;
|
||||||
|
let res = null;
|
||||||
|
const requestCookies = cookieHeaderFor(sess, current) || browserCookieHeader(req);
|
||||||
|
for (let hop = 0; hop < 8; hop++) {
|
||||||
|
res = await fetchUpstream(sess, current, hop === 0 ? init : { ...init, body: undefined }, requestCookies);
|
||||||
|
absorbSetCookies(sess, res, current);
|
||||||
|
const loc = res.headers.get("location");
|
||||||
|
if (res.status >= 300 && res.status < 400 && loc) {
|
||||||
|
const nextAbs = new URL(loc, current);
|
||||||
|
if (nextAbs.hostname === ACCOUNT_HOST) {
|
||||||
|
current = nextAbs.toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Cross-host redirect to mimo-server: rewrite into our takeover space so
|
||||||
|
// the browser stays on our origin.
|
||||||
|
if (/mimo-server-(cn|sgp)\.xiaomimimo\.com/.test(nextAbs.hostname)) {
|
||||||
|
const proxiedLoc = rewriteMimoBases(nextAbs.toString(), "toProxy", origin);
|
||||||
|
return new Response(null, { status: res.status, headers: { Location: proxiedLoc, "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
// Any other host: pass through.
|
||||||
|
return new Response(null, { status: res.status, headers: { Location: loc } });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildBrowserResponse(sess, res, origin, u.pathname, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBrowserResponse(sess, res, origin, reqPath = "", upstreamUrl = "") {
|
||||||
|
const outHeaders = new Headers();
|
||||||
|
const pass = ["content-type", "cache-control", "etag", "last-modified", "date"];
|
||||||
|
for (const h of pass) {
|
||||||
|
const v = res.headers.get(h);
|
||||||
|
if (v) outHeaders.set(h, v);
|
||||||
|
}
|
||||||
|
// Allow embedding in our modal (upstream often sends X-Frame-Options).
|
||||||
|
outHeaders.delete("x-frame-options");
|
||||||
|
outHeaders.delete("content-security-policy");
|
||||||
|
outHeaders.delete("content-security-policy-report-only");
|
||||||
|
outHeaders.set("Cache-Control", "no-store");
|
||||||
|
// Deliberately NOT forwarding upstream Set-Cookie to the browser: every
|
||||||
|
// proxied request already strips the browser Cookie header, so upstream
|
||||||
|
// auth lives only in the server-side jar. Replayed cookies would land on
|
||||||
|
// our origin's jar (some without HttpOnly → readable by any script here).
|
||||||
|
|
||||||
|
const ctType = res.headers.get("content-type") || "";
|
||||||
|
const isText = /text\/|javascript|json/.test(ctType);
|
||||||
|
if (!isText) return new Response(res.body, { status: res.status, headers: outHeaders });
|
||||||
|
|
||||||
|
return res.text().then((rawBody) => {
|
||||||
|
// JSON allows \/ as an escaped slash — Xiaomi backends (PHP-style) emit
|
||||||
|
// "https:\/\/mimo-server..." which defeats scheme-based string matching
|
||||||
|
// AND the SPA JSON.parses it back to a real URL (this leaked the sts
|
||||||
|
// callback straight to the browser -> cross-origin 401). Unescaping \/ to
|
||||||
|
// / inside JSON string values is semantics-preserving and stays valid.
|
||||||
|
const body = /json/.test(ctType) ? rawBody.replaceAll("\\/", "/") : rawBody;
|
||||||
|
// Surface upstream API errors (Xiaomi wraps JSON as &&&START&&&{code:...}).
|
||||||
|
if (/^\/(pass|sts)/.test(reqPath) || res.status >= 400) {
|
||||||
|
const m = body.match(/"code"\s*:\s*(-?\d+)/);
|
||||||
|
if ((m && m[1] !== "0") || res.status >= 400) {
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] upstream ${reqPath} http=${res.status} code=${m ? m[1] : "?"} | url=${upstreamUrl.slice(0, 180)} | ${body.replace(/\s+/g, " ").slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rewritten = rewriteMimoBases(body, "toProxy", origin);
|
||||||
|
return new Response(rewritten, { status: res.status, headers: outHeaders });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take over the mimo-server tail of the flow (sts -> me) server-side.
|
||||||
|
* Runs the whole redirect chain, absorbs cookies, verifies me=logged-in.
|
||||||
|
*/
|
||||||
|
export async function runTakeover(sess, upstreamUrl, origin) {
|
||||||
|
const T = () => new Date().toISOString().slice(11, 23);
|
||||||
|
const log = (m) => console.log(`${T()} [mimo-login][takeover] ${m}`);
|
||||||
|
const idSnap = () => {
|
||||||
|
const id = readSessionIdentity(sess);
|
||||||
|
const names = [...sess.jar.keys()].map((k) => k.split("|")[0]).join(",");
|
||||||
|
return `passToken=${id ? "Y" : "N"} jar=[${names.slice(0, 160)}]`;
|
||||||
|
};
|
||||||
|
log(`sts-nav url=${upstreamUrl.slice(0, 160)} origin=${origin} | ${idSnap()}`);
|
||||||
|
const id = readSessionIdentity(sess);
|
||||||
|
// AUTHORIZATION COMPLETION = passToken captured (that IS the credential the
|
||||||
|
// connection persists for the account route). The serviceToken exchange
|
||||||
|
// (weekly quota) is intentionally NOT part of completion — its API moved;
|
||||||
|
// ensureServiceSession stays exported for when that gets re-wired.
|
||||||
|
if (id) {
|
||||||
|
sess.status = "done";
|
||||||
|
return donePage();
|
||||||
|
}
|
||||||
|
return pendingPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlPage(title, lines, autoClose = false) {
|
||||||
|
const body = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#111;color:#eee}
|
||||||
|
.card{padding:2rem 2.5rem;border-radius:12px;background:#1b1b1f;text-align:center;max-width:420px}
|
||||||
|
h1{font-size:1.1rem;margin:.2rem 0 .6rem}p{opacity:.8;font-size:.9rem;line-height:1.6}</style></head>
|
||||||
|
<body><div class="card"><h1>${title}</h1>${lines.map((l) => `<p>${l}</p>`).join("")}</div>
|
||||||
|
<script>setTimeout(function(){try{window.opener&&window.opener.postMessage({mimoSession:"ok"},location.origin)}catch(e){}},300);${autoClose ? "setTimeout(function(){try{window.close()}catch(e){}},1600);" : ""}</script>
|
||||||
|
</body></html>`;
|
||||||
|
return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function donePage() {
|
||||||
|
return htmlPage("登录成功 ✅", ["账号会话已捕获,可以关闭此窗口。", "回到 9router 弹窗继续。"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingPage() {
|
||||||
|
return htmlPage("登录未完成", ["未检测到有效会话,请重试。"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __test__ = { STRIP_UPSTREAM_HEADERS, buildBrowserResponse };
|
||||||
66
src/proxy.js
66
src/proxy.js
@@ -1,6 +1,70 @@
|
|||||||
import { proxy as dashboardProxy } from "./dashboardGuard";
|
import { proxy as dashboardProxy, isAuthenticated } from "./dashboardGuard";
|
||||||
|
import {
|
||||||
|
sessionFromRequest,
|
||||||
|
isAccountProxyPath,
|
||||||
|
isMimoTakeoverPath,
|
||||||
|
takeoverUpstreamPath,
|
||||||
|
proxyAccountRequest,
|
||||||
|
runTakeover,
|
||||||
|
attachSessionCookie,
|
||||||
|
originOf,
|
||||||
|
} from "./lib/mimoLoginSession";
|
||||||
|
|
||||||
export default async function proxy(request) {
|
export default async function proxy(request) {
|
||||||
|
// Xiaomi account session-login proxy (src/lib/mimoLoginSession.js).
|
||||||
|
// Session state (region + accumulated cookie jar) travels in the httpOnly
|
||||||
|
// 9r_mimo_login cookie — route handlers and this proxy run in separate
|
||||||
|
// bundles, so module-level maps are NOT shared. The cookie is only set by
|
||||||
|
// the auth-gated login/start route, and the branch below ALSO requires a
|
||||||
|
// valid dashboard session: a forged 9r_mimo_login cookie (client-controlled
|
||||||
|
// header, unsigned payload) must never turn the app into an unauthenticated
|
||||||
|
// forwarder. No URL-carried session — it would leak the jar via history/logs/Referer.
|
||||||
|
const cookies = request.headers.get("cookie") || "";
|
||||||
|
const hasSessionCookie = cookies.includes("9r_mimo_login=");
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
if (hasSessionCookie && !(await isAuthenticated(request))) {
|
||||||
|
// Forged or stale session cookie without dashboard auth — drop it early.
|
||||||
|
const res = await dashboardProxy(request);
|
||||||
|
const headers = new Headers(res.headers);
|
||||||
|
headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
||||||
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
||||||
|
}
|
||||||
|
if (!hasSessionCookie && /^\/(fe\/|pass)/.test(pathname) && !pathname.startsWith("/_next")) {
|
||||||
|
// Anomaly: a login-flow XHR arrived without the session — the classic
|
||||||
|
// cause of silent SPA "Something went wrong" 404s. Narrow to login paths
|
||||||
|
// so unrelated unknown routes don't spam this.
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] no-session ${pathname} (cookie header: ${cookies ? cookies.slice(0, 80) : "<none>"})`);
|
||||||
|
}
|
||||||
|
if (hasSessionCookie) {
|
||||||
|
const sess = sessionFromRequest(request);
|
||||||
|
if (sess) {
|
||||||
|
const origin = originOf(request);
|
||||||
|
try {
|
||||||
|
if (isMimoTakeoverPath(pathname)) {
|
||||||
|
const upstreamUrl = `${sess.upstreamBase}${takeoverUpstreamPath(pathname)}${request.nextUrl.search || ""}`;
|
||||||
|
return attachSessionCookie(await runTakeover(sess, upstreamUrl, origin), sess);
|
||||||
|
}
|
||||||
|
if (isAccountProxyPath(pathname)) {
|
||||||
|
return attachSessionCookie(await proxyAccountRequest(sess, request, origin), sess);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxy error:`, e?.message || e);
|
||||||
|
return new Response("mimo login proxy error", { status: 502 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Anomaly (should not happen in a healthy flow): cookie present but unparseable.
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] session cookie undecodable — falling through (${pathname})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie present but expired/invalid — clear it on the way past.
|
||||||
|
if (hasSessionCookie) {
|
||||||
|
const res = await dashboardProxy(request);
|
||||||
|
const headers = new Headers(res.headers);
|
||||||
|
headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
||||||
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
||||||
|
}
|
||||||
|
|
||||||
return dashboardProxy(request);
|
return dashboardProxy(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,105 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Modal, Button } from "@/shared/components";
|
import { Modal, Button } from "@/shared/components";
|
||||||
|
import { translate } from "@/i18n/runtime";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Xiaomi MiMo Auth Modal
|
* Xiaomi MiMo Desktop Plan auth modal.
|
||||||
*
|
*
|
||||||
* Auto-imports credentials from the local Xiaomi MiMo Desktop auth.json (~/.local/share/mimocode/auth.json).
|
* Desktop Plan = Xiaomi account weekly quota (mimo-v2.6 family). Two ways in:
|
||||||
* If auto-import fails, offers a one-click browser OAuth fallback.
|
* 1. Import local MiMo Desktop credentials (~/.local/share/mimocode/auth.json)
|
||||||
* Reached only via the "Connect with OAuth" button — the API-key path uses the
|
* 2. Server-side login — no Desktop needed; picks a MiMo account cluster
|
||||||
* standard Add API Key modal, since Xiaomi MiMo supports both auth modes.
|
* (cn / sgp / ams / ru / in). The account session (passToken) is captured
|
||||||
|
* server-side and stored per connection.
|
||||||
|
*
|
||||||
|
* Token Plan (cloud sk- API key) uses the standard "API Key" entry point.
|
||||||
*/
|
*/
|
||||||
export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
|
||||||
const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing | error
|
|
||||||
const [detectResult, setDetectResult] = useState(null);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [oauthUrl, setOauthUrl] = useState(null);
|
|
||||||
const [oauthState, setOauthState] = useState(null);
|
|
||||||
|
|
||||||
// Auto-detect local credentials when modal opens
|
const CLUSTERS = [
|
||||||
|
{ id: "cn", flag: "🇨🇳", name: "China (Mainland)", host: "mimo-server-cn" },
|
||||||
|
{ id: "sgp", flag: "🇸🇬", name: "Singapore", host: "mimo-server-sgp" },
|
||||||
|
{ id: "ams", flag: "🇪🇺", name: "Europe · Amsterdam", host: "mimo-server-ams" },
|
||||||
|
{ id: "ru", flag: "🇷🇺", name: "Russia", host: "mimo-server-ru" },
|
||||||
|
{ id: "in", flag: "🇮🇳", name: "India", host: "mimo-server-in" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
||||||
|
const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing
|
||||||
|
const [detectResult, setDetectResult] = useState(null);
|
||||||
|
const [existingConnection, setExistingConnection] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Server-side session login
|
||||||
|
const [showClusterModal, setShowClusterModal] = useState(false);
|
||||||
|
const [sessBusy, setSessBusy] = useState(false);
|
||||||
|
const [sessPolling, setSessPolling] = useState(false);
|
||||||
|
const [sessError, setSessError] = useState(null);
|
||||||
|
const [sessPageUrl, setSessPageUrl] = useState(null);
|
||||||
|
const [sessRegion, setSessRegion] = useState("cn");
|
||||||
|
const sessTimerRef = useRef(null);
|
||||||
|
|
||||||
|
const stopSessionPoll = () => {
|
||||||
|
if (sessTimerRef.current) {
|
||||||
|
clearInterval(sessTimerRef.current);
|
||||||
|
sessTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setSessPolling(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => () => stopSessionPoll(), []);
|
||||||
|
|
||||||
|
// Detect local credentials when the modal opens (non-blocking: never trap the spinner)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
(async () => {
|
setPhase("detecting");
|
||||||
setPhase("detecting");
|
setError(null);
|
||||||
setError(null);
|
setDetectResult(null);
|
||||||
setDetectResult(null);
|
setExistingConnection(null);
|
||||||
setOauthUrl(null);
|
setShowClusterModal(false);
|
||||||
|
setSessError(null);
|
||||||
|
|
||||||
|
const runDetect = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/oauth/xiaomi-mimo/auto-import");
|
const res = await fetch("/api/oauth/xiaomi-mimo/auto-import", {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (data.found && data.apiKey) {
|
if (data.found && data.apiKey) {
|
||||||
setDetectResult(data);
|
setDetectResult(data);
|
||||||
setPhase("found");
|
setPhase("found");
|
||||||
|
|
||||||
|
// Non-blocking: flag an already-imported account
|
||||||
|
fetch("/api/providers", { signal: AbortSignal.timeout(3000) })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((provData) => {
|
||||||
|
const foundConn = (provData.connections || []).find(
|
||||||
|
(c) =>
|
||||||
|
c.provider === "xiaomi-mimo" &&
|
||||||
|
data.uid &&
|
||||||
|
(c.email === `${data.uid}@xiaomi` ||
|
||||||
|
c.providerSpecificData?.uid === data.uid ||
|
||||||
|
c.providerSpecificData?.mimoUserId === data.uid),
|
||||||
|
);
|
||||||
|
if (foundConn) setExistingConnection(foundConn);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
setPhase("not-found");
|
setPhase("not-found");
|
||||||
setError(data.error || "Xiaomi MiMo Desktop credentials not found on this machine.");
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
setPhase("not-found");
|
||||||
setPhase("not-found");
|
|
||||||
setError("Failed to read local Xiaomi MiMo Desktop credentials.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
runDetect();
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Import the auto-detected key
|
// Import the auto-detected local desktop credentials
|
||||||
const handleImport = async () => {
|
const handleImportLocal = async () => {
|
||||||
if (!detectResult?.apiKey) return;
|
if (!detectResult?.apiKey) return;
|
||||||
setPhase("importing");
|
setPhase("importing");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -73,11 +118,7 @@ export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.success) throw new Error(data.error || "Import failed");
|
||||||
if (!res.ok || !data.success) {
|
|
||||||
throw new Error(data.error || "Import failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess?.(data.connection);
|
onSuccess?.(data.connection);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -86,183 +127,265 @@ export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start browser OAuth fallback
|
// Server-side login (account.xiaomi.com) for the chosen cluster
|
||||||
const handleStartOAuth = async () => {
|
const startSessionLogin = async (region) => {
|
||||||
setError(null);
|
setSessBusy(true);
|
||||||
|
setSessError(null);
|
||||||
|
setShowClusterModal(false);
|
||||||
|
setSessRegion(region);
|
||||||
try {
|
try {
|
||||||
const state = crypto.randomUUID();
|
const res = await fetch("/api/oauth/xiaomi-mimo/login/start", {
|
||||||
const res = await fetch(`/api/oauth/xiaomi-mimo/authorize?state=${state}`);
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ region }),
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.authorizeUrl) {
|
if (!res.ok || !data.pageUrl) throw new Error(data.error || "Failed to start login");
|
||||||
setOauthUrl(data.authorizeUrl);
|
setSessPageUrl(data.pageUrl);
|
||||||
setOauthState(data.state);
|
window.open(data.pageUrl, "mimo-session-login", "width=500,height=760");
|
||||||
window.open(data.authorizeUrl, "_blank", "width=600,height=700");
|
setSessPolling(true);
|
||||||
} else {
|
const startedAt = Date.now();
|
||||||
throw new Error(data.error || "Failed to start OAuth");
|
sessTimerRef.current = setInterval(async () => {
|
||||||
}
|
if (Date.now() - startedAt > 14 * 60 * 1000) {
|
||||||
} catch (err) {
|
stopSessionPoll();
|
||||||
setError(err.message);
|
setSessError("Login timed out. Please retry.");
|
||||||
}
|
return;
|
||||||
};
|
|
||||||
|
|
||||||
// Poll OAuth result
|
|
||||||
const handlePollOAuth = async () => {
|
|
||||||
if (!oauthState) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/oauth/xiaomi-mimo/poll-status?state=${oauthState}`);
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.status === "done" && data.result) {
|
|
||||||
// Exchange to create the connection
|
|
||||||
const exRes = await fetch("/api/oauth/xiaomi-mimo/exchange", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ state: oauthState }),
|
|
||||||
});
|
|
||||||
const exData = await exRes.json();
|
|
||||||
if (exData.success) {
|
|
||||||
onSuccess?.(exData.connection);
|
|
||||||
onClose();
|
|
||||||
} else {
|
|
||||||
throw new Error(exData.error || "Exchange failed");
|
|
||||||
}
|
}
|
||||||
} else if (data.status === "error") {
|
try {
|
||||||
throw new Error(data.error || "OAuth failed");
|
const sres = await fetch(`/api/oauth/xiaomi-mimo/login/status?state=${data.state}`);
|
||||||
} else {
|
const sd = await sres.json();
|
||||||
setError("Authorization not completed yet. Finish in the browser, then click Check Again.");
|
if (sd.status === "pending") return;
|
||||||
}
|
stopSessionPoll();
|
||||||
|
if (sd.status !== "done") {
|
||||||
|
setSessError(sd.error || "Login session expired — please retry.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const save = await fetch("/api/oauth/xiaomi-mimo/api-key", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiKey: "",
|
||||||
|
uid: sd.userId || null,
|
||||||
|
mimoPassToken: sd.passToken,
|
||||||
|
mimoUserId: sd.userId || null,
|
||||||
|
mimoCUserId: sd.cUserId || null,
|
||||||
|
region: sd.region || sessRegion,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const saved = await save.json();
|
||||||
|
if (!save.ok || !saved.success) throw new Error(saved.error || "Failed to save credentials");
|
||||||
|
onSuccess?.(saved.connection);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
stopSessionPoll();
|
||||||
|
setSessError(err.message);
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setSessError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSessBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The server-login card, shown in both the found and not-found states
|
||||||
|
const renderServerLogin = () => (
|
||||||
|
<div className="bg-card p-3 rounded-lg border border-border flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="material-symbols-outlined text-primary text-base">login</span>
|
||||||
|
<span className="text-xs sm:text-sm font-semibold">Browser Login</span>
|
||||||
|
<span className="text-[11px] text-text-muted">No Desktop required</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-text-muted">Weekly quota</span>
|
||||||
|
</div>
|
||||||
|
{sessPolling ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" fullWidth disabled>
|
||||||
|
Waiting for login...
|
||||||
|
</Button>
|
||||||
|
{sessPageUrl && (
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open(sessPageUrl, "mimo-session-login", "width=500,height=760")}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Reopen login window
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setShowClusterModal(true)} variant="outline" size="sm" fullWidth disabled={sessBusy}>
|
||||||
|
{sessBusy ? "Starting..." : "Choose cluster & sign in"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{sessError && <p className="text-[11px] text-red-500">{translate(sessError)}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} title="Connect Xiaomi MiMo" onClose={onClose}>
|
<Modal isOpen={isOpen} title={translate("Connect Xiaomi MiMo")} onClose={onClose} size="lg">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3.5">
|
||||||
{/* Detecting */}
|
{/* Detecting */}
|
||||||
{phase === "detecting" && (
|
{phase === "detecting" && (
|
||||||
<div className="text-center py-6">
|
<div className="text-center py-6">
|
||||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
<div className="size-12 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
<span className="material-symbols-outlined text-2xl text-primary animate-spin">
|
||||||
progress_activity
|
progress_activity
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Reading local credentials...</h3>
|
<p className="text-sm text-text-muted">Reading local MiMo Desktop credentials...</p>
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
Checking ~/.local/share/mimocode/auth.json
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Found — one-click import */}
|
|
||||||
{phase === "found" && detectResult && (
|
|
||||||
<>
|
|
||||||
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<span className="material-symbols-outlined text-green-600 dark:text-green-400">check_circle</span>
|
|
||||||
<div className="text-sm text-green-800 dark:text-green-200">
|
|
||||||
<p className="font-medium">Xiaomi MiMo Desktop credentials found!</p>
|
|
||||||
<p className="mt-1 opacity-80">
|
|
||||||
UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleImport} fullWidth>
|
|
||||||
Connect with Local Credentials
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Importing */}
|
{/* Importing */}
|
||||||
{phase === "importing" && (
|
{phase === "importing" && (
|
||||||
<div className="text-center py-6">
|
<div className="text-center py-6">
|
||||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
<div className="size-12 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
<span className="material-symbols-outlined text-2xl text-primary animate-spin">
|
||||||
progress_activity
|
progress_activity
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Connecting...</h3>
|
<p className="text-sm font-medium">Connecting...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Not found — offer OAuth fallback */}
|
{/* Found local desktop credentials */}
|
||||||
|
{phase === "found" && detectResult && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-muted px-0.5">
|
||||||
|
<span className="material-symbols-outlined text-primary text-sm">desktop_windows</span>
|
||||||
|
<span>Desktop Plan · Local credentials</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{existingConnection ? (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex gap-2.5 items-start">
|
||||||
|
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400 text-lg mt-0.5">
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="font-medium">This account is already connected (no need to import again)</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-80">
|
||||||
|
UID: {detectResult.uid || "—"} · Status: {existingConnection.testStatus === "active" ? "Active" : "Untested"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex gap-2.5 items-start">
|
||||||
|
<span className="material-symbols-outlined text-green-600 dark:text-green-400 text-lg mt-0.5">
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
<div className="text-sm text-green-800 dark:text-green-200">
|
||||||
|
<p className="font-medium">Xiaomi MiMo Desktop credentials found!</p>
|
||||||
|
<p className="text-xs mt-0.5 opacity-80">
|
||||||
|
UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 p-2.5 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400">{translate(error)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleImportLocal} variant={existingConnection ? "outline" : "default"} fullWidth>
|
||||||
|
{existingConnection ? "Re-sync local credentials" : "Connect with local credentials"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||||
|
{existingConnection ? "Close" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative my-1">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs text-text-muted">
|
||||||
|
<span className="bg-card px-2">or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderServerLogin()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No local desktop credentials */}
|
||||||
{phase === "not-found" && (
|
{phase === "not-found" && (
|
||||||
<>
|
<div className="flex flex-col gap-3">
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg border border-amber-200 dark:border-amber-800">
|
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex gap-2.5 items-start">
|
||||||
<span className="material-symbols-outlined text-amber-600 dark:text-amber-400">info</span>
|
<span className="material-symbols-outlined text-amber-600 dark:text-amber-400 text-lg mt-0.5">info</span>
|
||||||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
<div className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
<p className="font-medium">Local credentials not found</p>
|
<p className="font-medium">No local Desktop credentials found</p>
|
||||||
<p className="mt-1 opacity-80">{error}</p>
|
<p className="text-xs mt-0.5 opacity-80">
|
||||||
<p className="mt-2 opacity-80">
|
You can still sign in via browser — no Desktop client needed.
|
||||||
Make sure Xiaomi MiMo Desktop is installed and you are signed in, then retry.
|
|
||||||
Or sign in via browser below.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!oauthUrl ? (
|
{renderServerLogin()}
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
<div className="mt-1">
|
||||||
onClick={() => {
|
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||||
setPhase("detecting");
|
Cancel
|
||||||
// Re-trigger detect
|
</Button>
|
||||||
fetch("/api/oauth/xiaomi-mimo/auto-import")
|
</div>
|
||||||
.then((r) => r.json())
|
</div>
|
||||||
.then((data) => {
|
)}
|
||||||
if (data.found && data.apiKey) {
|
|
||||||
setDetectResult(data);
|
{/* Cluster selection sub-modal */}
|
||||||
setPhase("found");
|
{showClusterModal && (
|
||||||
} else {
|
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
|
||||||
setPhase("not-found");
|
<div className="relative w-full max-w-sm bg-surface border border-border-subtle rounded-[14px] shadow-2xl p-5 flex flex-col gap-3.5 animate-in zoom-in-95 duration-150">
|
||||||
setError(data.error || "Still not found.");
|
<div className="flex items-center justify-between">
|
||||||
}
|
<div className="flex items-center gap-2">
|
||||||
})
|
<span className="material-symbols-outlined text-primary text-lg">public</span>
|
||||||
.catch(() => setPhase("not-found"));
|
<h3 className="text-sm font-semibold">Select account cluster</h3>
|
||||||
}}
|
</div>
|
||||||
variant="outline"
|
<button
|
||||||
fullWidth
|
type="button"
|
||||||
|
onClick={() => setShowClusterModal(false)}
|
||||||
|
className="text-text-muted hover:text-text-primary p-1 rounded-md transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Retry Local Detect
|
<span className="material-symbols-outlined text-lg">close</span>
|
||||||
</Button>
|
</button>
|
||||||
<Button onClick={handleStartOAuth} fullWidth>
|
|
||||||
Sign in via Browser
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
|
<p className="text-xs text-text-muted">Choose the region cluster of your Xiaomi account:</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
{CLUSTERS.map((c) => (
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
<button
|
||||||
Browser opened. Complete the Xiaomi sign-in, then click{" "}
|
key={c.id}
|
||||||
<strong>Check Again</strong>.
|
type="button"
|
||||||
</p>
|
onClick={() => startSessionLogin(c.id)}
|
||||||
</div>
|
className="p-3 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-all text-left flex items-start gap-3 group cursor-pointer"
|
||||||
<div className="flex gap-2">
|
>
|
||||||
<Button onClick={handlePollOAuth} fullWidth>
|
<span className="text-2xl mt-0.5">{c.flag}</span>
|
||||||
Check Again
|
<div className="flex-1 min-w-0">
|
||||||
</Button>
|
<div className="text-sm font-semibold group-hover:text-primary transition-colors">
|
||||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
{c.name}
|
||||||
Cancel
|
</div>
|
||||||
</Button>
|
<div className="text-xs text-text-muted mt-0.5">{c.host}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
<Button onClick={() => setShowClusterModal(false)} variant="ghost" size="sm" fullWidth>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js";
|
import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js";
|
||||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||||
|
import * as mimoAccount from "../../open-sse/shared/mimoAccount.js";
|
||||||
|
|
||||||
const { bareModel, COOKIE_KEY } = __test__;
|
const { bareModel, COOKIE_KEY } = __test__;
|
||||||
|
|
||||||
@@ -17,22 +18,52 @@ describe("xiaomi-mimo executor", () => {
|
|||||||
expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor);
|
expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes Preview models to the account-service route regardless of transport", () => {
|
|
||||||
const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions";
|
|
||||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected);
|
|
||||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected);
|
|
||||||
// body.model arrives as `xiaomi/<id>` via upstreamModelId
|
|
||||||
expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the sourceFormat-matched endpoint for cloud models", () => {
|
it("keeps the sourceFormat-matched endpoint for cloud models", () => {
|
||||||
// Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions.
|
|
||||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
||||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("authenticates Preview calls with the account cookie", () => {
|
it("routes v2.6 models to account route when desktop credentials are present", () => {
|
||||||
const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview");
|
// No region → SGP default
|
||||||
|
const expected = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions";
|
||||||
|
const credsWithToken = { providerSpecificData: { mimoPassToken: "token123" } };
|
||||||
|
const credsWithCookie = { [COOKIE_KEY]: "serviceToken=abc" };
|
||||||
|
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, credsWithCookie)).toBe(expected);
|
||||||
|
expect(ex.buildUrl("xiaomi/mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes v2.6 models to cloud API when no desktop credentials are present", () => {
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the account-service cluster per connection region", () => {
|
||||||
|
const cn = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions";
|
||||||
|
const sgp = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions";
|
||||||
|
const ams = "https://mimo-server-ams.xiaomimimo.com/api/route/chat/completions";
|
||||||
|
const ru = "https://mimo-server-ru.xiaomimimo.com/api/route/chat/completions";
|
||||||
|
const inRegion = "https://mimo-server-in.xiaomimimo.com/api/route/chat/completions";
|
||||||
|
// default (no region) falls back to SGP (the international cluster)
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { mimoPassToken: "t" } })).toBe(sgp);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "cn", mimoPassToken: "t" } })).toBe(cn);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "sgp", mimoPassToken: "t" } })).toBe(sgp);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { region: "SGP", mimoPassToken: "t" } })).toBe(sgp);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ams", mimoPassToken: "t" } })).toBe(ams);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ru", mimoPassToken: "t" } })).toBe(ru);
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "in", mimoPassToken: "t" } })).toBe(inRegion);
|
||||||
|
// unknown region falls back to SGP
|
||||||
|
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "eu", mimoPassToken: "t" } })).toBe(sgp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authenticates v2.6 calls with account cookie when on account route", () => {
|
||||||
|
const headers = ex.buildHeaders(
|
||||||
|
{ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" },
|
||||||
|
true,
|
||||||
|
"u",
|
||||||
|
"mimo-v2.6-flash",
|
||||||
|
);
|
||||||
expect(headers.Cookie).toBe("serviceToken=abc");
|
expect(headers.Cookie).toBe("serviceToken=abc");
|
||||||
expect(headers.Authorization).toBeUndefined();
|
expect(headers.Authorization).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -43,38 +74,55 @@ describe("xiaomi-mimo executor", () => {
|
|||||||
expect(headers.Cookie).toBeUndefined();
|
expect(headers.Cookie).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast when a Preview call has no account session", async () => {
|
it("preserves content-part arrays for multimodal inputs", () => {
|
||||||
await expect(
|
const parts = [{ type: "image_url", image_url: { url: "data:image/png;base64,xyz" } }, { type: "text", text: "hi" }];
|
||||||
ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }),
|
|
||||||
).rejects.toThrow(/account session unavailable/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flattens content-part arrays to plain strings", () => {
|
|
||||||
const out = ex.transformRequest(
|
const out = ex.transformRequest(
|
||||||
"mimo-x-pro-preview",
|
"mimo-v2.6-pro",
|
||||||
{ messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] },
|
{ messages: [{ role: "user", content: parts }] },
|
||||||
true,
|
true,
|
||||||
{},
|
{ providerSpecificData: { mimoPassToken: "token" } },
|
||||||
);
|
);
|
||||||
expect(out.messages[0].content).toBe("ab");
|
expect(out.messages[0].content).toEqual(parts);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies Preview defaults without overriding explicit values", () => {
|
it("bridges reasoning_effort to official output_config.effort", () => {
|
||||||
|
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||||
|
const body = {
|
||||||
|
messages: [{ role: "user", content: "solve" }],
|
||||||
|
reasoning_effort: "high",
|
||||||
|
};
|
||||||
|
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||||
|
expect(out.reasoning_effort).toBeUndefined();
|
||||||
|
expect(out.output_config).toEqual({ effort: "high" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes xhigh reasoning_effort to high in output_config.effort", () => {
|
||||||
|
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||||
|
const body = {
|
||||||
|
messages: [{ role: "user", content: "complex" }],
|
||||||
|
reasoning_effort: "xhigh",
|
||||||
|
};
|
||||||
|
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||||
|
expect(out.reasoning_effort).toBeUndefined();
|
||||||
|
expect(out.output_config).toEqual({ effort: "high" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies defaults without overriding explicit values", () => {
|
||||||
|
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||||
const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 };
|
const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 };
|
||||||
const out = ex.transformRequest("mimo-x-pro-preview", body, true, {});
|
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||||
expect(out.temperature).toBe(0.2); // caller's value kept
|
expect(out.temperature).toBe(0.2);
|
||||||
expect(out.top_p).toBe(0.95); // default filled in
|
expect(out.top_p).toBe(0.95);
|
||||||
expect(out.max_tokens).toBe(4096);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves cloud bodies free of Preview defaults", () => {
|
it("leaves cloud bodies free of account defaults", () => {
|
||||||
const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {});
|
const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {});
|
||||||
expect(out.thinking).toBeUndefined();
|
expect(out.output_config).toBeUndefined();
|
||||||
expect(out.max_tokens).toBeUndefined();
|
expect(out.temperature).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips a provider/model prefix when testing preview ids", () => {
|
it("strips a provider/model prefix when testing model ids", () => {
|
||||||
expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
expect(bareModel("xiaomi/mimo-v2.6-pro")).toBe("mimo-v2.6-pro");
|
||||||
expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
expect(bareModel("mimo-v2.6-flash")).toBe("mimo-v2.6-flash");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
40
tests/unit/xiaomi-mimo-login-session-security.test.js
Normal file
40
tests/unit/xiaomi-mimo-login-session-security.test.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Security invariants of the server-assisted MiMo login proxy
|
||||||
|
* (src/lib/mimoLoginSession.js):
|
||||||
|
* - credentials bound to 9router's own origin are never forwarded upstream
|
||||||
|
* - upstream Set-Cookie is never replayed onto the app's own cookie jar
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { __test__ } from "../../src/lib/mimoLoginSession.js";
|
||||||
|
|
||||||
|
const { STRIP_UPSTREAM_HEADERS, buildBrowserResponse } = __test__;
|
||||||
|
|
||||||
|
describe("mimo login proxy security", () => {
|
||||||
|
it("strips auth credentials and session cookies before forwarding upstream", () => {
|
||||||
|
for (const h of ["authorization", "proxy-authorization", "cookie", "host"]) {
|
||||||
|
expect(STRIP_UPSTREAM_HEADERS.has(h)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not replay upstream Set-Cookie onto the app origin", async () => {
|
||||||
|
const upstream = new Response("ok", {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/html",
|
||||||
|
"set-cookie": "userId=123; Path=/", // plain object header: visible via getSetCookie
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/pass/");
|
||||||
|
expect(out.headers.getSetCookie()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps ordinary response headers intact", async () => {
|
||||||
|
const upstream = new Response("<html></html>", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html" },
|
||||||
|
});
|
||||||
|
const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/fe/");
|
||||||
|
expect(out.status).toBe(200);
|
||||||
|
expect(out.headers.get("content-type")).toBe("text/html");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user