2026-09-07 21:10:19 +00:00
|
|
|
import { KomodoApiError } from "./errors.js";
|
|
|
|
|
|
2026-09-07 20:36:39 +00:00
|
|
|
export class KomodoClient {
|
|
|
|
|
private baseUrl: string;
|
|
|
|
|
private username: string;
|
|
|
|
|
private password: string;
|
2026-09-07 21:10:19 +00:00
|
|
|
private apiKey: string | null = null;
|
|
|
|
|
private apiSecret: string | null = null;
|
2026-09-07 20:36:39 +00:00
|
|
|
private token: string | null = null;
|
2026-09-07 21:10:19 +00:00
|
|
|
private useApiKey: boolean;
|
2026-09-07 20:36:39 +00:00
|
|
|
|
|
|
|
|
constructor() {
|
2026-09-07 21:10:19 +00:00
|
|
|
this.baseUrl =
|
|
|
|
|
process.env.KOMODO_BASE_URL || process.env.KOMODO_URL || "http://10.10.2.114:9120";
|
2026-09-08 11:01:51 +00:00
|
|
|
this.username = process.env.KOMODO_USERNAME || process.env.KOMODO_INIT_ADMIN_USERNAME || "";
|
|
|
|
|
this.password = process.env.KOMODO_PASSWORD || process.env.KOMODO_INIT_ADMIN_PASSWORD || "";
|
2026-09-07 21:10:19 +00:00
|
|
|
this.apiKey = process.env.KOMODO_API_KEY || null;
|
|
|
|
|
this.apiSecret = process.env.KOMODO_API_SECRET || null;
|
|
|
|
|
this.useApiKey = !!(this.apiKey && this.apiSecret);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the appropriate auth headers based on the configured auth method.
|
|
|
|
|
* API key auth (X-Api-Key / X-Api-Secret) is preferred when credentials are set;
|
|
|
|
|
* otherwise falls back to JWT Bearer token.
|
|
|
|
|
*/
|
|
|
|
|
private async getAuthHeaders(): Promise<Record<string, string>> {
|
|
|
|
|
if (this.useApiKey) {
|
|
|
|
|
return {
|
|
|
|
|
"X-Api-Key": this.apiKey!,
|
|
|
|
|
"X-Api-Secret": this.apiSecret!,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
await this.ensureAuth();
|
|
|
|
|
return {
|
|
|
|
|
Authorization: `Bearer ${this.token}`,
|
|
|
|
|
};
|
2026-09-07 20:36:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async ensureAuth(): Promise<void> {
|
2026-09-07 21:10:19 +00:00
|
|
|
if (this.useApiKey) return; // No login needed for API key auth
|
2026-09-07 20:36:39 +00:00
|
|
|
if (this.token) return;
|
|
|
|
|
await this.login();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async login(): Promise<void> {
|
|
|
|
|
const res = await fetch(`${this.baseUrl}/auth/login`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-09-07 17:41:21 -04:00
|
|
|
// Komodo Core v2.3.3 login shape (verified live): {type, params}
|
2026-09-07 20:36:39 +00:00
|
|
|
body: JSON.stringify({
|
2026-09-07 17:41:21 -04:00
|
|
|
type: "LoginLocalUser",
|
|
|
|
|
params: {
|
|
|
|
|
username: this.username,
|
|
|
|
|
password: this.password,
|
|
|
|
|
},
|
2026-09-07 20:36:39 +00:00
|
|
|
}),
|
2026-09-07 21:10:19 +00:00
|
|
|
signal: AbortSignal.timeout(30000),
|
2026-09-07 20:36:39 +00:00
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const text = await res.text().catch(() => "");
|
|
|
|
|
throw new Error(`Komodo login failed (${res.status}): ${text}`);
|
|
|
|
|
}
|
2026-09-07 17:41:21 -04:00
|
|
|
// Response shape: {type, data: {jwt}} — NOT raw text
|
|
|
|
|
const body = (await res.json()) as { data?: { jwt?: string } };
|
|
|
|
|
const jwt = body?.data?.jwt;
|
|
|
|
|
if (!jwt) {
|
|
|
|
|
throw new Error(`Komodo login succeeded but no data.jwt in response`);
|
|
|
|
|
}
|
|
|
|
|
this.token = jwt;
|
2026-09-07 20:36:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async rpc(
|
|
|
|
|
route: "read" | "write" | "execute",
|
|
|
|
|
requestName: string,
|
|
|
|
|
params: Record<string, unknown> = {},
|
|
|
|
|
): Promise<unknown> {
|
|
|
|
|
await this.ensureAuth();
|
|
|
|
|
|
|
|
|
|
const url = `${this.baseUrl}/${route}/${requestName}`;
|
|
|
|
|
|
2026-09-07 21:10:19 +00:00
|
|
|
const attempt = async (
|
|
|
|
|
authHeaders: Record<string, string>,
|
|
|
|
|
): Promise<Response> => {
|
2026-09-07 20:36:39 +00:00
|
|
|
return fetch(url, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
2026-09-07 21:10:19 +00:00
|
|
|
...authHeaders,
|
2026-09-07 20:36:39 +00:00
|
|
|
},
|
|
|
|
|
body: JSON.stringify(params),
|
2026-09-07 21:10:19 +00:00
|
|
|
signal: AbortSignal.timeout(30000),
|
2026-09-07 20:36:39 +00:00
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-07 21:10:19 +00:00
|
|
|
const authHeaders = await this.getAuthHeaders();
|
|
|
|
|
let res = await attempt(authHeaders);
|
2026-09-07 20:36:39 +00:00
|
|
|
|
2026-09-07 21:10:19 +00:00
|
|
|
// Auto-refresh on 401 (JWT only)
|
|
|
|
|
if (res.status === 401 && !this.useApiKey) {
|
2026-09-07 20:36:39 +00:00
|
|
|
this.token = null;
|
|
|
|
|
await this.login();
|
2026-09-07 21:10:19 +00:00
|
|
|
res = await attempt({ Authorization: `Bearer ${this.token}` });
|
2026-09-07 20:36:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const text = await res.text().catch(() => "");
|
2026-09-07 21:10:19 +00:00
|
|
|
let message = `Komodo RPC ${route}/${requestName} failed (${res.status}): ${text}`;
|
|
|
|
|
try {
|
|
|
|
|
const body = JSON.parse(text) as {
|
|
|
|
|
error?: string;
|
|
|
|
|
trace?: unknown[];
|
|
|
|
|
};
|
|
|
|
|
if (body.error) {
|
|
|
|
|
message = `Komodo RPC ${route}/${requestName} failed (${res.status}): ${body.error}`;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Not JSON, use raw text
|
|
|
|
|
}
|
|
|
|
|
throw new KomodoApiError(message, res.status, route, requestName);
|
2026-09-07 20:36:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const text = await res.text();
|
|
|
|
|
if (!text) return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(text);
|
|
|
|
|
} catch {
|
|
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|