Merge pull request #170 from multica-ai/feat/desktop-api-client

feat(core): add auth-store and shared API client for desktop auth
This commit is contained in:
LinYushen 2026-02-13 19:12:00 +08:00 committed by GitHub
commit 77aff992c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 150 additions and 41 deletions

View file

@ -0,0 +1,72 @@
import { useAuthStore } from '../stores/auth'
// Backend API host — change this when switching environments
const API_HOST = 'https://api-dev.copilothub.ai'
/**
* Fetch request wrapper for desktop app.
* Attaches sid, device-id, and os-type headers automatically.
*/
export async function request<T = unknown>(url: string, options: RequestInit = {}): Promise<T> {
const sid = useAuthStore.getState().sid
const deviceIdHeader = await window.electronAPI.auth.getDeviceIdHeader()
const config: RequestInit = {
...options,
headers: {
'Content-Type': 'application/json',
'os-type': '3',
...(deviceIdHeader && { 'device-id': deviceIdHeader }),
...(sid && { sid }),
...options.headers,
},
}
const response = await fetch(`${API_HOST}${url}`, config)
let data: T
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
data = await response.json()
} else {
const text = await response.text()
data = { message: text || response.statusText } as T
}
if (!response.ok) {
console.error('API Error:', {
status: response.status,
url,
data,
})
throw new Error(
(data as { errMsg?: string; message?: string })?.errMsg ||
(data as { message?: string })?.message ||
`Request failed with status ${response.status}`,
)
}
return data
}
// GET request
export function get<T = unknown>(url: string, params?: Record<string, string | number | boolean>) {
const filteredParams = params
? Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== undefined && v !== null),
)
: undefined
const queryString =
filteredParams && Object.keys(filteredParams).length > 0
? `?${new URLSearchParams(filteredParams as Record<string, string>).toString()}`
: ''
return request<T>(url + queryString, { method: 'GET' })
}
// POST request
export function post<T = unknown>(url: string, data?: unknown) {
return request<T>(url, {
method: 'POST',
body: JSON.stringify(data),
})
}

View file

