initial commit
This commit is contained in:
592
fluxer_api/src/Schema.ts
Normal file
592
fluxer_api/src/Schema.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import validator from 'validator';
|
||||
import {z} from 'zod';
|
||||
import {Config} from '~/Config';
|
||||
|
||||
const RTL_OVERRIDE_REGEX = /\u202E/g;
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
|
||||
const FORM_FEED_REGEX = /\u000C/g;
|
||||
const EMAIL_LOCAL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
|
||||
const DISCRIMINATOR_REGEX = /^\d{1,4}$/;
|
||||
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
|
||||
const VANITY_URL_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
|
||||
const WHITESPACE_REGEX = /\s+/g;
|
||||
const MULTIPLE_HYPHENS_REGEX = /-{2,}/g;
|
||||
const NON_FILENAME_CHARS_REGEX = /[^\p{L}\p{N}\p{M}_.-]/gu;
|
||||
|
||||
const PROTOCOLS = ['http', 'https'];
|
||||
const TRUE_VALUES = ['true', 'True', '1'];
|
||||
|
||||
const DISALLOWED_CHARS = new Set(' !"#$%&\'()*+,/:;<=>?@[\\]^`{|}~');
|
||||
|
||||
const URL_VALIDATOR_OPTIONS = {
|
||||
require_protocol: true,
|
||||
require_host: true,
|
||||
disallow_auth: true,
|
||||
allow_trailing_dot: false,
|
||||
allow_protocol_relative_urls: false,
|
||||
allow_fragments: false,
|
||||
validate_length: true,
|
||||
protocols: ['http', 'https'] as Array<string>,
|
||||
} as const;
|
||||
|
||||
export const PHONE_E164_REGEX = /^\+[1-9]\d{1,14}$/;
|
||||
const PHONE_E164_ERROR_MESSAGE = 'Phone number must be in E.164 format (e.g., +1234567890)';
|
||||
|
||||
const normalizeString = (value: string): string => {
|
||||
return value.replace(RTL_OVERRIDE_REGEX, '').replace(FORM_FEED_REGEX, '').trim();
|
||||
};
|
||||
|
||||
const isValidBase64 = (value: string): boolean => {
|
||||
if (value.length % 4 !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let padding = 0;
|
||||
for (let i = value.length - 1; i >= 0; i--) {
|
||||
if (value.charCodeAt(i) !== 61) {
|
||||
break;
|
||||
}
|
||||
padding++;
|
||||
}
|
||||
|
||||
if (padding > 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boundary = value.length - padding;
|
||||
|
||||
for (let i = 0; i < boundary; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
const isUpper = code >= 65 && code <= 90;
|
||||
const isLower = code >= 97 && code <= 122;
|
||||
const isDigit = code >= 48 && code <= 57;
|
||||
const isPlus = code === 43;
|
||||
const isSlash = code === 47;
|
||||
|
||||
if (!(isUpper || isLower || isDigit || isPlus || isSlash)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = boundary; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) !== 61) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
if (decoded.length === 0) {
|
||||
return value === '';
|
||||
}
|
||||
|
||||
return decoded.toString('base64') === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const C0_C1_CTRL_REGEX =
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
|
||||
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g;
|
||||
|
||||
const SURROGATES_REGEX = /[\uD800-\uDFFF]/g;
|
||||
|
||||
const JOIN_CONTROLS_REGEX = /(?:\u200C|\u200D)/g;
|
||||
|
||||
const WJ_BOM_REGEX = /(?:\u2060|\uFEFF)/g;
|
||||
|
||||
const BIDI_CTRL_REGEX = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
||||
|
||||
const MISC_INVISIBLES_REGEX = /[\u00AD\u180E\uFFFE\uFFFF]/g;
|
||||
|
||||
const TAG_CHARS_REGEX = /[\u{E0000}-\u{E007F}]/gu;
|
||||
|
||||
const VARIATION_SELECTORS_BASIC = /[\uFE00-\uFE0F]/g;
|
||||
const VARIATION_SELECTORS_IDEOGRAPHIC = /[\u{E0100}-\u{E01EF}]/gu;
|
||||
|
||||
const UNICODE_SPACES_REGEX = /[\s\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g;
|
||||
|
||||
const normalizeWhitespace = (s: string): string => s.replace(UNICODE_SPACES_REGEX, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const stripInvisibles = (s: string): string =>
|
||||
s
|
||||
.replace(C0_C1_CTRL_REGEX, '')
|
||||
.replace(JOIN_CONTROLS_REGEX, '')
|
||||
.replace(WJ_BOM_REGEX, '')
|
||||
.replace(BIDI_CTRL_REGEX, '')
|
||||
.replace(MISC_INVISIBLES_REGEX, '')
|
||||
.replace(TAG_CHARS_REGEX, '');
|
||||
|
||||
const stripVariationSelectors = (s: string): string =>
|
||||
s.replace(VARIATION_SELECTORS_BASIC, '').replace(VARIATION_SELECTORS_IDEOGRAPHIC, '');
|
||||
|
||||
const sanitizeUsername = (value: string): string => {
|
||||
let s = normalizeString(value);
|
||||
s = s.replace(SURROGATES_REGEX, '');
|
||||
s = stripInvisibles(s);
|
||||
s = stripVariationSelectors(s);
|
||||
s = normalizeWhitespace(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
const sanitizeChannelName = (value: string): string => {
|
||||
let s = normalizeString(value);
|
||||
s = stripInvisibles(s);
|
||||
s = normalizeWhitespace(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
export const EmailType = z
|
||||
.email('Invalid email format')
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 1 && value.length <= 254, 'Email length must be between 1 and 254 characters')
|
||||
.refine((value) => {
|
||||
const atIndex = value.indexOf('@');
|
||||
if (atIndex === -1) return false;
|
||||
const local = value.slice(0, atIndex);
|
||||
return EMAIL_LOCAL_REGEX.test(local);
|
||||
}, 'Invalid email local part');
|
||||
|
||||
export const DiscriminatorType = z
|
||||
.string()
|
||||
.regex(DISCRIMINATOR_REGEX, 'Discriminator must be 1-4 digits')
|
||||
.superRefine((value, ctx) => {
|
||||
const num = Number.parseInt(value, 10);
|
||||
if (num < 0 || num > 9999) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Discriminator must be between 0 and 9999',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => Number.parseInt(value, 10));
|
||||
|
||||
const FLUXER_TAG_REGEX = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
export const UsernameType = z
|
||||
.string()
|
||||
.transform((value) => value.trim())
|
||||
.refine((value) => value.length >= 1 && value.length <= 32, 'Username length must be between 1 and 32 characters')
|
||||
.refine(
|
||||
(value) => FLUXER_TAG_REGEX.test(value),
|
||||
'Username can only contain Latin letters (a-z, A-Z), numbers (0-9), and underscores (_)',
|
||||
)
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return lowerValue !== 'everyone' && lowerValue !== 'here';
|
||||
}, 'Username cannot be "everyone" or "here"')
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return !lowerValue.includes('fluxer') && !lowerValue.includes('system message');
|
||||
}, 'Username cannot contain "fluxer" or "system message"');
|
||||
|
||||
export const GlobalNameType = z
|
||||
.string()
|
||||
.transform(sanitizeUsername)
|
||||
.refine((value) => value.length >= 1 && value.length <= 32, 'Global name length must be between 1 and 32 characters')
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return lowerValue !== 'everyone' && lowerValue !== 'here';
|
||||
}, 'Global name cannot be "everyone" or "here"')
|
||||
.refine((value) => {
|
||||
const lowerValue = value.toLowerCase();
|
||||
return !lowerValue.includes('system message');
|
||||
}, 'Global name cannot contain "system message"');
|
||||
|
||||
export const URLType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 1 && value.length <= 2048, 'URL length must be between 1 and 2048 characters')
|
||||
.refine((value) => {
|
||||
if (!value.startsWith('http://') && !value.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return PROTOCOLS.includes(url.protocol.slice(0, -1));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Invalid URL format')
|
||||
.refine(
|
||||
(value) =>
|
||||
validator.isURL(value, {
|
||||
...URL_VALIDATOR_OPTIONS,
|
||||
require_tld: Config.nodeEnv !== 'development',
|
||||
}),
|
||||
'Invalid URL format',
|
||||
);
|
||||
|
||||
export const AttachmentURLType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 1 && value.length <= 2048, 'URL length must be between 1 and 2048 characters')
|
||||
.refine((value) => {
|
||||
if (value.startsWith('attachment://')) {
|
||||
const filename = value.slice(13);
|
||||
if (filename.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return FILENAME_SAFE_REGEX.test(filename);
|
||||
}
|
||||
|
||||
if (!value.startsWith('http://') && !value.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return PROTOCOLS.includes(url.protocol.slice(0, -1));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Invalid URL format or attachment reference')
|
||||
.refine((value) => {
|
||||
if (value.startsWith('attachment://')) {
|
||||
return true;
|
||||
}
|
||||
return validator.isURL(value, {
|
||||
...URL_VALIDATOR_OPTIONS,
|
||||
require_tld: Config.nodeEnv !== 'development',
|
||||
});
|
||||
}, 'Invalid URL format');
|
||||
|
||||
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
||||
|
||||
function normalizeFilename(value: string): string {
|
||||
let normalized = normalizeString(value);
|
||||
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte filtering is intentional for security
|
||||
normalized = normalized.replace(/\x00/g, '');
|
||||
|
||||
normalized = normalized.replace(/[/\\]/g, '_');
|
||||
|
||||
normalized = normalized.replace(/\.{2,}/g, '_');
|
||||
|
||||
while (normalized.includes('..')) {
|
||||
normalized = normalized.replace(/\.\./g, '_');
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/[<>:"|?*]/g, '');
|
||||
|
||||
if (WINDOWS_RESERVED_NAMES.test(normalized)) {
|
||||
normalized = `_${normalized}`;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(WHITESPACE_REGEX, '_');
|
||||
|
||||
normalized = normalized.replace(NON_FILENAME_CHARS_REGEX, '');
|
||||
|
||||
normalized = normalized.replace(/\.\./g, '_');
|
||||
normalized = normalized.replace(/[/\\]/g, '_');
|
||||
|
||||
if (!normalized || /^[._]+$/.test(normalized)) {
|
||||
normalized = 'unnamed';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export const FilenameType = z
|
||||
.string()
|
||||
.refine((value) => value.length >= 1 && value.length <= 255, 'Filename length must be between 1 and 255 characters')
|
||||
.transform(normalizeFilename)
|
||||
.refine((value) => value.length >= 1, 'Filename cannot be empty after normalization')
|
||||
.refine((value) => FILENAME_SAFE_REGEX.test(value), 'Filename contains invalid characters');
|
||||
|
||||
export const Int64Type = z.union([z.string(), z.number().int()]).transform((value, ctx) => {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid integer format',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
const normalized = typeof value === 'number' ? value.toString() : value;
|
||||
const trimmed = normalized.trim();
|
||||
|
||||
try {
|
||||
const bigInt = BigInt(trimmed);
|
||||
if (bigInt < -9223372036854775808n || bigInt > 9223372036854775807n) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Integer out of valid int64 range',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return bigInt;
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid integer format',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
export const ColorType = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0x000000, 'Color value must be at least 0x000000')
|
||||
.max(0xffffff, 'Color value must not exceed 0xffffff');
|
||||
|
||||
export const Int32Type = z.number().int().min(0).max(2147483647);
|
||||
|
||||
const INTEGER_STRING_REGEX = /^[+-]?\d+$/;
|
||||
|
||||
const coerceNumericStringToNumber = (value: unknown): unknown => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0 || !INTEGER_STRING_REGEX.test(trimmed)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isNaN(parsed) ? value : parsed;
|
||||
};
|
||||
|
||||
export const coerceNumberFromString = <T extends z.ZodNumber>(schema: T) =>
|
||||
z.preprocess((value) => coerceNumericStringToNumber(value), schema);
|
||||
|
||||
export const QueryBooleanType = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((value) => TRUE_VALUES.includes(value));
|
||||
|
||||
export const createQueryIntegerType = ({defaultValue = 0, minValue = 0, maxValue = 2147483647} = {}) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.default(defaultValue.toString())
|
||||
.superRefine((value, ctx) => {
|
||||
const num = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(num) || num < minValue || num > maxValue) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Invalid integer: must be between ${minValue} and ${maxValue}`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => Number.parseInt(value, 10));
|
||||
|
||||
export const DateTimeType = z.union([
|
||||
z
|
||||
.string()
|
||||
.regex(ISO_TIMESTAMP_REGEX, 'Must be a valid ISO timestamp')
|
||||
.superRefine((value, ctx) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid date',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => new Date(value)),
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(8640000000000000)
|
||||
.superRefine((value, ctx) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid date',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => new Date(value)),
|
||||
]);
|
||||
|
||||
export const WebhookNameType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine(
|
||||
(value) => value.length >= 1 && value.length <= 80,
|
||||
'Webhook name length must be between 1 and 80 characters',
|
||||
);
|
||||
|
||||
export const PasswordType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => value.length >= 8 && value.length <= 256, 'Password length must be between 8 and 256 characters');
|
||||
|
||||
export const PhoneNumberType = z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine((value) => PHONE_E164_REGEX.test(value), PHONE_E164_ERROR_MESSAGE);
|
||||
|
||||
export const createStringType = (minLength = 1, maxLength = 256) =>
|
||||
z
|
||||
.string()
|
||||
.transform(normalizeString)
|
||||
.refine(
|
||||
(value: string) => value.length >= minLength && value.length <= maxLength,
|
||||
minLength === maxLength
|
||||
? `String must be exactly ${minLength} characters`
|
||||
: `String length must be between ${minLength} and ${maxLength} characters`,
|
||||
);
|
||||
|
||||
export const createUnboundedStringType = () => z.string().transform(normalizeString);
|
||||
|
||||
export const createBase64StringType = (minLength = 1, maxLength = 256) =>
|
||||
z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
const normalized = normalizeString(value);
|
||||
const commaIndex = normalized.indexOf(',');
|
||||
const base64 = commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
|
||||
|
||||
if (base64.length < minLength || base64.length > maxLength) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Base64 string length must be between ${minLength} and ${maxLength} characters`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
if (base64.length < 1 || !isValidBase64(base64)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Value must be a valid base64-encoded string',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => {
|
||||
const normalized = normalizeString(value);
|
||||
const commaIndex = normalized.indexOf(',');
|
||||
return commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
|
||||
});
|
||||
|
||||
export const ChannelNameType = z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
const normalized = normalizeString(value);
|
||||
const processed =
|
||||
normalized
|
||||
.toLowerCase()
|
||||
.replace(WHITESPACE_REGEX, '-')
|
||||
.split('')
|
||||
.filter((char) => !DISALLOWED_CHARS.has(char))
|
||||
.join('') || '-';
|
||||
if (processed.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Channel name cannot be empty after normalization',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => {
|
||||
const normalized = normalizeString(value);
|
||||
return (
|
||||
normalized
|
||||
.toLowerCase()
|
||||
.replace(WHITESPACE_REGEX, '-')
|
||||
.split('')
|
||||
.filter((char) => !DISALLOWED_CHARS.has(char))
|
||||
.join('') || '-'
|
||||
);
|
||||
})
|
||||
.refine(
|
||||
(value) => value.length >= 1 && value.length <= 100,
|
||||
'Channel name length must be between 1 and 100 characters',
|
||||
);
|
||||
|
||||
export const GeneralChannelNameType = z
|
||||
.string()
|
||||
.transform((value) => {
|
||||
let sanitized = sanitizeChannelName(value);
|
||||
sanitized = sanitized.replace(WHITESPACE_REGEX, ' ');
|
||||
return sanitized;
|
||||
})
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.refine((v) => v.trim().length > 0, 'Name cannot be empty after normalization')
|
||||
.min(1, 'Name length must be between 1 and 100 characters')
|
||||
.max(100, 'Name length must be between 1 and 100 characters'),
|
||||
);
|
||||
|
||||
export const VanityURLCodeType = z
|
||||
.string()
|
||||
.superRefine((value, ctx) => {
|
||||
const normalized = normalizeString(value);
|
||||
const processed = normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
|
||||
if (!VANITY_URL_REGEX.test(processed)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Vanity URL can only contain lowercase letters (a-z), digits (0-9), and hyphens (-)',
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.transform((value) => {
|
||||
const normalized = normalizeString(value);
|
||||
return normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
|
||||
})
|
||||
.refine(
|
||||
(value) => value.length >= 2 && value.length <= 32,
|
||||
'Vanity URL code length must be between 2 and 32 characters',
|
||||
);
|
||||
|
||||
const AUDIT_LOG_REASON_MAX_LENGTH = 512;
|
||||
|
||||
export const AuditLogReasonType = z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeString(value);
|
||||
if (normalized.length < 1 || normalized.length > AUDIT_LOG_REASON_MAX_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
|
||||
export const SudoVerificationSchema = z.object({
|
||||
password: PasswordType.optional(),
|
||||
mfa_method: z.enum(['totp', 'sms', 'webauthn']).optional(),
|
||||
mfa_code: createStringType(1, 32).optional(),
|
||||
webauthn_response: z.any().optional(),
|
||||
webauthn_challenge: createStringType().optional(),
|
||||
});
|
||||
|
||||
export {z};
|
||||
Reference in New Issue
Block a user