feat(protobufs): sync to firmware-current and consume workspace package (#1097)
* feat(protobufs): sync to firmware-current and consume workspace package Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*). Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true. * build(protobufs): vendor generated bindings for workspace consumers The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen * fix(protobufs): clean script removes the actual generated output dir buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback. * refactor: move web app packages/web -> apps/web Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green. * feat: config fields, module pages, key verification, telemetry capture Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green. * chore: drop #1062 (unsaved-change-detection) to match upstream revert #1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green. * fix(nodes): clean up SNR display in node table and map popup SNR is a ratio measured in dB, not dBm (which is absolute power); the node table and map popup both mislabeled it and crammed three values together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same heuristic ((snr+10)*5) shown twice — once clamped, once not. Render SNR in dB rounded to one decimal, color-coded by a 0-100% signal-quality heuristic (green/yellow/red), with the quality percentage as a muted secondary. Drop the redundant raw value. Adds unit.db; this matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeChannelSchema } from "./channel.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { validateMaxByteLength } from "@core/utils/string.ts";
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
import { makePskHelpers } from "./pskSchema.ts";
|
||||
|
||||
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role);
|
||||
|
||||
const moduleSettingsSchema = z.object({
|
||||
positionPrecision: z.union([
|
||||
z.literal(0),
|
||||
z.coerce.number().int().min(10).max(19),
|
||||
z.literal(32),
|
||||
]),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const ChannelValidationSchema = makeChannelSchema(0); // generate a schema that doesn't validate PSK length, just structure, for type purposes
|
||||
export type ChannelValidation = z.infer<typeof ChannelValidationSchema>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const PairingModeEnum = z.enum(Protobuf.Config.Config_BluetoothConfig_PairingMode);
|
||||
|
||||
export const BluetoothValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
mode: PairingModeEnum,
|
||||
fixedPin: z.coerce.number().int().min(100000).max(999999),
|
||||
});
|
||||
|
||||
export type BluetoothValidation = z.infer<typeof BluetoothValidationSchema>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const RoleEnum = z.enum(Protobuf.Config.Config_DeviceConfig_Role);
|
||||
const RebroadcastModeEnum = z.enum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode);
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
export type DeviceValidation = z.infer<typeof DeviceValidationSchema>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const GpsCoordinateEnum = z.enum(
|
||||
Protobuf.Config.Config_DisplayConfig_DeprecatedGpsCoordinateFormat,
|
||||
);
|
||||
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);
|
||||
|
||||
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(),
|
||||
enableMessageBubbles: z.boolean(),
|
||||
});
|
||||
|
||||
export type DisplayValidation = z.infer<typeof DisplayValidationSchema>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("LoRa modem presets", () => {
|
||||
it("includes LONG_TURBO modem preset", () => {
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
Protobuf.Config.Config_LoRaConfig_ModemPreset,
|
||||
"LONG_TURBO",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const ModemPresetEnum = z.enum(Protobuf.Config.Config_LoRaConfig_ModemPreset);
|
||||
const RegionCodeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_RegionCode);
|
||||
const FemLnaModeEnum = z.enum(Protobuf.Config.Config_LoRaConfig_FEM_LNA_Mode);
|
||||
|
||||
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().refine((val) => val === 0 || (val >= 410 && val <= 930), {
|
||||
message: "formValidation.invalidOverrideFreq.number",
|
||||
}),
|
||||
ignoreIncoming: z.coerce.number().array(),
|
||||
ignoreMqtt: z.boolean(),
|
||||
configOkToMqtt: z.boolean(),
|
||||
femLnaMode: FemLnaModeEnum,
|
||||
serialHalOnly: z.boolean(),
|
||||
});
|
||||
|
||||
export type LoRaValidation = z.infer<typeof LoRaValidationSchema>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const AddressModeEnum = z.enum(Protobuf.Config.Config_NetworkConfig_AddressMode);
|
||||
const ProtocolFlagsEnum = z.enum(Protobuf.Config.Config_NetworkConfig_ProtocolFlags);
|
||||
|
||||
export const NetworkValidationIpV4ConfigSchema = z.object({
|
||||
ip: z.ipv4(),
|
||||
gateway: z.ipv4(),
|
||||
subnet: z.ipv4(),
|
||||
dns: z.ipv4(),
|
||||
});
|
||||
|
||||
export const NetworkValidationSchema = z.object({
|
||||
wifiEnabled: z.boolean(),
|
||||
wifiSsid: z.string().max(33),
|
||||
wifiPsk: z.string().max(64),
|
||||
ntpServer: z.string().min(0).max(33),
|
||||
ethEnabled: z.boolean(),
|
||||
addressMode: AddressModeEnum,
|
||||
ipv4Config: NetworkValidationIpV4ConfigSchema,
|
||||
enabledProtocols: ProtocolFlagsEnum,
|
||||
rsyslogServer: z.string().max(33),
|
||||
});
|
||||
|
||||
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const GpsModeEnum = z.enum(Protobuf.Config.Config_PositionConfig_GpsMode);
|
||||
|
||||
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,
|
||||
latitude: z.coerce.number().min(-90).max(90).optional(),
|
||||
longitude: z.coerce.number().min(-180).max(180).optional(),
|
||||
altitude: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export type PositionValidation = z.infer<typeof PositionValidationSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
export type PowerValidation = z.infer<typeof PowerValidationSchema>;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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.required.managed")).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()],
|
||||
});
|
||||
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.required.managed")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type ZodType, z } from "zod/v4";
|
||||
import { makePskHelpers } from "./../pskSchema.ts";
|
||||
|
||||
const { stringSchema, bytesSchema, isValidKey } = makePskHelpers([32]); // 256-bit
|
||||
|
||||
const isManagedRequiredMsg = "formValidation.required.managed";
|
||||
|
||||
function makeSecuritySchema<KeyT>(keyMaker: (optional: boolean) => ZodType<KeyT>) {
|
||||
return z
|
||||
.object({
|
||||
isManaged: z.boolean(),
|
||||
adminChannelEnabled: z.boolean(),
|
||||
debugLogApiEnabled: z.boolean(),
|
||||
serialEnabled: z.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>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { t } from "i18next";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UserValidationSchema = z.object({
|
||||
longName: z
|
||||
.string()
|
||||
.min(1, t("user.longName.validation.min"))
|
||||
.max(40, t("user.longName.validation.max")),
|
||||
shortName: z
|
||||
.string()
|
||||
.min(2, t("user.shortName.validation.min"))
|
||||
.max(4, t("user.shortName.validation.max")),
|
||||
isUnmessageable: z.boolean().default(false),
|
||||
isLicensed: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type UserValidation = z.infer<typeof UserValidationSchema>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
export type AmbientLightingValidation = z.infer<typeof AmbientLightingValidationSchema>;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const Audio_BaudEnum = z.enum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud);
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
export type AudioValidation = z.infer<typeof AudioValidationSchema>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const InputEventCharEnum = z.enum(
|
||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||
);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type CannedMessageValidation = z.infer<typeof CannedMessageValidationSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const detectionTriggerTypeEnum = z.enum(
|
||||
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||
);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type DetectionSensorValidation = z.infer<typeof DetectionSensorValidationSchema>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type ExternalNotificationValidation = z.infer<typeof ExternalNotificationValidationSchema>;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const MqttValidationMapReportSettingsSchema = z.object({
|
||||
publishIntervalSecs: z.coerce.number().int(),
|
||||
positionPrecision: z.coerce.number().int(),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
export type MqttValidation = z.infer<typeof MqttValidationSchema>;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const NeighborInfoValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
updateInterval: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
export type NeighborInfoValidation = z.infer<typeof NeighborInfoValidationSchema>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type PaxcounterValidation = z.infer<typeof PaxcounterValidationSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const RangeTestValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
sender: z.coerce.number().int().min(0),
|
||||
save: z.boolean(),
|
||||
});
|
||||
|
||||
export type RangeTestValidation = z.infer<typeof RangeTestValidationSchema>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const RemoteHardwareValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
allowUndefinedPinAccess: z.boolean(),
|
||||
// Preserved untouched: the form does not yet edit the available-pins list,
|
||||
// so round-trip it to avoid clearing pins configured elsewhere.
|
||||
availablePins: z.array(z.any()),
|
||||
});
|
||||
|
||||
export type RemoteHardwareValidation = z.infer<typeof RemoteHardwareValidationSchema>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const Serial_BaudEnum = z.enum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud);
|
||||
const Serial_ModeEnum = z.enum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
export type SerialValidation = z.infer<typeof SerialValidationSchema>;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const StatusMessageValidationSchema = z.object({
|
||||
nodeStatus: z.string().max(200),
|
||||
});
|
||||
|
||||
export type StatusMessageValidation = z.infer<typeof StatusMessageValidationSchema>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
export type StoreForwardValidation = z.infer<typeof StoreForwardValidationSchema>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Protobuf } from "@meshtastic/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const TeamEnum = z.enum(Protobuf.ATAK.Team);
|
||||
const MemberRoleEnum = z.enum(Protobuf.ATAK.MemberRole);
|
||||
|
||||
export const TakValidationSchema = z.object({
|
||||
team: TeamEnum,
|
||||
role: MemberRoleEnum,
|
||||
});
|
||||
|
||||
export type TakValidation = z.infer<typeof TakValidationSchema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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(),
|
||||
airQualityScreenEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type TelemetryValidation = z.infer<typeof TelemetryValidationSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const TrafficManagementValidationSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
positionDedupEnabled: z.boolean(),
|
||||
positionPrecisionBits: z.coerce.number().int().min(0).max(32),
|
||||
positionMinIntervalSecs: z.coerce.number().int().min(0),
|
||||
nodeinfoDirectResponse: z.boolean(),
|
||||
nodeinfoDirectResponseMaxHops: z.coerce.number().int().min(0).max(7),
|
||||
rateLimitEnabled: z.boolean(),
|
||||
rateLimitWindowSecs: z.coerce.number().int().min(0),
|
||||
rateLimitMaxPackets: z.coerce.number().int().min(0),
|
||||
dropUnknownEnabled: z.boolean(),
|
||||
unknownPacketThreshold: z.coerce.number().int().min(0),
|
||||
exhaustHopTelemetry: z.boolean(),
|
||||
exhaustHopPosition: z.boolean(),
|
||||
routerPreserveHops: z.boolean(),
|
||||
});
|
||||
|
||||
export type TrafficManagementValidation = z.infer<typeof TrafficManagementValidationSchema>;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { fromByteArray } from "base64-js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makePskHelpers } from "./pskSchema.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { toByteArray } from "base64-js";
|
||||
import { type ZodType, z } from "zod/v4";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ZodError, ZodType } from "zod/v4";
|
||||
|
||||
export function validateSchema<T>(
|
||||
schema: ZodType<T>,
|
||||
data: unknown,
|
||||
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
return { success: false, errors: result.error.issues };
|
||||
}
|
||||
Reference in New Issue
Block a user