/*
* 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 .
*/
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {ZodTypeAny} from 'zod';
import {z} from 'zod';
export function withOpenApiType(schema: T, typeName: string): T {
(schema as Record).__fluxer_custom_type__ = typeName;
return schema;
}
export function withFieldDescription(schema: T, fieldDescription: string): T {
const currentDesc = schema.description ?? '';
const newDesc = currentDesc ? `${currentDesc}|fieldDesc:${fieldDescription}` : `|fieldDesc:${fieldDescription}`;
return schema.describe(newDesc) as T;
}
const RTL_OVERRIDE_REGEX = /\u202E/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
const FORM_FEED_REGEX = /\u000C/g;
export const MAX_STRING_PROCESSING_LENGTH = 10_000;
export function normalizeString(value: string): string {
return (
value
.replace(RTL_OVERRIDE_REGEX, '')
.replace(FORM_FEED_REGEX, '')
// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte and control character filtering is intentional for security
.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g, '')
.trim()
);
}
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: ValidationErrorCodes.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: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:Int64Type');
export const UnsignedInt64Type = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
if (!/^\d+$/.test(trimmed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
try {
const bigInt = BigInt(trimmed);
if (bigInt < 0n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:UnsignedInt64Type');
export const Int64StringType = z
.string()
.regex(/^-?\d+$/)
.describe('fluxer:Int64StringType');
const SNOWFLAKE_REGEX = /^(0|[1-9][0-9]*)$/;
const UNSIGNED_INT64_STRING_REGEX = /^\d+$/;
export const SnowflakeStringType = z.string().regex(SNOWFLAKE_REGEX).describe('fluxer:SnowflakeStringType');
export const BitflagStringType = z.string().regex(UNSIGNED_INT64_STRING_REGEX).describe('fluxer:BitflagStringType');
const HEX_STRING_16_REGEX = /^[a-f0-9]{16}$/;
export const HexString16Type = z.string().regex(HEX_STRING_16_REGEX).describe('fluxer:HexString16Type');
const HEX_STRING_32_REGEX = /^[a-f0-9]{32}$/;
export const HexString32Type = z.string().regex(HEX_STRING_32_REGEX).describe('fluxer:HexString32Type');
export const SnowflakeType = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
if (!SNOWFLAKE_REGEX.test(trimmed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
try {
const bigInt = BigInt(trimmed);
if (bigInt < 0n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.SNOWFLAKE_OUT_OF_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:SnowflakeType');
export const ColorType = z
.number()
.int()
.min(0x000000, ValidationErrorCodes.COLOR_VALUE_TOO_LOW)
.max(0xffffff, ValidationErrorCodes.COLOR_VALUE_TOO_HIGH)
.describe('fluxer:ColorType');
export const Int32Type = z.number().int().min(0).max(2147483647).describe('fluxer:Int32Type');
export const SignedInt32Type = z.number().int().min(-2147483648).max(2147483647).describe('fluxer:SignedInt32Type');
const INTEGER_STRING_REGEX = /^[+-]?\d+$/;
function 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 function coerceNumberFromString(schema: T) {
return z.preprocess((value) => coerceNumericStringToNumber(value), schema);
}
export function withStringLengthRangeValidation(
schema: z.ZodString,
minLength: number,
maxLength: number,
errorCode: string,
) {
return schema.superRefine((value, ctx) => {
if (value.length < minLength || value.length > maxLength) {
const params: Record = {min: minLength, max: maxLength};
if (minLength === maxLength) {
params.length = minLength;
}
ctx.addIssue({code: 'custom', message: errorCode, params});
}
});
}
export function createStringType(minLength = 1, maxLength = 256) {
const errorMessage =
minLength === maxLength ? ValidationErrorCodes.STRING_LENGTH_EXACT : ValidationErrorCodes.STRING_LENGTH_INVALID;
return z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), minLength, maxLength, errorMessage));
}
export function createUnboundedStringType() {
return z.string().transform(normalizeString);
}
const C0_C1_CTRL_REGEX =
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/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;
export function removeStandaloneSurrogates(value: string): string {
return Array.from(value)
.filter((char) => {
if (char.length > 1) {
return true;
}
const codePoint = char.codePointAt(0);
if (codePoint === undefined) {
return false;
}
return codePoint < 0xd800 || codePoint > 0xdfff;
})
.join('');
}
export function normalizeWhitespace(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s.replace(UNICODE_SPACES_REGEX, ' ').replace(/\s+/g, ' ').trim();
}
export function stripInvisibles(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return 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, '');
}
export function stripVariationSelectors(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s.replace(VARIATION_SELECTORS_BASIC, '').replace(VARIATION_SELECTORS_IDEOGRAPHIC, '');
}
interface EnumEntryJson {
n: string;
v: string | number;
d?: string;
}
export function createNamedLiteral(value: T, name: string, description?: string) {
const entry: EnumEntryJson = {n: name, v: value};
if (description) entry.d = description;
return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`);
}
export function createNamedLiteralUnion(
pairs: ReadonlyArray,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
return z
.union(literals as [z.ZodLiteral, z.ZodLiteral, ...Array>])
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createNamedStringLiteral(value: T, name: string, description?: string) {
const entry: EnumEntryJson = {n: name, v: value};
if (description) entry.d = description;
return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`);
}
export function createNamedStringLiteralUnion(
pairs: ReadonlyArray,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
return z
.union(literals as [z.ZodLiteral, z.ZodLiteral, ...Array>])
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createFlexibleStringLiteralUnion(
pairs: ReadonlyArray,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
const flexibleUnionOperands = [...literals, z.string()] as unknown as [
z.ZodLiteral,
z.ZodLiteral,
...Array | z.ZodString>,
];
return z.union(flexibleUnionOperands).describe(`fluxer:FlexibleEnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createInt32EnumType(
pairs: ReadonlyArray,
description?: string,
typeName?: string,
) {
const entries: Array = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = description ? ` ${description}` : '';
return Int32Type.describe(`fluxer:Int32Enum${typeNamePart}:${JSON.stringify(entries)}${descPart}`);
}
type BitflagConstantsObject = Readonly>;
type BitflagDescriptionsObject = Readonly>>;
interface BitflagEntryJson {
n: string;
v: string;
d?: string;
}
function formatBitflagAnnotation(
constants: T,
descriptions?: BitflagDescriptionsObject,
): string {
const entries: Array = Object.entries(constants)
.filter(([, value]) => typeof value === 'number' || typeof value === 'bigint')
.map(([name, value]) => {
const desc = descriptions?.[name as keyof T];
const entry: BitflagEntryJson = {n: name, v: value.toString()};
if (desc) entry.d = desc;
return entry;
});
return JSON.stringify(entries);
}
export function createBitflagStringType(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return BitflagStringType.describe(`fluxer:Bitflags64${typeNamePart}:${annotation}${descPart}`);
}
export function createBitflagInt32Type(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return Int32Type.describe(`fluxer:Bitflags32${typeNamePart}:${annotation}${descPart}`);
}
export function createPermissionStringType(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return z
.string()
.regex(UNSIGNED_INT64_STRING_REGEX)
.describe(`fluxer:Permissions${typeNamePart}:${annotation}${descPart}`);
}