Files
vantage/web/lib/api.ts
T
mrhid6andClaude Opus 5 c4e6ad5485
Server Deploy / deploy (push) Successful in 5m22s
feat(license): metered licensing — catalogue, entitlements, and enforcement
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.

Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.

Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.

Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.

Docs: CLAUDE.md, spec index and plan 5 preamble updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:37:39 +01:00

836 lines
23 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;
support_level?: string;
expires_at?: string;
days_remaining?: number;
limits: {
max_servers: number;
max_monitors: number;
max_secret_groups: number;
max_channels: number;
audit_retention_days: number;
};
features: Record<string, boolean>;
usage: {
servers: number;
monitors: 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 }) });
},
};