BetterFrame/server/src/shared/api-schemas.ts
Mitchell R 8c59bb6b02
fix: wrap nullable event fields with optional() for missing keys
anyvali nullable() accepts null but rejects undefined (absent field).
Kiosk log events omit camera_id/property_op entirely. Wrap with
optional() so missing fields default to null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 15:26:55 +02:00

168 lines
5.4 KiB
TypeScript

/**
* Anyvali input schemas for all external-facing API endpoints.
* Applied via validateBody() / validateQuery() helpers.
*/
import * as av from "@anyvali/js";
// ---- Kiosk API (service-api-http) -------------------------------------------
export const PairInitiateBody = av.object(
{
proposed_name: av.string().maxLength(128).default(""),
hardware_model: av.string().maxLength(128).default(""),
capabilities: av.array(av.string().maxLength(64)).default([]),
managed_image: av.bool().default(false),
},
{ unknownKeys: "strip" },
);
export const PairClaimBody = av.object(
{
code: av.string().minLength(1).maxLength(16),
},
{ unknownKeys: "strip" },
);
const HeartbeatDisplay = av.object(
{
index: av.int().min(0).max(32).default(0),
name: av.string().maxLength(128).default(""),
width_px: av.int().min(0).max(16384).default(0),
height_px: av.int().min(0).max(16384).default(0),
power_state: av.string().maxLength(16).default("unknown"),
},
{ unknownKeys: "strip" },
);
const HeartbeatPartition = av.object(
{
device: av.string().maxLength(128).default(""),
mountpoint: av.string().maxLength(256).default(""),
total_mb: av.int().min(0).default(0),
used_mb: av.int().min(0).default(0),
free_mb: av.int().min(0).default(0),
used_percent: av.number().min(0).max(100).default(0),
},
{ unknownKeys: "strip" },
);
export const HeartbeatBody = av.object(
{
bundle_version: av.string().maxLength(128).default(""),
kiosk_app_version: av.string().maxLength(64).default(""),
os_version: av.string().maxLength(64).default(""),
displays: av.array(HeartbeatDisplay).default([]),
cpu_temp_c: av.nullable(av.number().min(-40).max(150)).default(null),
cpu_load_percent: av.nullable(av.number().min(0).max(100)).default(null),
fan_rpm: av.nullable(av.int().min(0).max(50000)).default(null),
fan_pwm: av.nullable(av.int().min(0).max(255)).default(null),
memory_total_mb: av.nullable(av.int().min(0)).default(null),
memory_used_mb: av.nullable(av.int().min(0)).default(null),
disk_total_mb: av.nullable(av.int().min(0)).default(null),
disk_free_mb: av.nullable(av.int().min(0)).default(null),
disk_used_percent: av.nullable(av.number().min(0).max(100)).default(null),
local_key: av.nullable(av.string().maxLength(256)).default(null),
local_port: av.nullable(av.int().min(1).max(65535)).default(null),
reported_hostname: av.nullable(av.string().maxLength(256)).default(null),
network_interfaces: av.array(av.any()).default([]),
partitions: av.array(HeartbeatPartition).default([]),
managed_config_applied_version: av.optional(av.int().min(0)),
managed_config_error: av.optional(av.nullable(av.string().maxLength(4096))),
onvif_subscriptions: av.optional(av.any()),
},
{ unknownKeys: "strip" },
);
export const EventBody = av.object(
{
topic: av.string().minLength(1).maxLength(512),
source_type: av.string().maxLength(32).default("system"),
camera_id: av.optional(av.nullable(av.string().maxLength(64))).default(null),
property_op: av.optional(av.nullable(av.string().maxLength(32))).default(null),
payload: av.any().default({}),
},
{ unknownKeys: "strip" },
);
const KioskLogEntry = av.object(
{
level: av.string().maxLength(16).default("info"),
message: av.string().maxLength(4096).default(""),
context: av.any().default({}),
logged_at: av.optional(av.string().maxLength(64)),
},
{ unknownKeys: "strip" },
);
export const KioskLogsBody = av.object(
{
entries: av.array(KioskLogEntry).default([]),
},
{ unknownKeys: "strip" },
);
export const FirmwareAppliedBody = av.object(
{
version: av.string().minLength(1).maxLength(64),
error: av.optional(av.string().maxLength(4096)),
},
{ unknownKeys: "strip" },
);
export const OsAppliedBody = av.object(
{
version: av.string().minLength(1).maxLength(64),
error: av.optional(av.string().maxLength(4096)),
},
{ unknownKeys: "strip" },
);
// ---- Auth (routes-auth, routes-setup) ----------------------------------------
export const LoginBody = av.object(
{
username: av.string().minLength(1).maxLength(128),
password: av.string().minLength(1).maxLength(1024),
},
{ unknownKeys: "strip" },
);
export const TotpBody = av.object(
{
code: av.string().minLength(1).maxLength(16),
},
{ unknownKeys: "strip" },
);
export const SetupBody = av.object(
{
username: av.string().minLength(3).maxLength(64),
password: av.string().minLength(12).maxLength(1024),
},
{ unknownKeys: "strip" },
);
export const PasswordChangeBody = av.object(
{
current_password: av.string().minLength(1).maxLength(1024),
new_password: av.string().minLength(12).maxLength(1024),
},
{ unknownKeys: "strip" },
);
// ---- Helper -----------------------------------------------------------------
export function validateBody<T>(schema: { safeParse(input: unknown): { success: boolean; data?: T; error?: unknown } }, raw: unknown): T {
const result = schema.safeParse(raw);
if (!result.success) {
let msg = "invalid request body";
const err = result.error as any;
if (err?.issues) {
msg = err.issues.map((i: any) => `${i.path?.join?.(".") ?? "?"}: ${i.message}`).join("; ");
} else if (err?.message) {
msg = String(err.message);
}
throw Object.assign(new Error(msg), { status: 400, statusText: "Bad Request" });
}
return result.data as T;
}