import { requestStepUp } from "./stepup"; export type ServerStatus = "pending" | "active" | "offline"; export type KeySource = "uploaded" | "generated"; export interface PackageUpdate { name: string; current_version?: string; new_version: string; /** Ubuntu phased update this host is not yet selected for: apt defers it. */ phased?: boolean; } /** Updates an upgrade would install now. Phased updates are pending but deferred by apt. */ export function installableUpdates(updates: PackageUpdate[] | undefined): PackageUpdate[] { return (updates ?? []).filter((u) => !u.phased); } 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; reboot_required?: boolean; 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; tags?: Record; } 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; /** Display-only heading on the monitors page. Empty means ungrouped. */ group?: 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; group?: 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; } /** One check result. Kept for 48 hours, which is what the sub-hour views read. */ export interface MonitorSample { monitor_id: string; at: string; up: boolean; latency_ms: number; } export interface Rollup { monitor_id: string; period_start: string; checks: number; up_count: number; sum_latency: number; } // --------------------------------------------------------------------------- // Status page authoring types. These mirror server/internal/models/statuspage.go // field for field -- that Go file is the contract. The public shapes // (PublicDay, PublicComponent, PublicSection, PublicIncident, StatusSnapshot) // live further down this file, added by the public status page task; this // block must not redeclare them. // --------------------------------------------------------------------------- export type StatusPageKind = "incident" | "maintenance"; export type StatusImpact = "none" | "minor" | "major" | "critical"; export interface StatusPageEntry { monitor_id: string; display_name?: string; } export interface StatusPageSection { name: string; entries: StatusPageEntry[]; } export interface StatusPageBanner { enabled: boolean; level?: string; text?: string; } export interface StatusPage { page_id: string; title: string; description?: string; logo_url?: string; published: boolean; banner: StatusPageBanner; sections: StatusPageSection[]; created_at: string; updated_at: string; } export interface StatusIncidentUpdate { at: string; status: string; body: string; author?: string; } export interface StatusIncident { incident_id: string; page_ids: string[]; kind: StatusPageKind; title: string; impact: StatusImpact; affected_monitors?: string[]; status: string; scheduled_start?: string; scheduled_end?: string; updates: StatusIncidentUpdate[]; started_at: string; resolved_at?: string; } export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram"; /** * What a channel's secret config values read as over the API. Writing it back * unchanged preserves the stored credential; anything else, including "", is * written verbatim. * * Mirrors `models.RedactedSecret` and `models.channelSecretKeys` in * `server/internal/models/channel.go` - change both in the same commit, the * same hazard as the mirrored token blocks. */ export const REDACTED_SECRET = "••••••••"; export const CHANNEL_SECRET_FIELDS: Record = { webhook: ["url"], slack: ["url"], discord: ["url"], telegram: ["token"], smtp: ["password"], }; export interface NotificationChannel { channel_id: string; name: string; type: ChannelType; config: Record; enabled: boolean; created_at: string; } export interface ChannelInput { name: string; type: ChannelType; config: Record; 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 AuditQuery { q?: string; category?: string; limit?: number; skip?: number; } /* * The audit log pages on the server, unlike the fleet endpoints that answer * with everything and slice in the browser. It is kept for months and read to * answer questions about the past, so a search that only saw the most recent * page would report "no results" for events that exist. */ export interface AuditPage { events: AuditEvent[]; total: number; } export interface AlertSettings { offline_threshold_minutes: number; offline_channel_ids: string[] | null; } export interface SecretsSettings { read_token_set: boolean; rotated_at?: string; } export interface Settings { alerts: AlertSettings; secrets: SecretsSettings; workflow_log_retention_days?: number | null; local_login_enabled?: boolean; api_token_max_days?: number | null; /** Owner-only: members without a factor must enrol at their next sign-in. */ require_mfa?: boolean; } export type ApiToken = { token_id: string; name: string; hint: string; role: Role; scopes: string[]; expires_at?: string | null; created_at: string; last_used_at?: string | null; user_id: string; user_email?: string; /** Restricts the token to servers carrying every pair. Absent or empty is * the whole fleet - the asymmetry is deliberate, see services.MatchesSelector. */ tag_selector?: Record | 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; } export interface Workflow { workflow_id: string; name: string; target_server_ids: string[]; target_tags?: Record; steps: WorkflowStepRef[]; schedule?: Schedule; next_run_at?: string; last_run_at?: string; last_skipped?: Skip; } export interface StepRun { order: number; name: string; status: string; attempts: number; exit_code: number; log_offset: number; output_env: Record; started_at?: string; finished_at?: string; } export interface ServerRun { server_id: string; hostname: string; status: string; run_env: Record; 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 PatchScope = "all" | "security"; export type PatchReboot = "never" | "if_required"; export type PatchRunStatus = "running" | "succeeded" | "partial" | "failed" | "cancelled"; export type PatchServerStatus = | "queued" | "waiting_offline" | "patching" | "rebooting" | "succeeded" | "failed" | "unsupported" | "agent_too_old" | "missed_offline" | "window_closed" | "cancelled"; export interface MaintenanceWindow { window_id: string; name: string; cron: string; tz: string; duration_minutes: number; created_at: string; updated_at: string; } export interface MaintenanceWindowInput { name: string; cron: string; tz: string; duration_minutes: number; } export interface WindowSpan { start: string; end: string; } export interface PatchPolicy { policy_id: string; name: string; enabled: boolean; window_id: string; target_server_ids: string[]; target_tags?: Record; scope: PatchScope; reboot: PatchReboot; max_concurrent: number; notify_channel_ids?: string[]; next_run_at?: string; last_run_at?: string; last_skipped?: Skip; disabled_reason?: string; created_at: string; updated_at: string; } export type PatchPolicyInput = Pick< PatchPolicy, "name" | "enabled" | "window_id" | "target_server_ids" | "target_tags" | "scope" | "reboot" | "max_concurrent" | "notify_channel_ids" >; export interface PatchServerRun { server_id: string; hostname: string; status: PatchServerStatus; pending_before: number; pending_after?: number; rebooted_at?: string; verified_at?: string; output?: string; error?: string; started_at?: string; finished_at?: string; } export interface PatchRun { run_id: string; policy_id?: string; policy_name?: string; triggered_by: string; source: "schedule" | "run_now" | "server" | "vulnerabilities" | "mcp"; scope: PatchScope; reboot: PatchReboot; max_concurrent: number; window_end?: string; status: PatchRunStatus; cancelled_at?: string; started_at: string; finished_at?: string; servers: PatchServerRun[]; } 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; /** Vantage HQ has locked this instance; the login page shows a message instead of the form. */ locked?: boolean; } 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; mfa_enabled: boolean; } export interface OrgUserInput { email: string; password: string; role: Role; } export interface PublicProvider { id: string; name: string; preset: string; } export interface ProvidersResponse { local_enabled: boolean; providers: PublicProvider[]; locked?: boolean; } export interface AuthProvider { provider_id: string; instance_id: string; name: string; kind: "oidc" | "oauth2"; preset: string; issuer: string; client_id: string; scopes: string[]; enabled: boolean; callback_notice: boolean; order: number; created_at: string; updated_at: string; client_secret_set: boolean; callback_url: string; } export interface AuthPreset { id: string; label: string; kind: "oidc" | "oauth2"; input_label: string; input_hint: string; } export interface AuthProviderInput { name: string; preset: string; issuer_input?: string; client_id: string; client_secret: string; enabled: boolean; } export interface AuthProviderUpdate { name?: string; issuer_input?: string; client_id?: string; client_secret?: string; enabled?: boolean; order?: number; } export class ApiError extends Error { constructor( public status: number, message: string, public code?: string, public retryAfter?: number, public attemptsLeft?: number, ) { super(message); this.name = "ApiError"; } } /** RequestInit plus the internal flag that stops a step-up retry from looping. */ type ApiRequestInit = RequestInit & { __retried?: boolean }; async function request(path: string, options?: ApiRequestInit): Promise { 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}`; let code: string | undefined; let methods: string[] | undefined; try { const body = JSON.parse(text || "{}"); if (body?.error) message = body.error; if (body?.code) code = body.code; if (Array.isArray(body?.methods)) methods = body.methods; } catch {} // A guarded route answers 403 with the factors that would satisfy it. // Re-authenticate once through the shared modal, then retry the // original request exactly once - a loop here would prompt forever // against a server that keeps refusing. This is outside the parse // try/catch above: a cancelled step-up rejects, and that rejection // must propagate, not be swallowed as a JSON parse failure. if (res.status === 403 && code === "step_up_required" && !options?.__retried) { await requestStepUp(methods ?? ["password"]); return request(path, { ...options, __retried: true }); } const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined; throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined); } if (res.status === 204) { return undefined as T; } return res.json(); } async function authRequest(path: string, options?: RequestInit): Promise { const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...options?.headers }, ...options, }); if (!res.ok) { let message = `HTTP ${res.status}`; let code: string | undefined; let attemptsLeft: number | undefined; try { const body = await res.json(); if (body?.error) message = body.error; if (body?.code) code = body.code; if (typeof body?.attempts_left === "number") attemptsLeft = body.attempts_left; } catch {} const retryAfter = res.status === 429 ? Number(res.headers.get("Retry-After")) : undefined; throw new ApiError(res.status, message, code, Number.isFinite(retryAfter) ? retryAfter : undefined, attemptsLeft); } if (res.status === 204) { return undefined as T; } return res.json(); } export const auth = { bootstrapStatus(): Promise { return authRequest("/auth/bootstrap-status"); }, bootstrap(input: { instance_name: string; email: string; password: string }): Promise { return authRequest("/auth/bootstrap", { method: "POST", body: JSON.stringify(input), }); }, login(email: string, password: string): Promise<{ ok?: true } | { mfa_required: true; methods: string[] } | { enrol_required: true }> { return authRequest("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); }, logout(): Promise { return authRequest("/auth/logout", { method: "POST" }); }, // --- second factor (an in-progress login, identified by the server-side ticket cookie) --- mfaTotp(code: string): Promise<{ ok: true }> { return authRequest("/auth/mfa/totp", { method: "POST", body: JSON.stringify({ code }) }); }, mfaRecovery(code: string): Promise<{ ok: true }> { return authRequest("/auth/mfa/recovery", { method: "POST", body: JSON.stringify({ code }) }); }, mfaWebAuthnBegin(): Promise<{ publicKey: any; ceremony_id: string }> { return authRequest("/auth/mfa/webauthn/begin", { method: "POST" }); }, mfaWebAuthnFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> { return authRequest("/auth/mfa/webauthn/finish", { method: "POST", body: JSON.stringify({ ceremony_id: ceremonyId, credential }), }); }, // --- passwordless passkey sign-in --- passkeyLoginBegin(): Promise<{ publicKey: any; ceremony_id: string }> { return authRequest("/auth/passkey/begin", { method: "POST" }); }, passkeyLoginFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> { return authRequest("/auth/passkey/finish", { method: "POST", body: JSON.stringify({ ceremony_id: ceremonyId, credential }), }); }, // --- forced enrolment (a fresh account that has not set up a second factor yet) --- enrolTotpSetup(): Promise<{ secret: string; otpauth_url: string }> { return authRequest("/auth/mfa/enrol/totp/setup", { method: "POST" }); }, enrolTotpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> { return authRequest("/auth/mfa/enrol/totp/confirm", { method: "POST", body: JSON.stringify({ code }) }); }, enrolPasskeyBegin(): Promise<{ publicKey: any; ceremony_id: string }> { return authRequest("/auth/mfa/enrol/passkey/begin", { method: "POST" }); }, enrolPasskeyFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> { return authRequest("/auth/mfa/enrol/passkey/finish", { method: "POST", body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }), }); }, me(): Promise { return authRequest("/auth/me"); }, /** Unauthenticated: what the login page draws itself from. */ providers(): Promise { return authRequest("/auth/providers"); }, /** Where a provider button sends the browser. */ ssoStartUrl(providerId: string): string { return `/auth/oidc/${providerId}/start`; }, }; export interface MePasskey { id: string; name: string; created_at: string; last_used_at?: string; transports?: string[]; } export interface MeMfaStatus { totp_enabled: boolean; passkeys: MePasskey[]; recovery_remaining: number; /** The instance's require_mfa policy: a removal leaving no factor is refused. */ require_mfa: boolean; /** False when an identity provider owns this user's authentication. */ applicable: boolean; } /** * The signed-in user's own factors, at /api/me/*. Several of these are * guarded by step-up and answer 403 `step_up_required`; that interception is * handled globally elsewhere, not here. */ export const me = { mfa(): Promise { return request("/me/mfa"); }, totpSetup(): Promise<{ secret: string; otpauth_uri: string }> { return request("/me/mfa/totp/setup", { method: "POST" }); }, totpConfirm(code: string): Promise<{ ok: true; recovery_codes?: string[] }> { return request("/me/mfa/totp/confirm", { method: "POST", body: JSON.stringify({ code }) }); }, removeTotp(): Promise { return request("/me/mfa/totp", { method: "DELETE" }); }, regenerateRecovery(): Promise<{ recovery_codes: string[] }> { return request("/me/mfa/recovery/regenerate", { method: "POST" }); }, passkeyRegisterBegin(): Promise<{ publicKey: any; ceremony_id: string }> { return request("/me/passkeys/begin", { method: "POST" }); }, passkeyRegisterFinish(ceremonyId: string, credential: unknown, name?: string): Promise<{ ok: true; recovery_codes?: string[] }> { return request("/me/passkeys/finish", { method: "POST", body: JSON.stringify({ ceremony_id: ceremonyId, credential, name }), }); }, renamePasskey(id: string, name: string): Promise { return request(`/me/passkeys/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }); }, deletePasskey(id: string): Promise { return request(`/me/passkeys/${id}`, { method: "DELETE" }); }, /** * Re-authenticates the current session against exactly one factor. Called * by the StepUpModal, and by `request()` internally never - the modal is * the only caller, so the retry-once guarantee lives entirely in `request`. */ stepUp(factor: { totp: string } | { recovery: string } | { password: string }): Promise<{ ok: true }> { return request("/me/step-up", { method: "POST", body: JSON.stringify(factor) }); }, }; export const api = { listInstanceUsers(): Promise { return request("/instance/users"); }, createInstanceUser(input: OrgUserInput): Promise { return request("/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 { return request(`/instance/users/${userId}`, { method: "DELETE" }); }, /** Owner or admin; an admin cannot reset an owner's MFA (403). Step-up guarded. */ resetMemberMFA(userId: string): Promise { return request(`/org/users/${userId}/mfa`, { method: "DELETE" }); }, listAuthPresets(): Promise { return request("/auth/presets"); }, listAuthProviders(): Promise { return request("/auth/providers"); }, createAuthProvider(input: AuthProviderInput): Promise { return request("/auth/providers", { method: "POST", body: JSON.stringify(input) }); }, updateAuthProvider(id: string, input: AuthProviderUpdate): Promise<{ saved: boolean }> { return request<{ saved: boolean }>(`/auth/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }); }, deleteAuthProvider(id: string): Promise<{ deleted: boolean }> { return request<{ deleted: boolean }>(`/auth/providers/${id}`, { method: "DELETE" }); }, testAuthProvider(id: string): Promise<{ ok: boolean; message: string }> { return request<{ ok: boolean; message: string }>(`/auth/providers/${id}/test`, { method: "POST" }); }, ackAuthProviderNotice(id: string): Promise<{ acknowledged: boolean }> { return request<{ acknowledged: boolean }>(`/auth/providers/${id}/ack-notice`, { method: "POST" }); }, listServers(tags?: Record): Promise { const params = Object.entries(tags ?? {}).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`); return request(`/servers${params.length ? `?${params.join("&")}` : ""}`); }, listKnownTags(): Promise> { return request>("/servers/tags"); }, setServerTags(serverId: string, tags: Record): Promise<{ tags: Record }> { return request<{ tags: Record }>(`/servers/${serverId}/tags`, { method: "PUT", body: JSON.stringify({ tags }), }); }, getServer(serverId: string): Promise { return request(`/servers/${serverId}`); }, createServer(): Promise { return request("/servers/new", { method: "POST" }); }, deleteServer(serverId: string): Promise { return request(`/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 { return request("/monitors"); }, getMonitor(monitorId: string): Promise { return request(`/monitors/${monitorId}`); }, createMonitor(input: MonitorInput): Promise { return request("/monitors", { method: "POST", body: JSON.stringify(input) }); }, updateMonitor(monitorId: string, input: Partial): Promise { return request(`/monitors/${monitorId}`, { method: "PUT", body: JSON.stringify(input) }); }, deleteMonitor(monitorId: string): Promise { return request(`/monitors/${monitorId}`, { method: "DELETE" }); }, getMonitorIncidents(monitorId: string): Promise { return request(`/monitors/${monitorId}/incidents`); }, getMonitorUptime(monitorId: string): Promise { return request(`/monitors/${monitorId}/uptime`); }, getMonitorSamples(monitorId: string, minutes: number): Promise { return request(`/monitors/${monitorId}/samples?minutes=${minutes}`); }, listStatusPages(): Promise { return request("/status-pages"); }, getStatusPage(pageId: string): Promise { return request(`/status-pages/${pageId}`); }, createStatusPage(input: Partial): Promise { return request("/status-pages", { method: "POST", body: JSON.stringify(input) }); }, updateStatusPage(pageId: string, input: Partial): Promise { return request(`/status-pages/${pageId}`, { method: "PUT", body: JSON.stringify(input), }); }, deleteStatusPage(pageId: string): Promise { return request(`/status-pages/${pageId}`, { method: "DELETE" }); }, listStatusIncidents(pageId: string): Promise { return request(`/status-pages/${pageId}/incidents`); }, createStatusIncident(pageId: string, input: Partial): Promise { return request(`/status-pages/${pageId}/incidents`, { method: "POST", body: JSON.stringify(input), }); }, updateStatusIncident( pageId: string, incidentId: string, input: Partial, ): Promise { return request(`/status-pages/${pageId}/incidents/${incidentId}`, { method: "PUT", body: JSON.stringify(input), }); }, deleteStatusIncident(pageId: string, incidentId: string): Promise { return request(`/status-pages/${pageId}/incidents/${incidentId}`, { method: "DELETE", }); }, postStatusIncidentUpdate( pageId: string, incidentId: string, status: string, body: string, ): Promise { return request(`/status-pages/${pageId}/incidents/${incidentId}/updates`, { method: "POST", body: JSON.stringify({ status, body }), }); }, listChannels(): Promise { return request("/channels"); }, createChannel(input: ChannelInput): Promise { return request("/channels", { method: "POST", body: JSON.stringify(input) }); }, updateChannel(channelId: string, input: Partial): Promise { return request(`/channels/${channelId}`, { method: "PUT", body: JSON.stringify(input) }); }, deleteChannel(channelId: string): Promise { return request(`/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, source?: "vulnerabilities"): Promise<{ message: string; run_id?: string }> { const qs = source ? `?source=${source}` : ""; return request<{ message: string; run_id?: string }>(`/servers/${serverId}/apply-updates${qs}`, { method: "POST", }); }, listMaintenanceWindows(): Promise { return request("/maintenance-windows"); }, createMaintenanceWindow(input: MaintenanceWindowInput): Promise { return request("/maintenance-windows", { method: "POST", body: JSON.stringify(input) }); }, updateMaintenanceWindow(windowId: string, input: MaintenanceWindowInput): Promise { return request(`/maintenance-windows/${windowId}`, { method: "PUT", body: JSON.stringify(input) }); }, deleteMaintenanceWindow(windowId: string): Promise { return request(`/maintenance-windows/${windowId}`, { method: "DELETE" }); }, previewMaintenanceWindow(input: Omit): Promise { return request("/maintenance-windows/preview", { method: "POST", body: JSON.stringify(input) }); }, listPatchPolicies(): Promise { return request("/patch-policies"); }, createPatchPolicy(input: PatchPolicyInput): Promise { return request("/patch-policies", { method: "POST", body: JSON.stringify(input) }); }, updatePatchPolicy(policyId: string, input: PatchPolicyInput): Promise { return request(`/patch-policies/${policyId}`, { method: "PUT", body: JSON.stringify(input) }); }, deletePatchPolicy(policyId: string): Promise { return request(`/patch-policies/${policyId}`, { method: "DELETE" }); }, runPatchPolicyNow(policyId: string): Promise { return request(`/patch-policies/${policyId}/run-now`, { method: "POST" }); }, listPatchRuns(params: { policy_id?: string; server_id?: string; limit?: number } = {}): Promise { const qs = new URLSearchParams(); if (params.policy_id) qs.set("policy_id", params.policy_id); if (params.server_id) qs.set("server_id", params.server_id); if (params.limit) qs.set("limit", String(params.limit)); const suffix = qs.toString(); return request(`/patch-runs${suffix ? `?${suffix}` : ""}`); }, getPatchRun(runId: string): Promise { return request(`/patch-runs/${runId}`); }, cancelPatchRun(runId: string): Promise { return request(`/patch-runs/${runId}/cancel`, { method: "POST" }); }, listAuditEvents(params: AuditQuery = {}): Promise { const qs = new URLSearchParams(); if (params.q) qs.set("q", params.q); if (params.category) qs.set("category", params.category); if (params.limit) qs.set("limit", String(params.limit)); if (params.skip) qs.set("skip", String(params.skip)); const suffix = qs.toString(); return request(`/audit${suffix ? `?${suffix}` : ""}`); }, getSettings(): Promise { return request("/settings"); }, saveSettings(settings: { alerts: AlertSettings; workflow_log_retention_days?: number | null; local_login_enabled?: boolean; api_token_max_days?: number | null; require_mfa?: boolean; }): 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" }); }, listApiTokens(all = false): Promise<{ tokens: ApiToken[]; all: boolean }> { return request<{ tokens: ApiToken[]; all: boolean }>(`/tokens${all ? "?all=true" : ""}`); }, listTokenScopes(): Promise<{ scopes: string[] }> { return request<{ scopes: string[] }>("/tokens/scopes"); }, createApiToken(body: { name: string; role: Role; scopes: string[]; tag_selector?: Record; expires_in_days?: number | null; }): Promise<{ token: string; record: ApiToken }> { return request<{ token: string; record: ApiToken }>("/tokens", { method: "POST", body: JSON.stringify(body), }); }, revokeApiToken(tokenId: string): Promise<{ revoked: boolean }> { return request<{ revoked: boolean }>(`/tokens/${tokenId}`, { method: "DELETE" }); }, listSecretGroups(): Promise { return request("/secrets"); }, createSecretGroup(group: string, values: Record): 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): 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 { return request(`/secrets/${encodeURIComponent(group)}/${encodeURIComponent(key)}`, { method: "DELETE", }); }, deleteSecretGroup(group: string): Promise { return request(`/secrets/${encodeURIComponent(group)}`, { method: "DELETE" }); }, listKeys(): Promise { return request("/keys"); }, getKey(keyId: string): Promise { return request(`/keys/${keyId}`); }, uploadKey(label: string, public_key: string, private_key?: string, passphrase?: string): Promise { return request("/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 { return request(`/keys/${keyId}`, { method: "DELETE" }); }, assignKey(keyId: string, serverId: string): Promise { return request(`/keys/${keyId}/assign`, { method: "POST", body: JSON.stringify({ server_id: serverId }), }); }, revokeKey(keyId: string, serverId: string): Promise { return request(`/keys/${keyId}/assign/${serverId}`, { method: "DELETE", }); }, connectConsole(body: ConsoleConnectRequest): Promise { return request("/console/connect", { method: "POST", body: JSON.stringify(body), }); }, listSteps(): Promise { return request("/steps"); }, createStep(s: Partial): Promise { return request("/steps", { method: "POST", body: JSON.stringify(s), }); }, updateStep(stepId: string, s: Partial): Promise { return request(`/steps/${stepId}`, { method: "PUT", body: JSON.stringify(s), }); }, deleteStep(stepId: string): Promise { return request(`/steps/${stepId}`, { method: "DELETE" }); }, exportStepUrl(stepId: string): string { return `/api/steps/${stepId}/export`; }, importStep(doc: unknown): Promise { return request("/steps/import", { method: "POST", body: JSON.stringify(doc), }); }, parseStep(doc: unknown): Promise { return request("/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> { return request>("/steps/usage"); }, listWorkflows(): Promise { return request("/workflows"); }, getWorkflow(workflowId: string): Promise { return request(`/workflows/${workflowId}`); }, createWorkflow(w: Partial): Promise { return request("/workflows", { method: "POST", body: JSON.stringify(w), }); }, updateWorkflow(workflowId: string, w: Partial): Promise { return request(`/workflows/${workflowId}`, { method: "PUT", body: JSON.stringify(w), }); }, deleteWorkflow(workflowId: string): Promise { return request(`/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 { return request(`/workflows/${workflowId}/runs`); }, getRun(runId: string): Promise { return request(`/runs/${runId}`); }, cancelRun(runId: string): Promise { return request(`/runs/${runId}/cancel`, { method: "POST" }); }, async getServerRunLog(runId: string, serverId: string): Promise { 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`; }, setWorkflowSchedule(workflowId: string, schedule: Schedule): Promise<{ schedule: Schedule; next_run_at: string | null }> { return request(`/workflows/${workflowId}/schedule`, { method: "PUT", body: JSON.stringify(schedule) }); }, previewSchedule(workflowId: string, cron: string, tz: string): Promise<{ occurrences: string[] }> { return request(`/workflows/${workflowId}/schedule/preview?cron=${encodeURIComponent(cron)}&tz=${encodeURIComponent(tz)}`); }, }; 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; 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; } export type Severity = "critical" | "high" | "medium" | "low" | "unknown"; export type FindingState = "open" | "fixed" | "accepted"; export interface Acceptance { by: string; reason: string; until: string; at: string; } export interface VulnFinding { id: string; server_id: string; cve_id: string; package_name: string; installed_version: string; /** Absent means no vendor fix is published - a real state, not missing data. */ fixed_in?: string; severity: Severity; cvss_score?: number; title?: string; references?: string[]; state: FindingState; first_seen: string; last_seen: string; fixed_at?: string; accepted?: Acceptance; } /** One CVE across every server it affects. The board groups by CVE because the * same CVE on forty servers is one decision, not forty rows. */ export interface VulnGroup { cve_id: string; severity: Severity; title?: string; server_count: number; findings: VulnFinding[]; } export interface VulnSummary { counts: Partial>; db_version?: number; pulled_at?: string; last_full_scan_at?: string; last_error?: string; } export interface InstalledPackage { name: string; version: string; epoch?: number; arch: string; source_name?: string; } export interface ServerPackages { server_id: string; os: { family: string; version_id: string; arch: string }; hash: string; packages: InstalledPackage[]; collected_at: string; scan_pending: boolean; scanned_at?: string; /** "ok" | "unsupported". Unsupported must never read as "clean". */ status: string; db_version: number; } export interface PackageHit { server_id: string; name: string; version: string; } export interface VulnAlertRule { id: string; name: string; enabled: boolean; min_severity: Severity; tags?: Record; channel_ids: string[]; created_at: string; updated_at: string; } export interface VulnAlertRuleInput { name: string; enabled: boolean; min_severity: Severity; tags?: Record; channel_ids: string[]; } export const vulnerabilities = { list(params?: { severity?: string; state?: string; server?: string; hasFix?: boolean; tags?: Record }): Promise { const q = new URLSearchParams(); if (params?.severity) q.set("severity", params.severity); if (params?.state) q.set("state", params.state); if (params?.server) q.set("server", params.server); // Explicitly undefined-checked: `false` is a real selection here (the // unfixable set), so a truthiness test would silently drop it. if (params?.hasFix !== undefined) q.set("has_fix", String(params.hasFix)); for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`); const qs = q.toString(); return request(`/vulnerabilities${qs ? `?${qs}` : ""}`); }, summary(): Promise { return request("/vulnerabilities/summary"); }, rescan(): Promise<{ queued: number }> { return request<{ queued: number }>("/vulnerabilities/rescan", { method: "POST" }); }, accept(id: string, reason: string, until: string): Promise { return request(`/vulnerabilities/${id}/accept`, { method: "POST", body: JSON.stringify({ reason, until }), }); }, unaccept(id: string): Promise { return request(`/vulnerabilities/${id}/accept`, { method: "DELETE" }); }, forServer(serverId: string): Promise { return request(`/servers/${serverId}/vulnerabilities`); }, packagesForServer(serverId: string): Promise { return request(`/servers/${serverId}/packages`); }, searchPackages(name: string): Promise { return request(`/packages/search?name=${encodeURIComponent(name)}`); }, listRules(): Promise { return request("/vuln-rules"); }, createRule(input: VulnAlertRuleInput): Promise { return request("/vuln-rules", { method: "POST", body: JSON.stringify(input) }); }, updateRule(id: string, input: VulnAlertRuleInput): Promise<{ status: string }> { return request<{ status: string }>(`/vuln-rules/${id}`, { method: "PUT", body: JSON.stringify(input) }); }, deleteRule(id: string): Promise<{ status: string }> { return request<{ status: string }>(`/vuln-rules/${id}`, { method: "DELETE" }); }, }; export type WorkloadKind = "container" | "unit"; export type WorkloadAction = "start" | "stop" | "restart"; /** One Docker container, one systemd unit, or one Windows service. * * `state` is deliberately not a shared vocabulary across the two kinds: * containers report running/exited/paused/restarting/created, units report * active/inactive/failed/activating. A failed unit and an exited container * mean different things. */ export interface Workload { kind: WorkloadKind; id: string; name: string; state: string; health?: string; image?: string; stack?: string; ports?: string[]; restarts?: number; started_at?: string; protected: boolean; } export interface ServerWorkloads { server_id: string; hash?: string; workloads: Workload[]; collected_at?: string; /** false with no error means "Docker not in use here", which is not a * fault. With an error it means installed but not responding. */ docker_ok: boolean; docker_error?: string; systemd_ok: boolean; systemd_error?: string; } export interface WorkloadHit { server_id: string; workload: Workload; } export const workloads = { forServer(serverId: string): Promise { return request(`/servers/${serverId}/workloads`); }, refresh(serverId: string): Promise<{ message: string }> { return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" }); }, control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> { return request<{ message: string }>( `/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`, { method: "POST", body: JSON.stringify({ kind, action }) }, ); }, logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> { return request<{ text: string; truncated: boolean }>( `/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`, ); }, search(params?: { image?: string; stack?: string; state?: string }): Promise { const q = new URLSearchParams(); if (params?.image) q.set("image", params.image); if (params?.stack) q.set("stack", params.stack); if (params?.state) q.set("state", params.state); const qs = q.toString(); return request(`/workloads${qs ? `?${qs}` : ""}`); }, }; // `request` already prefixes /api, so these paths do not repeat it. export const licence = { get(): Promise { return request("/license"); }, put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> { return request("/license", { method: "POST", body: JSON.stringify({ blob }) }); }, }; export interface Schedule { enabled: boolean; cron: string; tz: string; } export interface Skip { reason: string; due: string; at: string; } // --------------------------------------------------------------------------- // Public status page types // // These mirror services.StatusSnapshot and friends in // server/internal/services/statussnapshot.go field for field - that Go file // is the contract. They back the anonymous /status/[pageId] page, which is // deliberately outside the (app) route group and never calls `request()` // (no session, no auth). Task 10 adds the authoring types and api client // methods alongside these; it must not redeclare this block. // --------------------------------------------------------------------------- export interface PublicDay { date: string; state: "up" | "down" | "maintenance" | "no_data"; uptime: number; } export interface PublicComponent { name: string; status: "up" | "down" | "maintenance" | "pending" | "no_data"; uptime_90d: number; days: PublicDay[]; } export interface PublicSection { name: string; components: PublicComponent[]; } export interface PublicIncidentUpdate { at: string; status: string; body: string; } export interface PublicIncident { id: string; kind: string; title: string; impact?: string; status: string; affected?: string[]; started_at: string; resolved_at?: string; scheduled_start?: string; scheduled_end?: string; updates?: PublicIncidentUpdate[]; } export interface PublicBanner { level: string; text: string; } export interface StatusSnapshot { available: boolean; reason?: string; title: string; description?: string; logo_url?: string; banner?: PublicBanner; overall: string; sections: PublicSection[]; active_incidents: PublicIncident[]; upcoming_maintenance: PublicIncident[]; history: PublicIncident[]; generated_at: string; }