#!/usr/bin/env node /** * Grix public auth API helper (TypeScript). * * Notes: * - Do not read any environment variables. * - Emits JSON to stdout for success/error, except --help. */ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; type JsonObject = Record; const DEFAULT_BASE_URL = "https://grix.dhf.pub"; const DEFAULT_TIMEOUT_MS = 15_000; class GrixAuthError extends Error { status: number; code: number; payload: unknown; constructor(message: string, status = 0, code = -1, payload: unknown = null) { super(message); this.name = "GrixAuthError"; this.status = status; this.code = code; this.payload = payload; } } function printJson(payload: unknown): void { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); } function usageText(): string { return [ "Usage:", " node scripts/grix_auth.ts [--base-url ] [options]", "", "Actions:", " fetch-captcha", " send-email-code --email --scene [--captcha-id ] [--captcha-value ]", " register --email --password --email-code [--device-id ] [--platform ]", " login (--account |--email ) --password [--device-id ] [--platform ]", " create-api-agent --access-token --agent-name [--avatar-url ] [--no-reuse-existing-agent] [--no-rotate-key-on-reuse]", ].join("\n"); } function normalizeBaseUrl(rawBaseUrl: string): string { const base = (rawBaseUrl ?? "").trim() || DEFAULT_BASE_URL; let parsed: URL; try { parsed = new URL(base); } catch { throw new Error(`Invalid base URL: ${base}`); } if (!parsed.protocol || !parsed.host) { throw new Error(`Invalid base URL: ${base}`); } let path = parsed.pathname.replace(/\/+$/, ""); if (!path) { path = "/v1"; } else if (!path.endsWith("/v1")) { path = `${path}/v1`; } parsed.pathname = path; parsed.search = ""; parsed.hash = ""; return parsed.toString().replace(/\/$/, ""); } function derivePortalUrl(rawBaseUrl: string): string { const base = (rawBaseUrl ?? "").trim() || DEFAULT_BASE_URL; let parsed: URL; try { parsed = new URL(base); } catch { throw new Error(`Invalid base URL: ${base}`); } if (!parsed.protocol || !parsed.host) { throw new Error(`Invalid base URL: ${base}`); } let path = parsed.pathname.replace(/\/+$/, ""); if (path.endsWith("/v1")) { path = path.slice(0, -"/v1".length); } path = path.replace(/\/+$/, ""); if (!path) { path = "/"; } parsed.pathname = path.endsWith("/") ? path : `${path}/`; parsed.search = ""; parsed.hash = ""; return parsed.toString(); } async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } } async function requestJson(params: { method: string; path: string; baseUrl: string; body?: unknown; headers?: Record; }): Promise<{ api_base_url: string; status: number; data: unknown; payload: unknown }> { const apiBaseUrl = normalizeBaseUrl(params.baseUrl); const normalizedPath = params.path.startsWith("/") ? params.path : `/${params.path}`; const url = `${apiBaseUrl}${normalizedPath}`; const headers: Record = { ...(params.headers ?? {}) }; let body: string | undefined; if (params.body !== undefined) { body = JSON.stringify(params.body); headers["Content-Type"] = "application/json"; } let response: Response; try { response = await fetchWithTimeout( url, { method: params.method, headers, body, }, DEFAULT_TIMEOUT_MS, ); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new GrixAuthError(`network error: ${message}`); } const status = response.status; const raw = await response.text(); let payload: unknown; try { payload = JSON.parse(raw); } catch (err) { const excerpt = raw.slice(0, 256); throw new GrixAuthError( `invalid json response: ${excerpt}`, status, -1, err instanceof Error ? err.message : String(err), ); } const obj = (payload ?? {}) as JsonObject; const code = Number(obj.code ?? -1); const msg = String(obj.msg ?? "").trim() || "unknown error"; if (status >= 400 || code !== 0) { throw new GrixAuthError(msg, status, code, payload); } return { api_base_url: apiBaseUrl, status, data: obj.data, payload, }; } async function maybeWriteCaptchaImage(b64s: string): Promise { const text = (b64s ?? "").trim(); if (!text.startsWith("data:image/")) { return ""; } const marker = ";base64,"; const idx = text.indexOf(marker); if (idx < 0) { return ""; } const encoded = text.slice(idx + marker.length); let content: Buffer; try { content = Buffer.from(encoded, "base64"); } catch { return ""; } const path = join(tmpdir(), `grix-captcha-${randomUUID()}.png`); try { await writeFile(path, content); } catch { return ""; } return path; } function buildAuthResult(action: string, result: { api_base_url: string; data: unknown }, baseUrl: string) { const data = (result.data ?? {}) as JsonObject; const user = (data.user ?? {}) as JsonObject; return { ok: true, action, api_base_url: result.api_base_url, access_token: String(data.access_token ?? ""), refresh_token: String(data.refresh_token ?? ""), expires_in: Number(data.expires_in ?? 0), user_id: String(user.id ?? ""), portal_url: derivePortalUrl(baseUrl), data, }; } function buildAgentResult(action: string, result: { api_base_url: string; data: unknown }) { const data = (result.data ?? {}) as JsonObject; const agent_id = String(data.id ?? "").trim(); const api_endpoint = String(data.api_endpoint ?? "").trim(); const api_key = String(data.api_key ?? "").trim(); const agent_name = String(data.agent_name ?? "").trim(); const bind_local = { agent_name, agent_id, api_endpoint, api_key, }; const task = [ "bind-local", `agent_name=${agent_name}`, `agent_id=${agent_id}`, `api_endpoint=${api_endpoint}`, `api_key=${api_key}`, "do_not_create_remote_agent=true", ].join("\n"); return { ok: true, action, api_base_url: result.api_base_url, agent_id, agent_name, provider_type: Number(data.provider_type ?? 0), api_endpoint, api_key, api_key_hint: String(data.api_key_hint ?? ""), session_id: String(data.session_id ?? ""), handoff: { target_tool: "grix_admin", task, bind_local, }, data, }; } function defaultDeviceId(platform: string): string { const normalized = (platform ?? "").trim() || "web"; return `${normalized}_${randomUUID()}`; } async function loginWithCredentials(params: { baseUrl: string; account: string; password: string; deviceId: string; platform: string; }) { const result = await requestJson({ method: "POST", path: "/auth/login", baseUrl: params.baseUrl, body: { account: params.account, password: params.password, device_id: params.deviceId, platform: params.platform, }, }); return buildAuthResult("login", result, params.baseUrl); } async function listAgents(baseUrl: string, accessToken: string): Promise { const result = await requestJson({ method: "GET", path: "/agents/list", baseUrl, headers: { Authorization: `Bearer ${accessToken.trim()}`, }, }); const data = (result.data ?? {}) as JsonObject; const items = data.list; return Array.isArray(items) ? (items as JsonObject[]) : []; } function findExistingApiAgent(agents: JsonObject[], agentName: string): JsonObject | null { const normalizedName = (agentName ?? "").trim(); if (!normalizedName) { return null; } for (const item of agents) { if (String(item.agent_name ?? "").trim() !== normalizedName) { continue; } if (Number(item.provider_type ?? 0) !== 3) { continue; } if (Number(item.status ?? 0) === 3) { continue; } return item; } return null; } async function rotateApiAgentKey(baseUrl: string, accessToken: string, agentId: string) { const result = await requestJson({ method: "POST", path: `/agents/${agentId.trim()}/api/key/rotate`, baseUrl, body: {}, headers: { Authorization: `Bearer ${accessToken.trim()}`, }, }); return buildAgentResult("rotate-api-agent-key", result); } async function createApiAgent(params: { baseUrl: string; accessToken: string; agentName: string; avatarUrl: string; }) { const body: JsonObject = { agent_name: params.agentName.trim(), provider_type: 3, is_main: true, }; const avatar = (params.avatarUrl ?? "").trim(); if (avatar) { body.avatar_url = avatar; } const result = await requestJson({ method: "POST", path: "/agents/create", baseUrl: params.baseUrl, body, headers: { Authorization: `Bearer ${params.accessToken.trim()}`, }, }); return buildAgentResult("create-api-agent", result); } async function createOrReuseApiAgent(params: { baseUrl: string; accessToken: string; agentName: string; avatarUrl: string; preferExisting: boolean; rotateOnReuse: boolean; }): Promise { if (params.preferExisting) { const agents = await listAgents(params.baseUrl, params.accessToken); const existing = findExistingApiAgent(agents, params.agentName); if (existing) { if (!params.rotateOnReuse) { throw new GrixAuthError( "existing provider_type=3 agent found but rotate-on-reuse is disabled; cannot obtain api_key safely", 0, -1, { existing_agent: existing }, ); } const rotated = (await rotateApiAgentKey( params.baseUrl, params.accessToken, String(existing.id ?? "").trim(), )) as JsonObject; rotated.source = "reused_existing_agent_with_rotated_key"; rotated.existing_agent = existing; return rotated; } } const created = (await createApiAgent({ baseUrl: params.baseUrl, accessToken: params.accessToken, agentName: params.agentName, avatarUrl: params.avatarUrl, })) as JsonObject; created.source = "created_new_agent"; return created; } function takeOption(argv: string[], name: string): string | undefined { const prefix = `--${name}=`; for (let index = 0; index < argv.length; index += 1) { const token = argv[index] ?? ""; if (token === `--${name}`) { const value = argv[index + 1]; argv.splice(index, 2); return value; } if (token.startsWith(prefix)) { argv.splice(index, 1); return token.slice(prefix.length); } } return undefined; } function takeBool(argv: string[], name: string): boolean { const token = `--${name}`; const index = argv.indexOf(token); if (index >= 0) { argv.splice(index, 1); return true; } return false; } function requireOption(argv: string[], name: string): string { const value = takeOption(argv, name); if (value === undefined || String(value).trim() === "") { throw new GrixAuthError(`missing required option: --${name}`); } return String(value); } async function runAction(action: string, baseUrl: string, argv: string[]): Promise { if (action === "fetch-captcha") { const result = await requestJson({ method: "GET", path: "/auth/captcha", baseUrl }); const data = (result.data ?? {}) as JsonObject; const b64s = String(data.b64s ?? ""); const imagePath = await maybeWriteCaptchaImage(b64s); const payload: JsonObject = { ok: true, action: "fetch-captcha", api_base_url: result.api_base_url, captcha_id: String(data.captcha_id ?? ""), b64s, }; if (imagePath) { payload.captcha_image_path = imagePath; } printJson(payload); return; } if (action === "send-email-code") { const email = requireOption(argv, "email").trim(); const scene = requireOption(argv, "scene").trim(); if (!["register", "reset", "change_password"].includes(scene)) { throw new GrixAuthError(`invalid scene: ${scene}`); } const payload: JsonObject = { email, scene }; const captchaId = String(takeOption(argv, "captcha-id") ?? "").trim(); const captchaValue = String(takeOption(argv, "captcha-value") ?? "").trim(); if ((scene === "reset" || scene === "change_password") && (!captchaId || !captchaValue)) { throw new GrixAuthError("captcha-id and captcha-value are required for reset/change_password"); } if (captchaId) payload.captcha_id = captchaId; if (captchaValue) payload.captcha_value = captchaValue; const result = await requestJson({ method: "POST", path: "/auth/send-code", baseUrl, body: payload, }); printJson({ ok: true, action: "send-email-code", api_base_url: result.api_base_url, data: result.data, }); return; } if (action === "register") { const email = requireOption(argv, "email").trim(); const password = requireOption(argv, "password").trim(); const emailCode = requireOption(argv, "email-code").trim(); const platform = String(takeOption(argv, "platform") ?? "").trim() || "web"; const deviceId = String(takeOption(argv, "device-id") ?? "").trim() || defaultDeviceId(platform); const result = await requestJson({ method: "POST", path: "/auth/register", baseUrl, body: { email, password, email_code: emailCode, device_id: deviceId, platform, }, }); printJson(buildAuthResult("register", result, baseUrl)); return; } if (action === "login") { const account = String(takeOption(argv, "email") ?? takeOption(argv, "account") ?? "").trim(); if (!account) { throw new GrixAuthError("either --email or --account is required"); } const password = requireOption(argv, "password").trim(); const platform = String(takeOption(argv, "platform") ?? "").trim() || "web"; const deviceId = String(takeOption(argv, "device-id") ?? "").trim() || defaultDeviceId(platform); const result = await loginWithCredentials({ baseUrl, account, password, deviceId, platform, }); printJson(result); return; } if (action === "create-api-agent") { const accessToken = requireOption(argv, "access-token").trim(); const agentName = requireOption(argv, "agent-name").trim(); const avatarUrl = String(takeOption(argv, "avatar-url") ?? "").trim(); const noReuseExistingAgent = takeBool(argv, "no-reuse-existing-agent"); const noRotateKeyOnReuse = takeBool(argv, "no-rotate-key-on-reuse"); const result = await createOrReuseApiAgent({ baseUrl, accessToken, agentName, avatarUrl, preferExisting: !noReuseExistingAgent, rotateOnReuse: !noRotateKeyOnReuse, }); printJson(result); return; } throw new GrixAuthError(`unsupported action: ${action}`); } async function main(): Promise { const argv = process.argv.slice(2); if (argv.includes("--help") || argv.includes("-h")) { process.stdout.write(`${usageText()}\n`); return 0; } const baseUrl = String(takeOption(argv, "base-url") ?? DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL; const action = String(argv.shift() ?? "").trim(); if (!action) { process.stderr.write(`${usageText()}\n`); return 1; } try { await runAction(action, baseUrl, argv); return 0; } catch (err) { if (err instanceof GrixAuthError) { printJson({ ok: false, action, status: err.status, code: err.code, error: err.message, payload: err.payload, }); return 1; } const message = err instanceof Error ? err.message : String(err); printJson({ ok: false, action, status: 0, code: -1, error: message, }); return 1; } } void main().then((code) => { process.exitCode = code; });