- Clone of github.com/fluxerapp/fluxer (official upstream) - SELF_HOSTING.md: full VM rebuild procedure, architecture overview, service reference, step-by-step setup, troubleshooting, seattle reference - dev/.env.example: all env vars with secrets redacted and generation instructions - dev/livekit.yaml: LiveKit config template with placeholder keys - fluxer-seattle/: existing seattle deployment setup scripts
464 lines
15 KiB
TypeScript
464 lines
15 KiB
TypeScript
/*
|
|
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|
import type {ZodTypeAny} from 'zod';
|
|
import {z} from 'zod';
|
|
|
|
export function withOpenApiType<T extends ZodTypeAny>(schema: T, typeName: string): T {
|
|
(schema as Record<string, unknown>).__fluxer_custom_type__ = typeName;
|
|
return schema;
|
|
}
|
|
|
|
export function withFieldDescription<T extends z.ZodTypeAny>(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<T extends z.ZodNumber>(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<string, unknown> = {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<T extends number>(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<T extends number>(
|
|
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
|
|
description?: string,
|
|
) {
|
|
const literals = pairs.map(([value]) => z.literal(value));
|
|
const entries: Array<EnumEntryJson> = 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<T>, z.ZodLiteral<T>, ...Array<z.ZodLiteral<T>>])
|
|
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
|
|
}
|
|
|
|
export function createNamedStringLiteral<T extends string>(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<T extends string>(
|
|
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
|
|
description?: string,
|
|
) {
|
|
const literals = pairs.map(([value]) => z.literal(value));
|
|
const entries: Array<EnumEntryJson> = 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<T>, z.ZodLiteral<T>, ...Array<z.ZodLiteral<T>>])
|
|
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
|
|
}
|
|
|
|
export function createFlexibleStringLiteralUnion<T extends string>(
|
|
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
|
|
description?: string,
|
|
) {
|
|
const literals = pairs.map(([value]) => z.literal(value));
|
|
const entries: Array<EnumEntryJson> = 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<T>,
|
|
z.ZodLiteral<T>,
|
|
...Array<z.ZodLiteral<T> | z.ZodString>,
|
|
];
|
|
return z.union(flexibleUnionOperands).describe(`fluxer:FlexibleEnumValues:${JSON.stringify(entries)}${descPart}`);
|
|
}
|
|
|
|
export function createInt32EnumType<T extends number>(
|
|
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
|
|
description?: string,
|
|
typeName?: string,
|
|
) {
|
|
const entries: Array<EnumEntryJson> = 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<Record<string, number | bigint>>;
|
|
type BitflagDescriptionsObject<T extends BitflagConstantsObject> = Readonly<Partial<Record<keyof T, string>>>;
|
|
|
|
interface BitflagEntryJson {
|
|
n: string;
|
|
v: string;
|
|
d?: string;
|
|
}
|
|
|
|
function formatBitflagAnnotation<T extends BitflagConstantsObject>(
|
|
constants: T,
|
|
descriptions?: BitflagDescriptionsObject<T>,
|
|
): string {
|
|
const entries: Array<BitflagEntryJson> = 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<T extends BitflagConstantsObject>(
|
|
constants: T,
|
|
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
|
|
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<T extends BitflagConstantsObject>(
|
|
constants: T,
|
|
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
|
|
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<T extends BitflagConstantsObject>(
|
|
constants: T,
|
|
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
|
|
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}`);
|
|
}
|