Zod config validation (#635)

* Zod WIP

* Zod form validation

* DynamicForm testing

* Fix linting

* Delete rasterSource.ts

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
Jeremy Gallant
2025-06-04 08:10:59 -04:00
committed by GitHub
co-authored by philon-
parent df036d3904
commit 1cbd98ec53
61 changed files with 1751 additions and 1263 deletions
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { makeChannelSchema } from "./channel.ts";
import { fromByteArray } from "base64-js";
const mockRole = 0;
function makeBase64OfLength(len: number): string {
return fromByteArray(new Uint8Array(len));
}
describe("makeChannelSchema", () => {
const allowedBytes = 16;
const schema = makeChannelSchema(allowedBytes);
const validBase64 = makeBase64OfLength(allowedBytes);
const validSettings = {
channelNum: 3,
psk: validBase64,
name: "TestName",
id: 3,
uplinkEnabled: true,
downlinkEnabled: false,
moduleSettings: { positionPrecision: 10 },
};
it("accepts valid channel object", () => {
const result = schema.safeParse({
index: 0,
settings: validSettings,
role: mockRole,
});
expect(result.success).toBe(true);
});
it("rejects invalid base64 psk", () => {
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, psk: "not_base64!" },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.path.includes("settings") && i.path.includes("psk")
),
).toBe(true);
}
});
it("rejects psk of wrong length", () => {
const wrongLength = makeBase64OfLength(8);
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, psk: wrongLength },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.path.includes("settings") && i.path.includes("psk")
),
).toBe(true);
}
});
it("rejects name longer than 12 bytes", () => {
const longName = "a".repeat(13);
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, name: longName },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.path.includes("settings") && i.path.includes("name")
),
).toBe(true);
}
});
it("rejects channelNum out of range", () => {
const result = schema.safeParse({
index: 0,
settings: { ...validSettings, channelNum: 10 },
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.path.includes("settings") && i.path.includes("channelNum")
),
).toBe(true);
}
});
it("rejects missing required fields", () => {
const result = schema.safeParse({
index: 0,
settings: {},
role: mockRole,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => {
for (const val of [0, 10, 15, 19, 32]) {
const result = schema.safeParse({
index: 0,
settings: {
...validSettings,
moduleSettings: { positionPrecision: val },
},
role: mockRole,
});
expect(result.success).toBe(true);
}
});
it("rejects moduleSettings.positionPrecision out of range", () => {
for (const val of [9, 20, 31, 33]) {
const result = schema.safeParse({
index: 0,
settings: {
...validSettings,
moduleSettings: { positionPrecision: val },
},
role: mockRole,
});
expect(result.success).toBe(false);
}
});
});
+33 -46
View File
@@ -1,51 +1,38 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import {
IsBoolean,
IsEnum,
IsInt,
IsNumber,
IsString,
Length,
} from "class-validator";
import { makePskHelpers } from "./pskSchema.ts";
import { validateMaxByteLength } from "@core/utils/string.ts";
export class ChannelValidation
implements Omit<Protobuf.Channel.Channel, keyof Message | "settings"> {
@IsNumber()
index: number;
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role);
settings: Channel_SettingsValidation;
const moduleSettingsSchema = z.object({
positionPrecision: z.union([
z.literal(0),
z.coerce.number().int().min(10).max(19),
z.literal(32),
]),
});
@IsEnum(Protobuf.Channel.Channel_Role)
role: Protobuf.Channel.Channel_Role;
}
export class Channel_SettingsValidation
implements Omit<Protobuf.Channel.ChannelSettings, keyof Message | "psk"> {
@IsNumber()
channelNum: number;
@IsString()
psk: string;
@Length(0, 11)
name: string;
@IsInt()
id: number;
@IsBoolean()
uplinkEnabled: boolean;
@IsBoolean()
downlinkEnabled: boolean;
@IsBoolean()
positionEnabled: boolean;
@IsBoolean()
preciseLocation: boolean;
@IsInt()
positionPrecision: number;
export function makeChannelSchema(allowedBytes: number) {
const { stringSchema } = makePskHelpers([allowedBytes]);
const ChannelSettingsSchema = z.object({
channelNum: z.coerce.number().int().min(0).max(7),
psk: stringSchema(false),
name: z.string()
.refine(
(s) => validateMaxByteLength(s, 12).isValid,
{ message: "formValidation.tooBig.bytes", params: { maximum: 12 } },
),
id: z.coerce.number().int(),
uplinkEnabled: z.boolean(),
downlinkEnabled: z.boolean(),
moduleSettings: moduleSettingsSchema,
});
return z.object({
index: z.coerce.number(),
settings: ChannelSettingsSchema,
role: RoleEnum,
});
}
+10 -14
View File
@@ -1,18 +1,14 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsEnum, IsInt } from "class-validator";
export class BluetoothValidation implements
Omit<
Protobuf.Config.Config_BluetoothConfig,
keyof Message | "deviceLoggingEnabled"
> {
@IsBoolean()
enabled: boolean;
const PairingModeEnum = z.enum(
Protobuf.Config.Config_BluetoothConfig_PairingMode,
);
@IsEnum(Protobuf.Config.Config_BluetoothConfig_PairingMode)
mode: Protobuf.Config.Config_BluetoothConfig_PairingMode;
export const BluetoothValidationSchema = z.object({
enabled: z.boolean(),
mode: PairingModeEnum,
fixedPin: z.coerce.number().int().min(100000).max(999999),
});
@IsInt()
fixedPin: number;
}
export type BluetoothValidation = z.infer<typeof BluetoothValidationSchema>;
+21 -38
View File
@@ -1,42 +1,25 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator";
export class DeviceValidation
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message> {
@IsEnum(Protobuf.Config.Config_DeviceConfig_Role)
role: Protobuf.Config.Config_DeviceConfig_Role;
const RoleEnum = z.enum(
Protobuf.Config.Config_DeviceConfig_Role,
);
const RebroadcastModeEnum = z.enum(
Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
);
@IsBoolean()
serialEnabled: boolean;
export const DeviceValidationSchema = z.object({
role: RoleEnum,
serialEnabled: z.boolean(),
buttonGpio: z.coerce.number().int().min(0),
buzzerGpio: z.coerce.number().int().min(0),
rebroadcastMode: RebroadcastModeEnum,
nodeInfoBroadcastSecs: z.coerce.number().int().min(0),
doubleTapAsButtonPress: z.boolean(),
isManaged: z.boolean(),
disableTripleClick: z.boolean(),
ledHeartbeatDisabled: z.boolean(),
tzdef: z.string().max(65),
});
@IsBoolean()
debugLogEnabled: boolean;
@IsInt()
buttonGpio: number;
@IsInt()
buzzerGpio: number;
@IsEnum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode)
rebroadcastMode: Protobuf.Config.Config_DeviceConfig_RebroadcastMode;
@IsInt()
nodeInfoBroadcastSecs: number;
@IsBoolean()
doubleTapAsButtonPress: boolean;
@IsBoolean()
isManaged: boolean;
@IsBoolean()
disableTripleClick: boolean;
@IsBoolean()
ledHeartbeatDisabled: boolean;
@IsString()
tzdef: string;
}
export type DeviceValidation = z.infer<typeof DeviceValidationSchema>;
+31 -35
View File
@@ -1,39 +1,35 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsEnum, IsInt } from "class-validator";
export class DisplayValidation
implements Omit<Protobuf.Config.Config_DisplayConfig, keyof Message> {
@IsInt()
screenOnSecs: number;
const GpsCoordinateEnum = z.enum(
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
);
const DisplayUnitsEnum = z.enum(
Protobuf.Config.Config_DisplayConfig_DisplayUnits,
);
const OledTypeEnum = z.enum(
Protobuf.Config.Config_DisplayConfig_OledType,
);
const DisplayModeEnum = z.enum(
Protobuf.Config.Config_DisplayConfig_DisplayMode,
);
const CompassOrientationEnum = z.enum(
Protobuf.Config.Config_DisplayConfig_CompassOrientation,
);
@IsEnum(Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat)
gpsFormat: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat;
export const DisplayValidationSchema = z.object({
screenOnSecs: z.coerce.number().int().min(0),
gpsFormat: GpsCoordinateEnum,
autoScreenCarouselSecs: z.coerce.number().int().min(0),
compassNorthTop: z.boolean(),
flipScreen: z.boolean(),
units: DisplayUnitsEnum,
oled: OledTypeEnum,
displaymode: DisplayModeEnum,
headingBold: z.boolean(),
wakeOnTapOrMotion: z.boolean(),
compassOrientation: CompassOrientationEnum,
use12hClock: z.boolean(),
});
@IsInt()
autoScreenCarouselSecs: number;
@IsBoolean()
compassNorthTop: boolean;
@IsBoolean()
flipScreen: boolean;
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayUnits)
units: Protobuf.Config.Config_DisplayConfig_DisplayUnits;
@IsEnum(Protobuf.Config.Config_DisplayConfig_OledType)
oled: Protobuf.Config.Config_DisplayConfig_OledType;
@IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayMode)
displaymode: Protobuf.Config.Config_DisplayConfig_DisplayMode;
@IsBoolean()
headingBold: boolean;
@IsBoolean()
wakeOnTapOrMotion: boolean;
@IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation)
compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation;
}
export type DisplayValidation = z.infer<typeof DisplayValidationSchema>;
+27 -61
View File
@@ -1,65 +1,31 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
export class LoRaValidation
implements
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled"> {
@IsBoolean()
usePreset: boolean;
const ModemPresetEnum = z.enum(
Protobuf.Config.Config_LoRaConfig_ModemPreset,
);
const RegionCodeEnum = z.enum(
Protobuf.Config.Config_LoRaConfig_RegionCode,
);
@IsEnum(Protobuf.Config.Config_LoRaConfig_ModemPreset)
modemPreset: Protobuf.Config.Config_LoRaConfig_ModemPreset;
export const LoRaValidationSchema = z.object({
usePreset: z.boolean(),
modemPreset: ModemPresetEnum,
bandwidth: z.coerce.number().int(),
spreadFactor: z.coerce.number().int().max(12),
codingRate: z.coerce.number().int().min(0).max(10),
frequencyOffset: z.coerce.number().int(),
region: RegionCodeEnum,
hopLimit: z.coerce.number().int().min(0).max(7),
txEnabled: z.boolean(),
txPower: z.coerce.number().int().min(0),
channelNum: z.coerce.number().int(),
overrideDutyCycle: z.boolean(),
sx126xRxBoostedGain: z.boolean(),
overrideFrequency: z.coerce.number().int(),
ignoreIncoming: z.coerce.number().array(),
ignoreMqtt: z.boolean(),
configOkToMqtt: z.boolean(),
});
@IsInt()
bandwidth: number;
@IsInt()
// @Min(7)
@Max(12)
spreadFactor: number;
@IsInt()
@Min(0)
@Max(10)
codingRate: number;
@IsInt()
frequencyOffset: number;
@IsEnum(Protobuf.Config.Config_LoRaConfig_RegionCode)
region: Protobuf.Config.Config_LoRaConfig_RegionCode;
@IsInt()
@Min(1)
@Max(7)
hopLimit: number;
@IsBoolean()
txEnabled: boolean;
@IsInt()
@Min(0)
txPower: number;
@IsInt()
channelNum: number;
@IsBoolean()
overrideDutyCycle: boolean;
@IsBoolean()
sx126xRxBoostedGain: boolean;
@IsInt()
overrideFrequency: number;
@IsArray()
ignoreIncoming: number[];
@IsBoolean()
ignoreMqtt: boolean;
@IsBoolean()
configOkToMqtt: boolean;
}
export type LoRaValidation = z.infer<typeof LoRaValidationSchema>;
+12 -12
View File
@@ -1,30 +1,30 @@
import { z } from "zod";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
const AddressModeEnum = z.nativeEnum(
const AddressModeEnum = z.enum(
Protobuf.Config.Config_NetworkConfig_AddressMode,
);
const ProtocolFlagsEnum = z.nativeEnum(
const ProtocolFlagsEnum = z.enum(
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
);
export const NetworkValidationIpV4ConfigSchema = z.object({
ip: z.string().ip(),
gateway: z.string().ip(),
subnet: z.string().ip(),
dns: z.string().ip(),
ip: z.ipv4(),
gateway: z.ipv4(),
subnet: z.ipv4(),
dns: z.ipv4(),
});
export const NetworkValidationSchema = z.object({
wifiEnabled: z.boolean(),
wifiSsid: z.string().min(0).max(33).optional(),
wifiPsk: z.string().min(0).max(64).optional(),
ntpServer: z.string().min(2).max(30),
wifiSsid: z.string().max(33),
wifiPsk: z.string().max(64),
ntpServer: z.string().min(2).max(33),
ethEnabled: z.boolean(),
addressMode: AddressModeEnum,
ipv4Config: NetworkValidationIpV4ConfigSchema.optional(),
ipv4Config: NetworkValidationIpV4ConfigSchema,
enabledProtocols: ProtocolFlagsEnum,
rsyslogServer: z.string(),
rsyslogServer: z.string().max(33),
});
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
+18 -40
View File
@@ -1,44 +1,22 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsEnum, IsInt } from "class-validator";
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
const GpsModeEnum = z.enum(
Protobuf.Config.Config_PositionConfig_GpsMode,
);
export class PositionValidation implements
Omit<
Protobuf.Config.Config_PositionConfig,
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
> {
@IsInt()
positionBroadcastSecs: number;
export const PositionValidationSchema = z.object({
positionBroadcastSecs: z.coerce.number().int().min(0),
positionBroadcastSmartEnabled: z.boolean(),
fixedPosition: z.boolean(),
gpsUpdateInterval: z.coerce.number().int().min(0),
positionFlags: z.coerce.number().int().min(0),
rxGpio: z.coerce.number().int().min(0),
txGpio: z.coerce.number().int().min(0),
broadcastSmartMinimumDistance: z.coerce.number().int().min(0),
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0),
gpsEnGpio: z.coerce.number().int().min(0),
gpsMode: GpsModeEnum,
});
@IsBoolean()
positionBroadcastSmartEnabled: boolean;
@IsBoolean()
fixedPosition: boolean;
@IsInt()
gpsUpdateInterval: number;
@IsInt()
positionFlags: number;
@IsInt()
rxGpio: number;
@IsInt()
txGpio: number;
@IsInt()
broadcastSmartMinimumDistance: number;
@IsInt()
broadcastSmartMinimumIntervalSecs: number;
@IsInt()
gpsEnGpio: number;
@IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode)
gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode;
}
export type PositionValidation = z.infer<typeof PositionValidationSchema>;
+12 -33
View File
@@ -1,35 +1,14 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
import { z } from "zod/v4";
export class PowerValidation implements
Omit<
Protobuf.Config.Config_PowerConfig,
keyof Message | "powermonEnables"
> {
@IsBoolean()
isPowerSaving: boolean;
export const PowerValidationSchema = z.object({
isPowerSaving: z.boolean(),
onBatteryShutdownAfterSecs: z.coerce.number().int().min(0),
adcMultiplierOverride: z.coerce.number().min(0).max(4),
waitBluetoothSecs: z.coerce.number().int().min(0),
sdsSecs: z.coerce.number().int().min(0),
lsSecs: z.coerce.number().int().min(0),
minWakeSecs: z.coerce.number().int().min(0),
deviceBatteryInaAddress: z.coerce.number().int().min(0),
});
@IsInt()
onBatteryShutdownAfterSecs: number;
@IsNumber()
@Min(2)
@Max(4)
adcMultiplierOverride: number;
@IsInt()
waitBluetoothSecs: number;
@IsInt()
sdsSecs: number;
@IsInt()
lsSecs: number;
@IsInt()
minWakeSecs: number;
@IsInt()
deviceBatteryInaAddress: number;
}
export type PowerValidation = z.infer<typeof PowerValidationSchema>;
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { fromByteArray } from "base64-js";
import { ParsedSecuritySchema, RawSecuritySchema } from "./security.ts";
function makeBase64OfLength(len: number): string {
return fromByteArray(new Uint8Array(len));
}
describe("RawSecuritySchema", () => {
const validKey = makeBase64OfLength(32);
it("accepts valid security config", () => {
const result = RawSecuritySchema.safeParse({
isManaged: false,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [validKey, "", ""],
});
expect(result.success).toBe(true);
});
it("rejects if privateKey is invalid", () => {
const result = RawSecuritySchema.safeParse({
isManaged: false,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: "badkey",
publicKey: validKey,
adminKey: [validKey, "", ""],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.path.includes("privateKey")))
.toBe(true);
}
});
it("requires at least one adminKey if isManaged", () => {
const result = RawSecuritySchema.safeParse({
isManaged: true,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: ["", "", ""],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.message === "formValidation.adminKeyRequiredWhenManaged"
),
).toBe(true);
}
});
it("accepts if at least one adminKey is valid when isManaged", () => {
const result = RawSecuritySchema.safeParse({
isManaged: true,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [validKey, "", ""],
});
expect(result.success).toBe(true);
});
});
describe("ParsedSecuritySchema", () => {
const validKey = new Uint8Array(32);
it("accepts valid parsed security config", () => {
const result = ParsedSecuritySchema.safeParse({
isManaged: false,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [validKey, new Uint8Array(), new Uint8Array()],
});
console.log(result);
expect(result.success).toBe(true);
});
it("requires at least one adminKey if isManaged", () => {
const result = ParsedSecuritySchema.safeParse({
isManaged: true,
adminChannelEnabled: true,
debugLogApiEnabled: false,
serialEnabled: true,
privateKey: validKey,
publicKey: validKey,
adminKey: [new Uint8Array(), new Uint8Array(), new Uint8Array()],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(
result.error.issues.some((i) =>
i.message === "formValidation.adminKeyRequiredWhenManaged"
),
).toBe(true);
}
});
});
+42 -31
View File
@@ -1,36 +1,47 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsString } from "class-validator";
import { z, ZodType } from "zod/v4";
import { makePskHelpers } from "./../pskSchema.ts";
export class SecurityValidation implements
Omit<
Protobuf.Config.Config_SecurityConfig,
| keyof Message
| "adminKey"
| "privateKey"
| "publicKey"
> {
@IsBoolean()
adminChannelEnabled: boolean;
const {
stringSchema,
bytesSchema,
isValidKey,
} = makePskHelpers([32]); // 256-bit
@IsString()
adminKey: [string, string, string];
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
@IsBoolean()
bluetoothLoggingEnabled: boolean;
function makeSecuritySchema<KeyT>(
keyMaker: (optional: boolean) => ZodType<KeyT>,
) {
return z
.object({
isManaged: z.boolean(),
adminChannelEnabled: z.boolean(),
debugLogApiEnabled: z.boolean(),
serialEnabled: z.boolean(),
@IsBoolean()
debugLogApiEnabled: boolean;
@IsBoolean()
isManaged: boolean;
@IsString()
privateKey: string;
@IsString()
publicKey: string;
@IsBoolean()
serialEnabled: boolean;
privateKey: keyMaker(false),
publicKey: keyMaker(false),
adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]),
})
.check((ctx) => {
if (ctx.value.isManaged) {
const hasAdmin = ctx.value.adminKey.some(isValidKey);
if (!hasAdmin) {
for (const path of [["isManaged"], ["adminKey", 0]] as const) {
ctx.issues.push({
code: "custom",
message: isManagedRequiredMsg,
path: [...path],
input: ctx.value,
});
}
}
}
});
}
export const RawSecuritySchema = makeSecuritySchema(stringSchema);
export type RawSecurity = z.infer<typeof RawSecuritySchema>;
export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema);
export type ParsedSecurity = z.infer<typeof ParsedSecuritySchema>;
+11 -22
View File
@@ -1,24 +1,13 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt } from "class-validator";
import { z } from "zod/v4";
export class AmbientLightingValidation implements
Omit<
Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig,
keyof Message
> {
@IsBoolean()
ledState: boolean;
export const AmbientLightingValidationSchema = z.object({
ledState: z.boolean(),
current: z.coerce.number().int().min(0),
red: z.coerce.number().int().min(0).max(255),
green: z.coerce.number().int().min(0).max(255),
blue: z.coerce.number().int().min(0).max(255),
});
@IsInt()
current: number;
@IsInt()
red: number;
@IsInt()
green: number;
@IsInt()
blue: number;
}
export type AmbientLightingValidation = z.infer<
typeof AmbientLightingValidationSchema
>;
+14 -24
View File
@@ -1,28 +1,18 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsEnum, IsInt } from "class-validator";
export class AudioValidation
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_AudioConfig, keyof Message> {
@IsBoolean()
codec2Enabled: boolean;
const Audio_BaudEnum = z.enum(
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
);
@IsInt()
pttPin: number;
export const AudioValidationSchema = z.object({
codec2Enabled: z.boolean(),
pttPin: z.coerce.number().int().min(0),
bitrate: Audio_BaudEnum,
i2sWs: z.coerce.number().int().min(0),
i2sSd: z.coerce.number().int().min(0),
i2sDin: z.coerce.number().int().min(0),
i2sSck: z.coerce.number().int().min(0),
});
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud)
bitrate: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud;
@IsInt()
i2sWs: number;
@IsInt()
i2sSd: number;
@IsInt()
i2sDin: number;
@IsInt()
i2sSck: number;
}
export type AudioValidation = z.infer<typeof AudioValidationSchema>;
+20 -41
View File
@@ -1,45 +1,24 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsEnum, IsInt, Length } from "class-validator";
export class CannedMessageValidation implements
Omit<
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig,
keyof Message
> {
@IsBoolean()
rotary1Enabled: boolean;
const InputEventCharEnum = z.enum(
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
);
@IsInt()
inputbrokerPinA: number;
export const CannedMessageValidationSchema = z.object({
rotary1Enabled: z.boolean(),
inputbrokerPinA: z.coerce.number().int().min(0),
inputbrokerPinB: z.coerce.number().int().min(0),
inputbrokerPinPress: z.coerce.number().int().min(0),
inputbrokerEventCw: InputEventCharEnum,
inputbrokerEventCcw: InputEventCharEnum,
inputbrokerEventPress: InputEventCharEnum,
updown1Enabled: z.boolean(),
enabled: z.boolean(),
allowInputSource: z.string().max(30),
sendBell: z.boolean(),
});
@IsInt()
inputbrokerPinB: number;
@IsInt()
inputbrokerPinPress: number;
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar)
inputbrokerEventCw:
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar;
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar)
inputbrokerEventCcw:
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar;
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar)
inputbrokerEventPress:
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar;
@IsBoolean()
updown1Enabled: boolean;
@IsBoolean()
enabled: boolean;
@Length(2, 30)
allowInputSource: string;
@IsBoolean()
sendBell: boolean;
}
export type CannedMessageValidation = z.infer<
typeof CannedMessageValidationSchema
>;
+18 -30
View File
@@ -1,33 +1,21 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt, Length } from "class-validator";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
export class DetectionSensorValidation implements
Omit<
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig,
keyof Message
> {
@IsBoolean()
enabled: boolean;
const detectionTriggerTypeEnum = z.enum(
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
);
@IsInt()
minimumBroadcastSecs: number;
export const DetectionSensorValidationSchema = z.object({
enabled: z.boolean(),
minimumBroadcastSecs: z.coerce.number().int().min(0),
stateBroadcastSecs: z.coerce.number().int().min(0),
sendBell: z.boolean(),
name: z.string().min(0).max(20),
monitorPin: z.coerce.number().int().min(0),
detectionTriggerType: detectionTriggerTypeEnum,
usePullup: z.boolean(),
});
@IsInt()
stateBroadcastSecs: number;
@IsBoolean()
sendBell: boolean;
@Length(0, 20)
name: string;
@IsInt()
monitorPin: number;
@IsBoolean()
detectionTriggeredHigh: boolean;
@IsBoolean()
usePullup: boolean;
}
export type DetectionSensorValidation = z.infer<
typeof DetectionSensorValidationSchema
>;
@@ -1,54 +1,23 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt } from "class-validator";
import { z } from "zod/v4";
export class ExternalNotificationValidation implements
Omit<
Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig,
keyof Message
> {
@IsBoolean()
enabled: boolean;
export const ExternalNotificationValidationSchema = z.object({
enabled: z.boolean(),
outputMs: z.coerce.number().int().min(0),
output: z.coerce.number().int().min(0),
outputVibra: z.coerce.number().int().min(0),
outputBuzzer: z.coerce.number().int().min(0),
active: z.boolean(),
alertMessage: z.boolean(),
alertMessageVibra: z.boolean(),
alertMessageBuzzer: z.boolean(),
alertBell: z.boolean(),
alertBellVibra: z.boolean(),
alertBellBuzzer: z.boolean(),
usePwm: z.boolean(),
nagTimeout: z.coerce.number().int().min(0),
useI2sAsBuzzer: z.boolean(),
});
@IsInt()
outputMs: number;
@IsInt()
output: number;
@IsInt()
outputVibra: number;
@IsInt()
outputBuzzer: number;
@IsBoolean()
active: boolean;
@IsBoolean()
alertMessage: boolean;
@IsBoolean()
alertMessageVibra: boolean;
@IsBoolean()
alertMessageBuzzer: boolean;
@IsBoolean()
alertBell: boolean;
@IsBoolean()
alertBellVibra: boolean;
@IsBoolean()
alertBellBuzzer: boolean;
@IsBoolean()
usePwm: boolean;
@IsInt()
nagTimeout: number;
@IsBoolean()
useI2sAsBuzzer: boolean;
}
export type ExternalNotificationValidation = z.infer<
typeof ExternalNotificationValidationSchema
>;
+19 -56
View File
@@ -1,59 +1,22 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
Length,
} from "class-validator";
import { z } from "zod/v4";
export class MqttValidation implements
Omit<
Protobuf.ModuleConfig.ModuleConfig_MQTTConfig,
keyof Message | "mapReportSettings"
> {
@IsBoolean()
enabled: boolean;
export const MqttValidationMapReportSettingsSchema = z.object({
publishIntervalSecs: z.number().optional(),
positionPrecision: z.number().optional(),
});
@Length(0, 30)
address: string;
export const MqttValidationSchema = z.object({
enabled: z.boolean(),
address: z.string().min(0).max(30),
username: z.string().min(0).max(30),
password: z.string().min(0).max(30),
encryptionEnabled: z.boolean(),
jsonEnabled: z.boolean(),
tlsEnabled: z.boolean(),
root: z.string(),
proxyToClientEnabled: z.boolean(),
mapReportingEnabled: z.boolean(),
mapReportSettings: MqttValidationMapReportSettingsSchema,
});
@Length(0, 30)
username: string;
@Length(0, 30)
password: string;
@IsBoolean()
encryptionEnabled: boolean;
@IsBoolean()
jsonEnabled: boolean;
@IsBoolean()
tlsEnabled: boolean;
@IsString()
root: string;
@IsBoolean()
proxyToClientEnabled: boolean;
@IsBoolean()
mapReportingEnabled: boolean;
mapReportSettings: MqttValidationMapReportSettings;
}
export class MqttValidationMapReportSettings
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> {
@IsNumber()
@IsOptional()
publishIntervalSecs: number;
@IsNumber()
@IsOptional()
positionPrecision: number;
}
export type MqttValidation = z.infer<typeof MqttValidationSchema>;
+8 -11
View File
@@ -1,13 +1,10 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt } from "class-validator";
import { z } from "zod/v4";
export class NeighborInfoValidation
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig, keyof Message> {
@IsBoolean()
enabled: boolean;
export const NeighborInfoValidationSchema = z.object({
enabled: z.boolean(),
updateInterval: z.coerce.number().int().min(0),
});
@IsInt()
updateInterval: number;
}
export type NeighborInfoValidation = z.infer<
typeof NeighborInfoValidationSchema
>;
+8 -17
View File
@@ -1,19 +1,10 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt } from "class-validator";
import { z } from "zod/v4";
export class PaxcounterValidation
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig, keyof Message> {
@IsBoolean()
enabled: boolean;
export const PaxcounterValidationSchema = z.object({
enabled: z.boolean(),
paxcounterUpdateInterval: z.coerce.number().int().min(0),
bleThreshold: z.coerce.number().int(),
wifiThreshold: z.coerce.number().int(),
});
@IsInt()
paxcounterUpdateInterval: number;
@IsInt()
bleThreshold: number;
@IsInt()
wifiThreshold: number;
}
export type PaxcounterValidation = z.infer<typeof PaxcounterValidationSchema>;
+7 -14
View File
@@ -1,16 +1,9 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt } from "class-validator";
import { z } from "zod/v4";
export class RangeTestValidation
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig, keyof Message> {
@IsBoolean()
enabled: boolean;
export const RangeTestValidationSchema = z.object({
enabled: z.boolean(),
sender: z.coerce.number().int().min(0),
save: z.boolean(),
});
@IsInt()
sender: number;
@IsBoolean()
save: boolean;
}
export type RangeTestValidation = z.infer<typeof RangeTestValidationSchema>;
+18 -27
View File
@@ -1,31 +1,22 @@
import type { Message } from "@bufbuild/protobuf";
import { z } from "zod/v4";
import { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsEnum, IsInt } from "class-validator";
export class SerialValidation
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_SerialConfig, keyof Message> {
@IsBoolean()
enabled: boolean;
const Serial_BaudEnum = z.enum(
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
);
const Serial_ModeEnum = z.enum(
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
);
@IsBoolean()
echo: boolean;
export const SerialValidationSchema = z.object({
enabled: z.boolean(),
echo: z.boolean(),
rxd: z.coerce.number().int().min(0),
txd: z.coerce.number().int().min(0),
baud: Serial_BaudEnum,
timeout: z.coerce.number().int().min(0),
mode: Serial_ModeEnum,
overrideConsoleSerialPort: z.boolean(),
});
@IsInt()
rxd: number;
@IsInt()
txd: number;
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud)
baud: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud;
@IsInt()
timeout: number;
@IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode)
mode: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode;
@IsBoolean()
overrideConsoleSerialPort: boolean;
}
export type SerialValidation = z.infer<typeof SerialValidationSchema>;
+11 -22
View File
@@ -1,24 +1,13 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt } from "class-validator";
import { z } from "zod/v4";
export class StoreForwardValidation implements
Omit<
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
keyof Message | "isServer"
> {
@IsBoolean()
enabled: boolean;
export const StoreForwardValidationSchema = z.object({
enabled: z.boolean(),
heartbeat: z.boolean(),
records: z.coerce.number().int().min(0),
historyReturnMax: z.coerce.number().int().min(0),
historyReturnWindow: z.coerce.number().int().min(0),
});
@IsBoolean()
heartbeat: boolean;
@IsInt()
records: number;
@IsInt()
historyReturnMax: number;
@IsInt()
historyReturnWindow: number;
}
export type StoreForwardValidation = z.infer<
typeof StoreForwardValidationSchema
>;
+16 -35
View File
@@ -1,37 +1,18 @@
import type { Message } from "@bufbuild/protobuf";
import type { Protobuf } from "@meshtastic/core";
import { IsBoolean, IsInt } from "class-validator";
import { z } from "zod/v4";
export class TelemetryValidation
implements
Omit<Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig, keyof Message> {
@IsInt()
deviceUpdateInterval: number;
export const TelemetryValidationSchema = z.object({
deviceUpdateInterval: z.coerce.number().int().min(0),
environmentUpdateInterval: z.coerce.number().int().min(0),
environmentMeasurementEnabled: z.boolean(),
environmentScreenEnabled: z.boolean(),
environmentDisplayFahrenheit: z.boolean(),
airQualityEnabled: z.boolean(),
airQualityInterval: z.coerce.number().int().min(0),
powerMeasurementEnabled: z.boolean(),
powerUpdateInterval: z.coerce.number().int().min(0),
powerScreenEnabled: z.boolean(),
});
@IsInt()
environmentUpdateInterval: number;
@IsBoolean()
environmentMeasurementEnabled: boolean;
@IsBoolean()
environmentScreenEnabled: boolean;
@IsBoolean()
environmentDisplayFahrenheit: boolean;
@IsBoolean()
airQualityEnabled: boolean;
@IsInt()
airQualityInterval: number;
@IsBoolean()
powerMeasurementEnabled: boolean;
@IsInt()
powerUpdateInterval: number;
@IsBoolean()
powerScreenEnabled: boolean;
}
export type TelemetryValidation = z.infer<
typeof TelemetryValidationSchema
>;
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import { makePskHelpers } from "./pskSchema.ts";
import { fromByteArray } from "base64-js";
function makeBase64OfLength(len: number): string {
return fromByteArray(new Uint8Array(len));
}
describe("stringSchema", () => {
it("accepts valid base64 string of allowed length", () => {
const { stringSchema } = makePskHelpers([16]);
const valid = makeBase64OfLength(16);
expect(() => stringSchema().parse(valid)).not.toThrow();
});
it("rejects base64 string of disallowed length", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const invalid = makeBase64OfLength(8);
const result = stringSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
it("rejects invalid base64 string", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const result = stringSchema().safeParse("not_base64!");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.format);
}
});
it("rejects empty string if not optional and 0 not allowed", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const result = stringSchema().safeParse("");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.required);
}
});
it("accepts empty string if 0 is allowed", () => {
const { stringSchema } = makePskHelpers([0]);
const result = stringSchema().safeParse("");
expect(result.success).toBe(true);
});
it("accepts empty string if optional=true", () => {
const { stringSchema } = makePskHelpers([16]);
const result = stringSchema(true).safeParse("");
expect(result.success).toBe(true);
});
it("accepts all allowed lengths", () => {
const { stringSchema } = makePskHelpers([8, 16, 32]);
for (const len of [8, 16, 32]) {
const valid = makeBase64OfLength(len);
const result = stringSchema().safeParse(valid);
expect(result.success).toBe(true);
}
});
it("accepts valid base64 string as optional when optional=true", () => {
const { stringSchema } = makePskHelpers([16]);
const valid = makeBase64OfLength(16);
const result = stringSchema(true).safeParse(valid);
expect(result.success).toBe(true);
});
it("rejects base64 string with correct length but extra padding", () => {
const { stringSchema, msgs } = makePskHelpers([16]);
const valid = makeBase64OfLength(16) + "==";
const result = stringSchema().safeParse(valid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.format);
}
});
it("accepts empty string if allowedByteLengths includes 0 and optional=false", () => {
const { stringSchema } = makePskHelpers([0, 16]);
const result = stringSchema(false).safeParse("");
expect(result.success).toBe(true);
});
it("rejects base64 string with valid format but not in allowedByteLengths", () => {
const { stringSchema, msgs } = makePskHelpers([8, 32]);
const invalid = makeBase64OfLength(16);
const result = stringSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
describe("bytesSchema", () => {
it("accepts valid byte array of allowed length", () => {
const { bytesSchema } = makePskHelpers([16]);
const valid = new Uint8Array(16);
expect(() => bytesSchema().parse(valid)).not.toThrow();
});
it("rejects byte array of disallowed length", () => {
const { bytesSchema, msgs } = makePskHelpers([16]);
const invalid = new Uint8Array(8);
const result = bytesSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
it("rejects non-Uint8Array input", () => {
const { bytesSchema } = makePskHelpers([16]);
const result = bytesSchema().safeParse([1, 2, 3]);
expect(result.success).toBe(false);
});
it("rejects empty array if not optional and 0 not allowed", () => {
const { bytesSchema, msgs } = makePskHelpers([16]);
const result = bytesSchema().safeParse(new Uint8Array(0));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.required);
}
});
it("accepts empty array if 0 is allowed", () => {
const { bytesSchema } = makePskHelpers([0]);
const result = bytesSchema().safeParse(new Uint8Array(0));
expect(result.success).toBe(true);
});
it("accepts empty array if optional=true", () => {
const { bytesSchema } = makePskHelpers([16]);
const result = bytesSchema(true).safeParse(new Uint8Array(0));
expect(result.success).toBe(true);
});
it("accepts all allowed lengths", () => {
const { bytesSchema } = makePskHelpers([8, 16, 32]);
for (const len of [8, 16, 32]) {
const valid = new Uint8Array(len);
const result = bytesSchema().safeParse(valid);
expect(result.success).toBe(true);
}
});
it("accepts valid byte array as optional when optional=true", () => {
const { bytesSchema } = makePskHelpers([16]);
const valid = new Uint8Array(16);
const result = bytesSchema(true).safeParse(valid);
expect(result.success).toBe(true);
});
it("accepts empty array if allowedByteLengths includes 0 and optional=false", () => {
const { bytesSchema } = makePskHelpers([0, 16]);
const result = bytesSchema(false).safeParse(new Uint8Array(0));
expect(result.success).toBe(true);
});
it("rejects byte array with valid format but not in allowedByteLengths", () => {
const { bytesSchema, msgs } = makePskHelpers([8, 32]);
const invalid = new Uint8Array(16);
const result = bytesSchema().safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(msgs.length);
}
});
});
});
+70
View File
@@ -0,0 +1,70 @@
import { z, ZodType } from "zod/v4";
import { toByteArray } from "base64-js";
export function makePskHelpers(
allowedByteLengths: readonly number[],
) {
const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | ");
const msgs = {
format: "formValidation.invalidFormat.key",
required: "formValidation.required.key",
length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`,
} as const;
function tryParse(str: string): Uint8Array | null {
try {
return toByteArray(str);
} catch {
return null;
}
}
function isValidString(str: string): boolean {
const arr = tryParse(str);
return arr !== null &&
allowedByteLengths.includes(arr.byteLength);
}
function isValidKey(v: unknown): boolean {
if (typeof v === "string") return isValidString(v);
if (v instanceof Uint8Array) {
return allowedByteLengths.includes(v.byteLength);
}
return false;
}
const stringSchema = (optional = false) =>
z.string()
.refine((s) =>
optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), {
message: msgs.required,
})
.refine((s) =>
s === "" || tryParse(s) !== null, { message: msgs.format })
.refine((s) =>
s === "" || isValidString(s), {
message: msgs.length,
params: { bits: bitsLabel },
});
const bytesSchema = (optional = false): ZodType<Uint8Array> =>
z.instanceof(Uint8Array)
.refine(
(arr) =>
optional || arr.byteLength !== 0 || allowedByteLengths.includes(0),
{ message: msgs.required },
)
.refine(
(arr) => optional || allowedByteLengths.includes(arr.byteLength),
{ message: msgs.length, params: { bits: bitsLabel } },
);
return {
allowedByteLengths,
msgs,
tryParseStringKey: tryParse,
isValidKey,
stringSchema,
bytesSchema,
};
}
-22
View File
@@ -1,22 +0,0 @@
import { IsArray, IsBoolean, IsNumber, IsString, IsUrl } from "class-validator";
import type { RasterSource } from "@core/stores/appStore.ts";
export class MapValidation {
@IsArray()
rasterSources: MapValidation_RasterSources[];
}
export class MapValidation_RasterSources implements RasterSource {
@IsBoolean()
enabled: boolean;
@IsString()
title: string;
@IsUrl()
tiles: string;
@IsNumber()
tileSize: number;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { ZodError, ZodSchema } from "zod";
import { ZodError, ZodType } from "zod/v4";
export function validateSchema<T>(
schema: ZodSchema<T>,
schema: ZodType<T>,
data: unknown,
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
const result = schema.safeParse(data);