Files
vantage/web/lib/api.ts
T
mrhid6 dd306e4757
Server Deploy / deploy (push) Successful in 2m14s
feat(license): the paste form is self-hosted only
A cloud instance's licence is issued in HQ and written into the control
plane by admin/internal/inject, straight to the database. The customer has
nothing to paste, so /settings/license offered them a form that could only
ever fail — and on an expired cloud instance, failed at the exact moment
they were looking for a way out.

GET /license now reports `deployment`, and the page swaps the paste form
for a short panel saying where the licence comes from, with a link to the
portal when HQ_URL is set. That is the same treatment hq-managed members
already get in the members table: read-only here, and a pointer to where
it is actually managed.

POST /license refuses with 409 cloud_managed on a cloud deployment. Hiding
a form is a courtesy; this codebase's rule is that the API is the boundary,
and the endpoint was reachable regardless of what the page rendered. It
cannot break injection, which never goes through HTTP at all.

Verified: server builds and vets clean, web builds clean.
2026-07-26 20:42:53 +01:00

824 lines
22 KiB
TypeScript

export type ServerStatus = "pending" | "active" | "offline";
export type KeySource = "uploaded" | "generated";
export interface PackageUpdate {
name: string;
current_version?: string;
new_version: string;
}
export interface Inventory {
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
memory: { total_bytes: number; used_bytes: number };
swap_total_bytes: number;
swap_used_bytes: number;
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
kernel?: string;
metrics_at?: string;
static_at?: string;
}
export interface Server {
id: string;
server_id: string;
hostname: string;
ip_address: string;
os_info: string;
status: ServerStatus;
agent_version?: string;
last_seen: string;
created_at: string;
available_updates?: PackageUpdate[];
updates_checked_at?: string;
console_protocols?: string[];
inventory?: Inventory;
}
export type MonitorType = "http" | "tcp" | "icmp" | "tls";
export type MonitorStatus = "up" | "down" | "pending";
export interface MonitorTarget {
url?: string;
host?: string;
port?: number;
method?: string;
expected_status?: number;
keyword?: string;
tls_warn_days?: number;
insecure?: boolean;
}
export interface MonitorState {
status: MonitorStatus;
last_check_at?: string;
latency_ms: number;
message?: string;
cert_expiry_at?: string;
fails: number;
}
export interface Monitor {
monitor_id: string;
name: string;
type: MonitorType;
target: MonitorTarget;
interval_sec: number;
runner: string; // "server" or a server_id
retries: number;
enabled: boolean;
channel_ids?: string[];
state: MonitorState;
created_at: string;
}
export interface MonitorInput {
name: string;
type: MonitorType;
target: MonitorTarget;
interval_sec: number;
runner: string;
retries: number;
enabled: boolean;
channel_ids?: string[];
}
export interface Incident {
incident_id: string;
monitor_id: string;
started_at: string;
resolved_at?: string;
cause?: string;
}
export interface Rollup {
monitor_id: string;
period_start: string;
checks: number;
up_count: number;
sum_latency: number;
}
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
export interface NotificationChannel {
channel_id: string;
name: string;
type: ChannelType;
config: Record<string, string>;
enabled: boolean;
created_at: string;
}
export interface ChannelInput {
name: string;
type: ChannelType;
config: Record<string, string>;
enabled: boolean;
}
export interface ConsoleConnectRequest {
server_id: string;
protocol: string;
key_id?: string;
rdp_username?: string;
rdp_password?: string;
ssh_username?: string;
}
export interface ConsoleConnectResponse {
session_id: string;
token: string;
ws_path: string;
}
export interface Key {
id: string;
key_id: string;
label: string;
public_key: string;
fingerprint: string;
source: KeySource;
generated_by_server_id?: string;
has_private_key: boolean;
has_passphrase?: boolean;
created_at: string;
assigned_count?: number;
}
export interface Assignment {
id: string;
key_id: string;
server_id: string;
assigned_at: string;
revoked_at: string | null;
}
export interface AuditEvent {
id: string;
event_type: string;
actor: string;
server_id?: string;
key_id?: string;
details: string;
created_at: string;
}
export interface AlertSettings {
enabled: boolean;
webhook_url: string;
offline_threshold_minutes: number;
}
export interface EmailSettings {
enabled: boolean;
smtp_host: string;
smtp_port: number;
username: string;
password: string;
from_addr: string;
to_addrs: string[];
use_tls: boolean;
}
export interface SecretsSettings {
read_token_set: boolean;
rotated_at?: string;
}
export interface Settings {
alerts: AlertSettings;
email: EmailSettings;
secrets: SecretsSettings;
workflow_log_retention_days?: number | null;
}
export interface SecretGroupSummary {
group: string;
key_count: number;
updated_at: string;
}
export interface Secret {
group: string;
key: string;
updated_at: string;
}
export interface NewServerResponse {
server_id: string;
pre_reg_token: string;
install_command: string;
install_command_ps: string;
}
export interface GenerateKeyOptions {
label: string;
key_type: "ed25519" | "rsa" | "ecdsa";
key_size?: number;
passphrase?: string;
comment?: string;
}
export interface KeyWithAssignments extends Key {
assignments: (Assignment & { server: Server })[];
}
export interface ServerWithKeys extends Server {
keys: (Assignment & { key: Key })[];
}
export interface InputParam {
name: string;
default: string;
description: string;
}
export interface WorkflowStep {
step_id: string;
name: string;
description: string;
interpreter: "bash" | "powershell";
script: string;
declared_outputs: string[];
declared_inputs: InputParam[];
secret_refs: string[];
source?: "user" | "default";
slug?: string;
}
export interface WorkflowStepRef {
step_id?: string;
inline?: WorkflowStep;
order: number;
on_failure: "stop" | "continue" | "retry";
max_retries: number;
overrides?: { script?: string; secret_refs?: string[] };
inputs?: Record<string, string>;
}
export interface Workflow {
workflow_id: string;
name: string;
target_server_ids: string[];
steps: WorkflowStepRef[];
}
export interface StepRun {
order: number;
name: string;
status: string;
attempts: number;
exit_code: number;
log_offset: number;
output_env: Record<string, string>;
started_at?: string;
finished_at?: string;
}
export interface ServerRun {
server_id: string;
hostname: string;
status: string;
run_env: Record<string, string>;
steps: StepRun[];
started_at?: string;
finished_at?: string;
}
export interface WorkflowRun {
run_id: string;
workflow_id: string;
name: string;
status: string;
triggered_by: string;
started_at: string;
finished_at?: string;
server_runs: ServerRun[];
}
export type Role = "owner" | "admin" | "member";
/** The session as returned by GET /auth/me mirrors auth.Session on the server. */
export interface SessionUser {
user_id: string;
instance_id: string;
role: Role;
email: string;
name: string;
}
export interface Instance {
instance_id: string;
name: string;
slug: string;
created_at: string;
}
export interface MeResponse {
user: SessionUser;
instance: Instance | null;
}
export interface BootstrapStatus {
needs_setup: boolean;
instance_name?: string;
}
export interface BootstrapResponse {
instance: Instance;
slug: string;
instance_id: string;
}
export interface InstanceUser {
user_id: string;
instance_id: string;
email: string;
role: Role;
// "hq" means the row was projected from a Vantage HQ account. Its role,
// password and existence belong to HQ; this instance refuses to change them.
auth_source: "local" | "oidc" | "hq";
hq_user_id?: string;
created_at: string;
last_login?: string;
}
export interface OrgUserInput {
email: string;
password: string;
role: Role;
}
export interface OrgOIDCConfig {
issuer?: string;
client_id?: string;
enabled: boolean;
client_secret_set: boolean;
updated_at?: string;
}
export interface OrgOIDCInput {
issuer: string;
client_id: string;
client_secret: string;
enabled: boolean;
}
class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...options?.headers,
},
...options,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
let message = text || res.statusText || `HTTP ${res.status}`;
try {
const body = JSON.parse(text);
if (body?.error) message = body.error;
} catch {}
throw new ApiError(res.status, message);
}
if (res.status === 204) {
return undefined as T;
}
return res.json();
}
async function authRequest<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...options?.headers },
...options,
});
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {}
throw new ApiError(res.status, message);
}
if (res.status === 204) {
return undefined as T;
}
return res.json();
}
export const auth = {
bootstrapStatus(): Promise<BootstrapStatus> {
return authRequest<BootstrapStatus>("/auth/bootstrap-status");
},
bootstrap(input: { instance_name: string; email: string; password: string }): Promise<BootstrapResponse> {
return authRequest<BootstrapResponse>("/auth/bootstrap", {
method: "POST",
body: JSON.stringify(input),
});
},
login(email: string, password: string): Promise<{ ok: boolean }> {
return authRequest<{ ok: boolean }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
},
logout(): Promise<void> {
return authRequest<void>("/auth/logout", { method: "POST" });
},
me(): Promise<MeResponse> {
return authRequest<MeResponse>("/auth/me");
},
/** The URL an admin must register with their OIDC provider. */
oidcRedirectUrl(): string {
if (typeof window === "undefined") return "/auth/oidc/callback";
return `${window.location.origin}/auth/oidc/callback`;
},
};
export const api = {
listInstanceUsers(): Promise<InstanceUser[]> {
return request<InstanceUser[]>("/instance/users");
},
createInstanceUser(input: OrgUserInput): Promise<InstanceUser> {
return request<InstanceUser>("/instance/users", { method: "POST", body: JSON.stringify(input) });
},
updateInstanceUserRole(userId: string, role: Role): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/instance/users/${userId}/role`, {
method: "PUT",
body: JSON.stringify({ role }),
});
},
deleteInstanceUser(userId: string): Promise<void> {
return request<void>(`/instance/users/${userId}`, { method: "DELETE" });
},
getInstanceOIDC(): Promise<OrgOIDCConfig> {
return request<OrgOIDCConfig>("/instance/oidc");
},
saveInstanceOIDC(input: OrgOIDCInput): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/instance/oidc", { method: "PUT", body: JSON.stringify(input) });
},
listServers(): Promise<Server[]> {
return request<Server[]>("/servers");
},
getServer(serverId: string): Promise<ServerWithKeys> {
return request<ServerWithKeys>(`/servers/${serverId}`);
},
createServer(): Promise<NewServerResponse> {
return request<NewServerResponse>("/servers/new", { method: "POST" });
},
deleteServer(serverId: string): Promise<void> {
return request<void>(`/servers/${serverId}`, { method: "DELETE" });
},
generateKeyForServer(serverId: string, opts: GenerateKeyOptions): Promise<{ command_id: string }> {
return request<{ command_id: string }>(`/servers/${serverId}/generate-key`, {
method: "POST",
body: JSON.stringify(opts),
});
},
getUpdateCommand(osInfo?: string): string {
if (osInfo && osInfo.toLowerCase().includes("windows")) {
return `irm "${window.location.origin}/update.ps1" | iex`;
}
return `curl -fsSL "${window.location.origin}/update" | bash`;
},
listMonitors(): Promise<Monitor[]> {
return request<Monitor[]>("/monitors");
},
getMonitor(monitorId: string): Promise<Monitor> {
return request<Monitor>(`/monitors/${monitorId}`);
},
createMonitor(input: MonitorInput): Promise<Monitor> {
return request<Monitor>("/monitors", { method: "POST", body: JSON.stringify(input) });
},
updateMonitor(monitorId: string, input: Partial<MonitorInput>): Promise<void> {
return request<void>(`/monitors/${monitorId}`, { method: "PUT", body: JSON.stringify(input) });
},
deleteMonitor(monitorId: string): Promise<void> {
return request<void>(`/monitors/${monitorId}`, { method: "DELETE" });
},
getMonitorIncidents(monitorId: string): Promise<Incident[]> {
return request<Incident[]>(`/monitors/${monitorId}/incidents`);
},
getMonitorUptime(monitorId: string): Promise<Rollup[]> {
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
},
listChannels(): Promise<NotificationChannel[]> {
return request<NotificationChannel[]>("/channels");
},
createChannel(input: ChannelInput): Promise<NotificationChannel> {
return request<NotificationChannel>("/channels", { method: "POST", body: JSON.stringify(input) });
},
updateChannel(channelId: string, input: Partial<ChannelInput>): Promise<void> {
return request<void>(`/channels/${channelId}`, { method: "PUT", body: JSON.stringify(input) });
},
deleteChannel(channelId: string): Promise<void> {
return request<void>(`/channels/${channelId}`, { method: "DELETE" });
},
testChannel(channelId: string): Promise<{ status: string }> {
return request<{ status: string }>(`/channels/${channelId}/test`, { method: "POST" });
},
getLatestAgentVersion(): Promise<{ version: string }> {
return request<{ version: string }>("/agent/latest-version");
},
updateAgent(serverId: string): Promise<{ message: string; version: string }> {
return request<{ message: string; version: string }>(`/servers/${serverId}/update-agent`, {
method: "POST",
});
},
applyUpdates(serverId: string): Promise<{ message: string }> {
return request<{ message: string }>(`/servers/${serverId}/apply-updates`, {
method: "POST",
});
},
listAuditEvents(limit?: number): Promise<AuditEvent[]> {
const qs = limit ? `?limit=${limit}` : "";
return request<AuditEvent[]>(`/audit${qs}`);
},
getSettings(): Promise<Settings> {
return request<Settings>("/settings");
},
saveSettings(settings: { alerts: AlertSettings; email: EmailSettings; workflow_log_retention_days?: number | null }): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
body: JSON.stringify(settings),
});
},
rotateSecretsToken(): Promise<{ token: string }> {
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
},
listSecretGroups(): Promise<SecretGroupSummary[]> {
return request<SecretGroupSummary[]>("/secrets");
},
createSecretGroup(group: string, values: Record<string, string>): Promise<{ group: string }> {
return request<{ group: string }>("/secrets", {
method: "POST",
body: JSON.stringify({ group, values }),
});
},
getSecretGroup(group: string): Promise<{ group: string; secrets: Secret[] }> {
return request<{ group: string; secrets: Secret[] }>(`/secrets/${encodeURIComponent(group)}`);
},
putSecrets(group: string, values: Record<string, string>): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>(`/secrets/${encodeURIComponent(group)}`, {
method: "PUT",
body: JSON.stringify(values),
});
},
revealSecret(group: string, key: string): Promise<{ value: string }> {
return request<{ value: string }>(`/secrets/${encodeURIComponent(group)}/reveal`, {
method: "POST",
body: JSON.stringify({ key }),
});
},
deleteSecret(group: string, key: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}/${encodeURIComponent(key)}`, {
method: "DELETE",
});
},
deleteSecretGroup(group: string): Promise<void> {
return request<void>(`/secrets/${encodeURIComponent(group)}`, { method: "DELETE" });
},
listKeys(): Promise<Key[]> {
return request<Key[]>("/keys");
},
getKey(keyId: string): Promise<KeyWithAssignments> {
return request<KeyWithAssignments>(`/keys/${keyId}`);
},
uploadKey(label: string, public_key: string, private_key?: string, passphrase?: string): Promise<Key> {
return request<Key>("/keys", {
method: "POST",
body: JSON.stringify({
label,
public_key,
private_key: private_key || undefined,
passphrase: passphrase || undefined,
}),
});
},
getPrivateKey(keyId: string): Promise<{ private_key: string }> {
return request<{ private_key: string }>(`/keys/${keyId}/private-key`);
},
deleteKey(keyId: string): Promise<void> {
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
},
assignKey(keyId: string, serverId: string): Promise<Assignment> {
return request<Assignment>(`/keys/${keyId}/assign`, {
method: "POST",
body: JSON.stringify({ server_id: serverId }),
});
},
revokeKey(keyId: string, serverId: string): Promise<void> {
return request<void>(`/keys/${keyId}/assign/${serverId}`, {
method: "DELETE",
});
},
connectConsole(body: ConsoleConnectRequest): Promise<ConsoleConnectResponse> {
return request<ConsoleConnectResponse>("/console/connect", {
method: "POST",
body: JSON.stringify(body),
});
},
listSteps(): Promise<WorkflowStep[]> {
return request<WorkflowStep[]>("/steps");
},
createStep(s: Partial<WorkflowStep>): Promise<WorkflowStep> {
return request<WorkflowStep>("/steps", {
method: "POST",
body: JSON.stringify(s),
});
},
updateStep(stepId: string, s: Partial<WorkflowStep>): Promise<WorkflowStep> {
return request<WorkflowStep>(`/steps/${stepId}`, {
method: "PUT",
body: JSON.stringify(s),
});
},
deleteStep(stepId: string): Promise<void> {
return request<void>(`/steps/${stepId}`, { method: "DELETE" });
},
exportStepUrl(stepId: string): string {
return `/api/steps/${stepId}/export`;
},
importStep(doc: unknown): Promise<WorkflowStep> {
return request<WorkflowStep>("/steps/import", {
method: "POST",
body: JSON.stringify(doc),
});
},
parseStep(doc: unknown): Promise<WorkflowStep> {
return request<WorkflowStep>("/steps/parse", {
method: "POST",
body: JSON.stringify(doc),
});
},
seedDefaults(): Promise<{ created: number; updated: number }> {
return request<{ created: number; updated: number }>("/steps/seed-defaults", {
method: "POST",
});
},
stepUsage(): Promise<Record<string, number>> {
return request<Record<string, number>>("/steps/usage");
},
listWorkflows(): Promise<Workflow[]> {
return request<Workflow[]>("/workflows");
},
getWorkflow(workflowId: string): Promise<Workflow> {
return request<Workflow>(`/workflows/${workflowId}`);
},
createWorkflow(w: Partial<Workflow>): Promise<Workflow> {
return request<Workflow>("/workflows", {
method: "POST",
body: JSON.stringify(w),
});
},
updateWorkflow(workflowId: string, w: Partial<Workflow>): Promise<Workflow> {
return request<Workflow>(`/workflows/${workflowId}`, {
method: "PUT",
body: JSON.stringify(w),
});
},
deleteWorkflow(workflowId: string): Promise<void> {
return request<void>(`/workflows/${workflowId}`, { method: "DELETE" });
},
runWorkflow(workflowId: string): Promise<{ run_id: string }> {
return request<{ run_id: string }>(`/workflows/${workflowId}/run`, {
method: "POST",
});
},
listRuns(workflowId: string): Promise<WorkflowRun[]> {
return request<WorkflowRun[]>(`/workflows/${workflowId}/runs`);
},
getRun(runId: string): Promise<WorkflowRun> {
return request<WorkflowRun>(`/runs/${runId}`);
},
cancelRun(runId: string): Promise<void> {
return request<void>(`/runs/${runId}/cancel`, { method: "POST" });
},
async getServerRunLog(runId: string, serverId: string): Promise<string> {
const res = await fetch(`/api/runs/${runId}/servers/${serverId}/logs`, {
credentials: "include",
});
if (!res.ok) throw new Error("no logs");
return res.text();
},
serverRunLogStreamUrl(runId: string, serverId: string): string {
return `/api/runs/${runId}/servers/${serverId}/logs/stream`;
},
};
export type LicenseState = "valid" | "expired" | "invalid";
export interface LicenseInfo {
instance_id: string;
state: LicenseState;
reason?: string;
tier?: string;
expires_at?: string;
days_remaining?: number;
limits: { max_servers: number; max_secret_groups: number; max_channels: number };
features: Record<string, boolean>;
usage: { servers: number; secret_groups: number; channels: number };
source: string;
/** "cloud" | "self_hosted". A cloud instance's licence is managed in HQ. */
deployment: string;
}
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {
return request<LicenseInfo>("/license");
},
put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> {
return request("/license", { method: "POST", body: JSON.stringify({ blob }) });
},
};