@ -1,32 +1,15 @@
/**
* Financial Datasets API client.
*
* Base URL: https://api.financialdatasets.ai
* Auth: X-API-KEY header
* Proxied through api-dev.copilothub.ai with auth headers (sid / device-id / os-type).
* All endpoints use GET with query parameters.
*/
import { credentialManager } from "../../../credentials.js";
import { API_BASE_URL, getAuthHeaders } from "../../../../hub/api-client.js";
const BASE_URL = "https://api.financialdatasets.ai";
const PATH_PREFIX = "/api/v1/financial";
const TIMEOUT_MS = 30_000;
function getApiKey(): string {
// 1. credentials.json5 → tools.data.apiKey (preferred)
const toolConfig = credentialManager.getToolConfig("data");
if (toolConfig?.apiKey) return toolConfig.apiKey;
// 2. Fallback: env var (skills.env.json5 or process.env)
const envKey = credentialManager.getEnv("FINANCIAL_DATASETS_API_KEY");
if (envKey) return envKey;
throw new Error(
"Financial Datasets API key not configured. " +
'Set it in ~/.super-multica/credentials.json5 under tools.data.apiKey, ' +
"or set FINANCIAL_DATASETS_API_KEY in ~/.super-multica/skills.env.json5.",
);
}
/**
* Fetch data from the Financial Datasets API.
*
@ -39,9 +22,9 @@ export async function financeFetch<T = Record<string, unknown>>(
params: Record<string, string | string[] | number | boolean | undefined>,
signal?: AbortSignal,
): Promise<{ data: T; url: string }> {
const apiKey = getApiKey();
const authHeaders = getAuthHeaders("to use financial data tools");
const url = new URL(path, BASE_URL);
const url = new URL(PATH_PREFIX + path, API_BASE_URL);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
@ -61,8 +44,8 @@ export async function financeFetch<T = Record<string, unknown>>(
const res = await fetch(url.toString(), {
method: "GET",
headers: {
"X-API-KEY": apiKey,
Accept: "application/json",
...authHeaders,
},
signal: combinedSignal,
});

View file

@ -1,9 +1,7 @@
import { createHmac } from "node:crypto";
import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { v7 as uuidv7 } from "uuid";
import { getHubId } from "../../../hub/hub-identity.js";
import { API_BASE_URL, getAuthHeaders } from "../../../hub/api-client.js";
import {
DEFAULT_CACHE_TTL_MINUTES,
DEFAULT_TIMEOUT_SECONDS,
@ -16,8 +14,7 @@ import {
import type { CacheEntry } from "./cache.js";
import { jsonResult, readStringParam } from "./param-helpers.js";
const DEVV_SEARCH_ENDPOINT = "https://api-dev.copilothub.ai/web-search";
const SIGNING_KEY = "019c2d34-e8b2-75da-ace5-99f887c090c9";
const WEB_SEARCH_PATH = "/api/v1/web-search";
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
@ -51,15 +48,6 @@ export type WebSearchResult = {
}>;
};
function buildReqId(): string {
const hubId = getHubId();
const nonce = uuidv7();
const timestamp = Math.floor(Date.now() / 1000);
const message = `${hubId}.${nonce}.${timestamp}`;
const signature = createHmac("sha256", SIGNING_KEY).update(message).digest("hex");
return `${signature}.${hubId}.${nonce}.${timestamp}`;
}
async function runDevvSearch(params: {
query: string;
timeoutSeconds: number;
@ -71,16 +59,21 @@ async function runDevvSearch(params: {
snippet: string;
}>;
}> {
const res = await fetch(DEVV_SEARCH_ENDPOINT, {
const authHeaders = getAuthHeaders("to use web search");
const res = await fetch(`${API_BASE_URL}${WEB_SEARCH_PATH}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: params.query, reqId: buildReqId() }),
headers: {
"Content-Type": "application/json",
...authHeaders,
},
body: JSON.stringify({ q: params.query }),
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
});
if (!res.ok) {
const detail = await readResponseText(res);
throw new Error(`Devv Search API error (${res.status}): ${detail || res.statusText}`);
throw new Error(`Web Search API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as DevvSearchResponse;

View file

@ -0,0 +1,25 @@
import { getLocalAuth } from "./auth-store.js";
export const API_BASE_URL = "https://api-dev.copilothub.ai";
/**
* Return auth headers for the proxy API.
* Throws if the user is not logged in.
*
* @param context - Optional feature name appended to the error message
* (e.g. "to use web search").
*/
export function getAuthHeaders(context?: string): Record<string, string> {
const auth = getLocalAuth();
if (!auth) {
const suffix = context ? ` ${context}` : "";
throw new Error(
`Not logged in. Please sign in via the Desktop app${suffix}.`,
);
}
return {
sid: auth.sid,
"device-id": auth.deviceId,
"os-type": "3",
};
}

View file

@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DATA_DIR } from "@multica/utils";
const AUTH_FILE_PATH = join(DATA_DIR, "auth.json");
export type LocalAuthData = { sid: string; deviceId: string };
/**
* Read sid and deviceId from ~/.super-multica/auth.json.
* Returns null if the file is missing, unreadable, or incomplete.
*/
export function getLocalAuth(): LocalAuthData | null {
try {
const raw = readFileSync(AUTH_FILE_PATH, "utf8").trim();
if (!raw) return null;
const data = JSON.parse(raw);
if (
typeof data !== "object" ||
data === null ||
typeof data.sid !== "string" ||
!data.sid ||
typeof data.deviceId !== "string" ||
!data.deviceId
) {
return null;
}
return { sid: data.sid, deviceId: data.deviceId };
} catch {
return null;
}
}

View file

@ -1,4 +1,6 @@
export { Hub } from "./hub.js";
export type { MessageSource, InboundMessageEvent } from "./hub.js";
export { getHubId } from "./hub-identity.js";
export { getLocalAuth, type LocalAuthData } from "./auth-store.js";
export { API_BASE_URL, getAuthHeaders } from "./api-client.js";
export type { HubOptions } from "./types.js";