refactor progress
This commit is contained in:
27
packages/config/package.json
Normal file
27
packages/config/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@fluxer/config",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "tsx src/schema/bundle.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"ajv": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
107
packages/config/src/BuildMetadata.tsx
Normal file
107
packages/config/src/BuildMetadata.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 {getConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
|
||||
export type ReleaseChannel = 'stable' | 'canary' | 'nightly';
|
||||
|
||||
export interface BuildMetadata {
|
||||
buildSha: string;
|
||||
buildNumber: string;
|
||||
buildTimestamp: string;
|
||||
releaseChannel: ReleaseChannel;
|
||||
}
|
||||
|
||||
const FALLBACK_VALUES = {
|
||||
BUILD_SHA: 'dev',
|
||||
BUILD_NUMBER: '0',
|
||||
RELEASE_CHANNEL: 'nightly',
|
||||
} as const;
|
||||
|
||||
let fallbackLogged = false;
|
||||
|
||||
function isDevEnvironment(): boolean {
|
||||
try {
|
||||
const config = getConfig();
|
||||
return config.env === 'development' || config.env === 'test';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function logFallbackWarning(usedFallbacks: Array<string>): void {
|
||||
if (fallbackLogged || usedFallbacks.length === 0 || isDevEnvironment()) {
|
||||
return;
|
||||
}
|
||||
fallbackLogged = true;
|
||||
process.stdout.write(
|
||||
`[build-metadata] Using fallback values for: ${usedFallbacks.join(', ')}. ` +
|
||||
`This indicates missing env vars in CI/production.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function getEnvOrDefault(name: string, defaultValue: string, usedFallbacks: Array<string>): string {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined && value.trim() !== '') {
|
||||
return value.trim();
|
||||
}
|
||||
usedFallbacks.push(name);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function resolveReleaseChannel(usedFallbacks: Array<string>): ReleaseChannel {
|
||||
const raw = getEnvOrDefault('RELEASE_CHANNEL', FALLBACK_VALUES.RELEASE_CHANNEL, usedFallbacks).toLowerCase();
|
||||
switch (raw) {
|
||||
case 'stable':
|
||||
return 'stable';
|
||||
case 'canary':
|
||||
return 'canary';
|
||||
default:
|
||||
return 'nightly';
|
||||
}
|
||||
}
|
||||
|
||||
let cachedMetadata: BuildMetadata | null = null;
|
||||
|
||||
export function getBuildMetadata(): BuildMetadata {
|
||||
if (cachedMetadata === null) {
|
||||
const usedFallbacks: Array<string> = [];
|
||||
cachedMetadata = {
|
||||
buildSha: getEnvOrDefault('BUILD_SHA', FALLBACK_VALUES.BUILD_SHA, usedFallbacks),
|
||||
buildNumber: getEnvOrDefault('BUILD_NUMBER', FALLBACK_VALUES.BUILD_NUMBER, usedFallbacks),
|
||||
buildTimestamp: getEnvOrDefault('BUILD_TIMESTAMP', String(Math.floor(Date.now() / 1000)), usedFallbacks),
|
||||
releaseChannel: resolveReleaseChannel(usedFallbacks),
|
||||
};
|
||||
logFallbackWarning(usedFallbacks);
|
||||
}
|
||||
return cachedMetadata;
|
||||
}
|
||||
|
||||
export function isDevBuild(): boolean {
|
||||
return getBuildMetadata().buildSha === 'dev';
|
||||
}
|
||||
|
||||
export function getServiceVersionLabel(): string | undefined {
|
||||
const metadata = getBuildMetadata();
|
||||
return metadata.buildSha !== 'dev' ? metadata.buildSha : undefined;
|
||||
}
|
||||
|
||||
export function getSentryBuildContext(): BuildMetadata {
|
||||
return getBuildMetadata();
|
||||
}
|
||||
69
packages/config/src/ConfigLoader.tsx
Normal file
69
packages/config/src/ConfigLoader.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 {existsSync} from 'node:fs';
|
||||
import {deepMerge} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
import {buildEnvOverrides} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
|
||||
import {loadJsonFile} from '@fluxer/config/src/config_loader/JsonConfigReader';
|
||||
import {deriveEndpointsFromDomain} from '@fluxer/config/src/EndpointDerivation';
|
||||
import {type MasterConfig, MasterConfigSchema} from '@fluxer/config/src/MasterZodSchema.generated';
|
||||
|
||||
const DEFAULT_CONFIG_PATHS = [process.env['FLUXER_CONFIG']].filter((path): path is string => Boolean(path));
|
||||
|
||||
let cachedConfig: MasterConfig | null = null;
|
||||
|
||||
export async function loadConfig(configPaths: Array<string> = DEFAULT_CONFIG_PATHS): Promise<MasterConfig> {
|
||||
if (cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
if (configPaths.length === 0) {
|
||||
throw new Error('FLUXER_CONFIG must be set to a JSON config path.');
|
||||
}
|
||||
|
||||
const configPath = configPaths.find((path) => existsSync(path));
|
||||
|
||||
if (!configPath) {
|
||||
throw new Error(`No config file found. Checked FLUXER_CONFIG paths: ${configPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
const raw = loadJsonFile(configPath);
|
||||
const envOverrides = buildEnvOverrides(process.env, 'FLUXER_CONFIG__');
|
||||
const merged = deepMerge(raw, envOverrides);
|
||||
delete merged['$schema'];
|
||||
|
||||
const parsedConfig = MasterConfigSchema.parse(merged);
|
||||
const derived = deriveEndpointsFromDomain(parsedConfig.domain);
|
||||
const overrides = parsedConfig.endpoint_overrides ?? {};
|
||||
const endpoints = {...derived, ...overrides};
|
||||
|
||||
cachedConfig = {...parsedConfig, endpoints} as MasterConfig;
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export function getConfig(): MasterConfig {
|
||||
if (!cachedConfig) {
|
||||
throw new Error('Config not loaded. Call loadConfig() first.');
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export function resetConfig(): void {
|
||||
cachedConfig = null;
|
||||
}
|
||||
2169
packages/config/src/ConfigSchema.json
Normal file
2169
packages/config/src/ConfigSchema.json
Normal file
File diff suppressed because it is too large
Load Diff
110
packages/config/src/EndpointDerivation.tsx
Normal file
110
packages/config/src/EndpointDerivation.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 {CdnEndpoints} from '@fluxer/constants/src/CdnEndpoints';
|
||||
|
||||
export interface DomainConfig {
|
||||
base_domain: string;
|
||||
public_scheme: 'http' | 'https';
|
||||
internal_scheme: 'http' | 'https';
|
||||
public_port?: number;
|
||||
internal_port?: number;
|
||||
static_cdn_domain?: string;
|
||||
invite_domain?: string;
|
||||
gift_domain?: string;
|
||||
}
|
||||
|
||||
export interface DerivedEndpoints {
|
||||
api: string;
|
||||
api_client: string;
|
||||
app: string;
|
||||
gateway: string;
|
||||
media: string;
|
||||
static_cdn: string;
|
||||
admin: string;
|
||||
marketing: string;
|
||||
invite: string;
|
||||
gift: string;
|
||||
}
|
||||
|
||||
export function buildUrl(scheme: string, domain: string, port?: number, path?: string): string {
|
||||
const isStandardPort =
|
||||
(scheme === 'http' && port === 80) ||
|
||||
(scheme === 'https' && port === 443) ||
|
||||
(scheme === 'ws' && port === 80) ||
|
||||
(scheme === 'wss' && port === 443);
|
||||
|
||||
const portPart = port && !isStandardPort ? `:${port}` : '';
|
||||
const pathPart = path || '';
|
||||
|
||||
return `${scheme}://${domain}${portPart}${pathPart}`;
|
||||
}
|
||||
|
||||
export function deriveDomain(
|
||||
endpointType:
|
||||
| 'api'
|
||||
| 'api_client'
|
||||
| 'app'
|
||||
| 'gateway'
|
||||
| 'media'
|
||||
| 'static_cdn'
|
||||
| 'admin'
|
||||
| 'marketing'
|
||||
| 'invite'
|
||||
| 'gift',
|
||||
config: DomainConfig,
|
||||
): string {
|
||||
switch (endpointType) {
|
||||
case 'static_cdn':
|
||||
return config.static_cdn_domain || CdnEndpoints.STATIC_HOST || config.base_domain;
|
||||
case 'invite':
|
||||
return config.invite_domain || config.base_domain;
|
||||
case 'gift':
|
||||
return config.gift_domain || config.base_domain;
|
||||
default:
|
||||
return config.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveEndpointsFromDomain(config: DomainConfig): DerivedEndpoints {
|
||||
const {public_scheme, public_port} = config;
|
||||
|
||||
const gatewayScheme = public_scheme === 'https' ? 'wss' : 'ws';
|
||||
|
||||
return {
|
||||
api: buildUrl(public_scheme, deriveDomain('api', config), public_port, '/api'),
|
||||
api_client: buildUrl(public_scheme, deriveDomain('api_client', config), public_port, '/api'),
|
||||
|
||||
app: buildUrl(public_scheme, deriveDomain('app', config), public_port),
|
||||
|
||||
gateway: buildUrl(gatewayScheme, deriveDomain('gateway', config), public_port, '/gateway'),
|
||||
|
||||
media: buildUrl(public_scheme, deriveDomain('media', config), public_port, '/media'),
|
||||
|
||||
static_cdn: buildUrl('https', deriveDomain('static_cdn', config), undefined),
|
||||
|
||||
admin: buildUrl(public_scheme, deriveDomain('admin', config), public_port, '/admin'),
|
||||
|
||||
marketing: buildUrl(public_scheme, deriveDomain('marketing', config), public_port, '/marketing'),
|
||||
|
||||
invite: buildUrl(public_scheme, deriveDomain('invite', config), public_port, '/invite'),
|
||||
|
||||
gift: buildUrl(public_scheme, deriveDomain('gift', config), public_port, '/gift'),
|
||||
};
|
||||
}
|
||||
196
packages/config/src/JsonValidation.tsx
Normal file
196
packages/config/src/JsonValidation.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* 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 ConfigSchema from '@fluxer/config/src/ConfigSchema.json';
|
||||
import {type ConfigObject, isPlainObject} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
import Ajv from 'ajv/dist/2020';
|
||||
|
||||
const ajv = new Ajv({allErrors: true, allowUnionTypes: true, strict: false, strictTypes: false, useDefaults: true});
|
||||
const validate = ajv.compile(ConfigSchema);
|
||||
|
||||
interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
$ref?: string;
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
anyOf?: Array<JsonSchema>;
|
||||
allOf?: Array<JsonSchema>;
|
||||
oneOf?: Array<JsonSchema>;
|
||||
additionalProperties?: boolean | JsonSchema;
|
||||
}
|
||||
|
||||
const CONFIG_SCHEMA_ROOT = ConfigSchema as unknown as JsonSchema;
|
||||
|
||||
function formatErrorPaths(errors: Array<{instancePath?: string; message?: string}>): string {
|
||||
return errors
|
||||
.map((error) => {
|
||||
const path = error.instancePath && error.instancePath.length > 0 ? error.instancePath : '/';
|
||||
const message = error.message ?? 'is invalid';
|
||||
return `${path} ${message}`;
|
||||
})
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function extractRefName(ref: string): string {
|
||||
const match = ref.match(/#\/\$defs\/(.+)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
throw new Error(`Invalid $ref format: ${ref}`);
|
||||
}
|
||||
|
||||
function resolveSchema(schema: JsonSchema): JsonSchema {
|
||||
if (!schema.$ref) {
|
||||
return schema;
|
||||
}
|
||||
const defs = CONFIG_SCHEMA_ROOT.$defs;
|
||||
if (!defs) {
|
||||
return schema;
|
||||
}
|
||||
const refName = extractRefName(schema.$ref);
|
||||
const resolved = defs[refName];
|
||||
return resolved ?? schema;
|
||||
}
|
||||
|
||||
function formatJsonPointer(segments: Array<string>): string {
|
||||
if (segments.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
return `/${segments.map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`;
|
||||
}
|
||||
|
||||
function collectKnownProperties(
|
||||
schema: JsonSchema,
|
||||
out: Map<string, Array<JsonSchema>>,
|
||||
visited: Set<JsonSchema>,
|
||||
): void {
|
||||
const resolved = resolveSchema(schema);
|
||||
if (visited.has(resolved)) {
|
||||
return;
|
||||
}
|
||||
visited.add(resolved);
|
||||
|
||||
if (resolved.properties) {
|
||||
for (const [key, propSchema] of Object.entries(resolved.properties)) {
|
||||
const existing = out.get(key) ?? [];
|
||||
existing.push(propSchema);
|
||||
out.set(key, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// For unknown-key warnings we treat combinators as "any of these keys might be valid".
|
||||
for (const sub of resolved.anyOf ?? []) {
|
||||
collectKnownProperties(sub, out, visited);
|
||||
}
|
||||
for (const sub of resolved.oneOf ?? []) {
|
||||
collectKnownProperties(sub, out, visited);
|
||||
}
|
||||
for (const sub of resolved.allOf ?? []) {
|
||||
collectKnownProperties(sub, out, visited);
|
||||
}
|
||||
}
|
||||
|
||||
function collectUnknownConfigKeys(
|
||||
value: unknown,
|
||||
schema: JsonSchema,
|
||||
segments: Array<string>,
|
||||
warnings: Array<string>,
|
||||
): void {
|
||||
const resolved = resolveSchema(schema);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const itemSchema = resolved.items;
|
||||
if (!itemSchema) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
collectUnknownConfigKeys(value[i], itemSchema, [...segments, String(i)], warnings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this object schema doesn't have an explicit property set, we can't reliably warn
|
||||
// (it may be intended as a map/dictionary).
|
||||
const known = new Map<string, Array<JsonSchema>>();
|
||||
collectKnownProperties(resolved, known, new Set());
|
||||
if (known.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the schema explicitly models "free-form" additionalProperties, don't warn.
|
||||
if (resolved.additionalProperties && typeof resolved.additionalProperties === 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!known.has(key)) {
|
||||
const pointer = formatJsonPointer(segments);
|
||||
warnings.push(`${pointer} has unknown property "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, schemas] of known.entries()) {
|
||||
if (!(key in value)) {
|
||||
continue;
|
||||
}
|
||||
const nextSchema = schemas[0];
|
||||
if (!nextSchema) {
|
||||
continue;
|
||||
}
|
||||
collectUnknownConfigKeys(value[key], nextSchema, [...segments, key], warnings);
|
||||
}
|
||||
}
|
||||
|
||||
function warnOnUnknownConfigKeys(config: ConfigObject): void {
|
||||
const warnings: Array<string> = [];
|
||||
collectUnknownConfigKeys(config, CONFIG_SCHEMA_ROOT, [], warnings);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep this bounded in case a user has a large extra subtree.
|
||||
const max = 25;
|
||||
const shown = warnings.slice(0, max);
|
||||
const remainder = warnings.length - shown.length;
|
||||
const extra = remainder > 0 ? ` (+${remainder} more)` : '';
|
||||
|
||||
console.warn(
|
||||
[
|
||||
`Config JSON contains unknown properties; they are ignored by Fluxer.${extra}`,
|
||||
...shown.map((w) => `- ${w}`),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
export function assertValidJsonConfig(config: ConfigObject): void {
|
||||
const valid = validate(config);
|
||||
|
||||
if (!valid) {
|
||||
const errors = validate.errors ? formatErrorPaths(validate.errors) : 'unknown schema error';
|
||||
throw new Error(`Invalid config JSON: ${errors}`);
|
||||
}
|
||||
|
||||
warnOnUnknownConfigKeys(config);
|
||||
}
|
||||
24
packages/config/src/MasterZodSchema.tsx
Normal file
24
packages/config/src/MasterZodSchema.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 type {SentrySchema, TelemetrySchema} from '@fluxer/config/src/MasterZodSchema.generated';
|
||||
import type {z} from 'zod';
|
||||
|
||||
export type SentryConfig = z.infer<typeof SentrySchema>;
|
||||
export type TelemetryConfig = z.infer<typeof TelemetrySchema>;
|
||||
58
packages/config/src/ServiceConfigSlices.tsx
Normal file
58
packages/config/src/ServiceConfigSlices.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 {getBuildMetadata} from '@fluxer/config/src/BuildMetadata';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterZodSchema.generated';
|
||||
|
||||
export function extractBaseServiceConfig(master: MasterConfig) {
|
||||
return {
|
||||
env: master.env,
|
||||
telemetry: master.telemetry,
|
||||
sentry: master.sentry,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractKVClientConfig(master: MasterConfig) {
|
||||
if (!master.internal) {
|
||||
throw new Error('internal configuration is required for KV client access (microservices mode only)');
|
||||
}
|
||||
return {
|
||||
kvUrl: master.internal.kv,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractBuildInfoConfig() {
|
||||
const metadata = getBuildMetadata();
|
||||
return {
|
||||
releaseChannel: metadata.releaseChannel,
|
||||
buildTimestamp: metadata.buildTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractRateLimit(
|
||||
rawRateLimit: {limit?: number | null; window_ms?: number | null} | null | undefined,
|
||||
): {limit: number; windowMs: number} | undefined {
|
||||
if (!rawRateLimit || rawRateLimit.limit == null || rawRateLimit.window_ms == null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
limit: rawRateLimit.limit,
|
||||
windowMs: rawRateLimit.window_ms,
|
||||
};
|
||||
}
|
||||
236
packages/config/src/__tests__/ConfigLoader.test.tsx
Normal file
236
packages/config/src/__tests__/ConfigLoader.test.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* 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 {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import {type ConfigObject, deepMerge} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
function createTempConfig(config: Record<string, unknown>): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'fluxer-config-test-'));
|
||||
const configPath = path.join(dir, 'config.json');
|
||||
writeFileSync(configPath, JSON.stringify(config));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function makeMinimalConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const base: ConfigObject = {
|
||||
env: 'test',
|
||||
domain: {
|
||||
base_domain: 'localhost',
|
||||
public_port: 8080,
|
||||
},
|
||||
database: {
|
||||
backend: 'sqlite',
|
||||
sqlite_path: ':memory:',
|
||||
},
|
||||
s3: {
|
||||
access_key_id: 'test-key',
|
||||
secret_access_key: 'test-secret',
|
||||
endpoint: 'http://localhost:9000',
|
||||
},
|
||||
services: {
|
||||
server: {port: 8772, host: '0.0.0.0'},
|
||||
media_proxy: {secret_key: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'},
|
||||
admin: {
|
||||
secret_key_base: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||
oauth_client_secret: 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
||||
},
|
||||
app_proxy: {port: 8773, sentry_report_host: 'sentry.io', sentry_dsn: 'https://test@sentry.io/1'},
|
||||
marketing: {
|
||||
enabled: false,
|
||||
port: 8774,
|
||||
host: '0.0.0.0',
|
||||
secret_key_base: 'marketing0123456789abcdef0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
gateway: {
|
||||
port: 8771,
|
||||
api_host: 'http://localhost:8772/api',
|
||||
admin_reload_secret: 'deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567',
|
||||
media_proxy_endpoint: 'http://localhost:8772/media',
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
rpc_secret: 'rpc-test-secret',
|
||||
},
|
||||
auth: {
|
||||
sudo_mode_secret: 'sudo-test-secret',
|
||||
connection_initiation_secret: 'connection-initiation-test-secret',
|
||||
vapid: {
|
||||
public_key: 'test-vapid-public-key',
|
||||
private_key: 'test-vapid-private-key',
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
search: {
|
||||
url: 'http://127.0.0.1:7700',
|
||||
api_key: 'test-search-key',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return deepMerge(base, overrides as ConfigObject);
|
||||
}
|
||||
|
||||
describe('ConfigLoader', () => {
|
||||
let tempPaths: Array<string> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
for (const p of tempPaths) {
|
||||
try {
|
||||
rmSync(path.dirname(p), {recursive: true, force: true});
|
||||
} catch {}
|
||||
}
|
||||
tempPaths = [];
|
||||
});
|
||||
|
||||
test('loadConfig loads and caches config', async () => {
|
||||
const configPath = createTempConfig(makeMinimalConfig());
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.env).toBe('test');
|
||||
expect(config.domain.base_domain).toBe('localhost');
|
||||
});
|
||||
|
||||
test('getConfig throws when config is not loaded', () => {
|
||||
expect(() => getConfig()).toThrow('Config not loaded');
|
||||
});
|
||||
|
||||
test('resetConfig clears the cache', async () => {
|
||||
const configPath = createTempConfig(makeMinimalConfig());
|
||||
tempPaths.push(configPath);
|
||||
|
||||
await loadConfig([configPath]);
|
||||
expect(() => getConfig()).not.toThrow();
|
||||
|
||||
resetConfig();
|
||||
expect(() => getConfig()).toThrow('Config not loaded');
|
||||
});
|
||||
|
||||
test('throws when no config file is found', async () => {
|
||||
await expect(loadConfig(['/nonexistent/path.json'])).rejects.toThrow('No config file found');
|
||||
});
|
||||
|
||||
test('throws when config paths array is empty', async () => {
|
||||
await expect(loadConfig([])).rejects.toThrow('FLUXER_CONFIG must be set');
|
||||
});
|
||||
|
||||
test('derives endpoints from domain config', async () => {
|
||||
const configPath = createTempConfig(makeMinimalConfig());
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.endpoints.api).toContain('localhost');
|
||||
expect(config.endpoints.api).toContain('/api');
|
||||
expect(config.endpoints.gateway).toContain('ws');
|
||||
});
|
||||
|
||||
test('endpoint_overrides take precedence over derived endpoints', async () => {
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
endpoint_overrides: {
|
||||
api: 'https://custom-api.example.com',
|
||||
api_client: 'https://custom-api-client.example.com',
|
||||
gateway: 'wss://custom-gw.example.com',
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.endpoints.api).toBe('https://custom-api.example.com');
|
||||
expect(config.endpoints.api_client).toBe('https://custom-api-client.example.com');
|
||||
expect(config.endpoints.gateway).toBe('wss://custom-gw.example.com');
|
||||
expect(config.endpoints.app).toContain('localhost');
|
||||
});
|
||||
|
||||
test('allows unknown properties (but warns)', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
extra_root: true,
|
||||
auth: {
|
||||
sudo_mode_secret: 'sudo-test-secret',
|
||||
connection_initiation_secret: 'connection-initiation-test-secret',
|
||||
vapid: {
|
||||
public_key: 'test-vapid-public-key',
|
||||
private_key: 'test-vapid-private-key',
|
||||
},
|
||||
extra_auth: 'oops',
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
await expect(loadConfig([configPath])).resolves.toBeDefined();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"extra_root"'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"extra_auth"'));
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('allows voice enabled without global credentials when default_region is omitted', async () => {
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
integrations: {
|
||||
voice: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.integrations.voice.enabled).toBe(true);
|
||||
expect(config.integrations.voice.api_key).toBeUndefined();
|
||||
expect(config.integrations.voice.api_secret).toBeUndefined();
|
||||
});
|
||||
|
||||
test('requires voice credentials when default_region bootstrap is configured', async () => {
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
integrations: {
|
||||
voice: {
|
||||
enabled: true,
|
||||
default_region: {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
emoji: ':earth_africa:',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
await expect(loadConfig([configPath])).rejects.toThrow('api_key');
|
||||
await expect(loadConfig([configPath])).rejects.toThrow('api_secret');
|
||||
});
|
||||
});
|
||||
82
packages/config/src/__tests__/ConfigObjectMerge.test.tsx
Normal file
82
packages/config/src/__tests__/ConfigObjectMerge.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 {deepMerge, isPlainObject} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('isPlainObject', () => {
|
||||
test('returns true for plain objects', () => {
|
||||
expect(isPlainObject({})).toBe(true);
|
||||
expect(isPlainObject({a: 1})).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for arrays', () => {
|
||||
expect(isPlainObject([])).toBe(false);
|
||||
expect(isPlainObject([1, 2])).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for null and primitives', () => {
|
||||
expect(isPlainObject(null)).toBe(false);
|
||||
expect(isPlainObject(undefined)).toBe(false);
|
||||
expect(isPlainObject(42)).toBe(false);
|
||||
expect(isPlainObject('string')).toBe(false);
|
||||
expect(isPlainObject(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge', () => {
|
||||
test('merges flat objects', () => {
|
||||
const result = deepMerge({a: 1, b: 2}, {b: 3, c: 4});
|
||||
expect(result).toEqual({a: 1, b: 3, c: 4});
|
||||
});
|
||||
|
||||
test('merges nested objects recursively', () => {
|
||||
const target = {database: {host: 'localhost', port: 5432}};
|
||||
const source = {database: {port: 3306, name: 'test'}};
|
||||
const result = deepMerge(target, source);
|
||||
expect(result).toEqual({database: {host: 'localhost', port: 3306, name: 'test'}});
|
||||
});
|
||||
|
||||
test('replaces arrays instead of merging them', () => {
|
||||
const target = {tags: ['a', 'b']};
|
||||
const source = {tags: ['c']};
|
||||
const result = deepMerge(target, source);
|
||||
expect(result).toEqual({tags: ['c']});
|
||||
});
|
||||
|
||||
test('source overrides target for non-object values', () => {
|
||||
const target = {a: 'old', b: {nested: true}};
|
||||
const source = {a: 'new', b: 'replaced'};
|
||||
const result = deepMerge(target, source);
|
||||
expect(result).toEqual({a: 'new', b: 'replaced'});
|
||||
});
|
||||
|
||||
test('does not mutate the target', () => {
|
||||
const target = {a: 1, nested: {b: 2}};
|
||||
const source = {a: 99, nested: {c: 3}};
|
||||
deepMerge(target, source);
|
||||
expect(target).toEqual({a: 1, nested: {b: 2}});
|
||||
});
|
||||
|
||||
test('handles empty objects', () => {
|
||||
expect(deepMerge({}, {a: 1})).toEqual({a: 1});
|
||||
expect(deepMerge({a: 1}, {})).toEqual({a: 1});
|
||||
expect(deepMerge({}, {})).toEqual({});
|
||||
});
|
||||
});
|
||||
388
packages/config/src/__tests__/EndpointDerivation.test.tsx
Normal file
388
packages/config/src/__tests__/EndpointDerivation.test.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* 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 {
|
||||
buildUrl,
|
||||
type DomainConfig,
|
||||
deriveDomain,
|
||||
deriveEndpointsFromDomain,
|
||||
} from '@fluxer/config/src/EndpointDerivation';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('buildUrl', () => {
|
||||
test('omits standard HTTP port (80)', () => {
|
||||
expect(buildUrl('http', 'example.com', 80, '/path')).toBe('http://example.com/path');
|
||||
});
|
||||
|
||||
test('omits standard HTTPS port (443)', () => {
|
||||
expect(buildUrl('https', 'example.com', 443, '/path')).toBe('https://example.com/path');
|
||||
});
|
||||
|
||||
test('omits standard WebSocket port (80)', () => {
|
||||
expect(buildUrl('ws', 'example.com', 80, '/gateway')).toBe('ws://example.com/gateway');
|
||||
});
|
||||
|
||||
test('omits standard secure WebSocket port (443)', () => {
|
||||
expect(buildUrl('wss', 'example.com', 443, '/gateway')).toBe('wss://example.com/gateway');
|
||||
});
|
||||
|
||||
test('includes non-standard port', () => {
|
||||
expect(buildUrl('http', 'localhost', 8088, '/api')).toBe('http://localhost:8088/api');
|
||||
});
|
||||
|
||||
test('includes non-standard HTTPS port', () => {
|
||||
expect(buildUrl('https', 'example.com', 8443, '/api')).toBe('https://example.com:8443/api');
|
||||
});
|
||||
|
||||
test('handles missing port', () => {
|
||||
expect(buildUrl('https', 'example.com', undefined, '/api')).toBe('https://example.com/api');
|
||||
});
|
||||
|
||||
test('handles missing path', () => {
|
||||
expect(buildUrl('https', 'example.com', 443)).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('handles empty path', () => {
|
||||
expect(buildUrl('https', 'example.com', 443, '')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('handles root path', () => {
|
||||
expect(buildUrl('https', 'example.com', 443, '/')).toBe('https://example.com/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveDomain', () => {
|
||||
const baseConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.dev',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
};
|
||||
|
||||
test('uses base domain for api endpoint', () => {
|
||||
expect(deriveDomain('api', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for app endpoint', () => {
|
||||
expect(deriveDomain('app', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for gateway endpoint', () => {
|
||||
expect(deriveDomain('gateway', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for media endpoint', () => {
|
||||
expect(deriveDomain('media', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses custom static CDN domain when specified', () => {
|
||||
const config = {...baseConfig, static_cdn_domain: 'cdn.fluxer.dev'};
|
||||
expect(deriveDomain('static_cdn', config)).toBe('cdn.fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for static CDN when custom domain not specified', () => {
|
||||
expect(deriveDomain('static_cdn', baseConfig)).toBe('fluxerstatic.com');
|
||||
});
|
||||
|
||||
test('uses custom invite domain when specified', () => {
|
||||
const config = {...baseConfig, invite_domain: 'fluxer.gg'};
|
||||
expect(deriveDomain('invite', config)).toBe('fluxer.gg');
|
||||
});
|
||||
|
||||
test('uses base domain for invite when custom domain not specified', () => {
|
||||
expect(deriveDomain('invite', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses custom gift domain when specified', () => {
|
||||
const config = {...baseConfig, gift_domain: 'fluxer.gift'};
|
||||
expect(deriveDomain('gift', config)).toBe('fluxer.gift');
|
||||
});
|
||||
|
||||
test('uses base domain for gift when custom domain not specified', () => {
|
||||
expect(deriveDomain('gift', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveEndpointsFromDomain', () => {
|
||||
describe('development environment (localhost)', () => {
|
||||
const devConfig: DomainConfig = {
|
||||
base_domain: 'localhost',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8088,
|
||||
internal_port: 8088,
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(devConfig);
|
||||
|
||||
test('derives api endpoint with port', () => {
|
||||
expect(endpoints.api).toBe('http://localhost:8088/api');
|
||||
});
|
||||
|
||||
test('derives api client endpoint with port', () => {
|
||||
expect(endpoints.api_client).toBe('http://localhost:8088/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint with port', () => {
|
||||
expect(endpoints.app).toBe('http://localhost:8088');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint with ws scheme', () => {
|
||||
expect(endpoints.gateway).toBe('ws://localhost:8088/gateway');
|
||||
});
|
||||
|
||||
test('derives media endpoint with port', () => {
|
||||
expect(endpoints.media).toBe('http://localhost:8088/media');
|
||||
});
|
||||
|
||||
test('derives static CDN endpoint via CDN host', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://fluxerstatic.com');
|
||||
});
|
||||
|
||||
test('derives admin endpoint with port', () => {
|
||||
expect(endpoints.admin).toBe('http://localhost:8088/admin');
|
||||
});
|
||||
|
||||
test('derives marketing endpoint with port', () => {
|
||||
expect(endpoints.marketing).toBe('http://localhost:8088/marketing');
|
||||
});
|
||||
|
||||
test('derives invite endpoint with port', () => {
|
||||
expect(endpoints.invite).toBe('http://localhost:8088/invite');
|
||||
});
|
||||
|
||||
test('derives gift endpoint with port', () => {
|
||||
expect(endpoints.gift).toBe('http://localhost:8088/gift');
|
||||
});
|
||||
});
|
||||
|
||||
describe('production environment (standard HTTPS port)', () => {
|
||||
const prodConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
internal_port: 8080,
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(prodConfig);
|
||||
|
||||
test('derives api endpoint without port', () => {
|
||||
expect(endpoints.api).toBe('https://fluxer.app/api');
|
||||
});
|
||||
|
||||
test('derives api client endpoint without port', () => {
|
||||
expect(endpoints.api_client).toBe('https://fluxer.app/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint without port', () => {
|
||||
expect(endpoints.app).toBe('https://fluxer.app');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint with wss scheme without port', () => {
|
||||
expect(endpoints.gateway).toBe('wss://fluxer.app/gateway');
|
||||
});
|
||||
|
||||
test('derives media endpoint without port', () => {
|
||||
expect(endpoints.media).toBe('https://fluxer.app/media');
|
||||
});
|
||||
|
||||
test('derives static CDN endpoint without port', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://fluxerstatic.com');
|
||||
});
|
||||
|
||||
test('derives admin endpoint without port', () => {
|
||||
expect(endpoints.admin).toBe('https://fluxer.app/admin');
|
||||
});
|
||||
|
||||
test('derives marketing endpoint without port', () => {
|
||||
expect(endpoints.marketing).toBe('https://fluxer.app/marketing');
|
||||
});
|
||||
|
||||
test('derives invite endpoint without port', () => {
|
||||
expect(endpoints.invite).toBe('https://fluxer.app/invite');
|
||||
});
|
||||
|
||||
test('derives gift endpoint without port', () => {
|
||||
expect(endpoints.gift).toBe('https://fluxer.app/gift');
|
||||
});
|
||||
});
|
||||
|
||||
describe('staging environment (custom port)', () => {
|
||||
const stagingConfig: DomainConfig = {
|
||||
base_domain: 'staging.fluxer.dev',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8443,
|
||||
internal_port: 8080,
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(stagingConfig);
|
||||
|
||||
test('derives api endpoint with custom port', () => {
|
||||
expect(endpoints.api).toBe('https://staging.fluxer.dev:8443/api');
|
||||
});
|
||||
|
||||
test('derives api client endpoint with custom port', () => {
|
||||
expect(endpoints.api_client).toBe('https://staging.fluxer.dev:8443/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint with custom port', () => {
|
||||
expect(endpoints.app).toBe('https://staging.fluxer.dev:8443');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint with wss and custom port', () => {
|
||||
expect(endpoints.gateway).toBe('wss://staging.fluxer.dev:8443/gateway');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom CDN domain', () => {
|
||||
const staticCdnConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
static_cdn_domain: 'cdn.fluxer.app',
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(staticCdnConfig);
|
||||
|
||||
test('uses custom CDN domain', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://cdn.fluxer.app');
|
||||
});
|
||||
|
||||
test('other endpoints use base domain', () => {
|
||||
expect(endpoints.api).toBe('https://fluxer.app/api');
|
||||
expect(endpoints.app).toBe('https://fluxer.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom invite and gift domains', () => {
|
||||
const customConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
invite_domain: 'fluxer.gg',
|
||||
gift_domain: 'fluxer.gift',
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(customConfig);
|
||||
|
||||
test('uses custom invite domain', () => {
|
||||
expect(endpoints.invite).toBe('https://fluxer.gg/invite');
|
||||
});
|
||||
|
||||
test('uses custom gift domain', () => {
|
||||
expect(endpoints.gift).toBe('https://fluxer.gift/gift');
|
||||
});
|
||||
|
||||
test('other endpoints use base domain', () => {
|
||||
expect(endpoints.api).toBe('https://fluxer.app/api');
|
||||
expect(endpoints.app).toBe('https://fluxer.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket scheme derivation', () => {
|
||||
test('derives ws from http', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'localhost',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8088,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.gateway).toBe('ws://localhost:8088/gateway');
|
||||
});
|
||||
|
||||
test('derives wss from https', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.gateway).toBe('wss://fluxer.app/gateway');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canary environment', () => {
|
||||
const canaryConfig: DomainConfig = {
|
||||
base_domain: 'canary.fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
static_cdn_domain: 'cdn-canary.fluxer.app',
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(canaryConfig);
|
||||
|
||||
test('derives api endpoint for canary', () => {
|
||||
expect(endpoints.api).toBe('https://canary.fluxer.app/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint for canary', () => {
|
||||
expect(endpoints.app).toBe('https://canary.fluxer.app');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint for canary', () => {
|
||||
expect(endpoints.gateway).toBe('wss://canary.fluxer.app/gateway');
|
||||
});
|
||||
|
||||
test('uses custom CDN domain for canary', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://cdn-canary.fluxer.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('handles standard HTTP port (80)', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'example.com',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 80,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.api).toBe('http://example.com/api');
|
||||
expect(endpoints.gateway).toBe('ws://example.com/gateway');
|
||||
});
|
||||
|
||||
test('handles ports when undefined', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'example.com',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.api).toBe('https://example.com/api');
|
||||
expect(endpoints.app).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('handles IPv4 addresses', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: '127.0.0.1',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8088,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.api).toBe('http://127.0.0.1:8088/api');
|
||||
});
|
||||
});
|
||||
});
|
||||
154
packages/config/src/__tests__/EnvironmentOverrides.test.tsx
Normal file
154
packages/config/src/__tests__/EnvironmentOverrides.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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 {buildEnvOverrides, parseEnvValue, setNestedValue} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('parseEnvValue', () => {
|
||||
test('parses boolean true', () => {
|
||||
expect(parseEnvValue('true')).toBe(true);
|
||||
expect(parseEnvValue(' true ')).toBe(true);
|
||||
});
|
||||
|
||||
test('parses boolean false', () => {
|
||||
expect(parseEnvValue('false')).toBe(false);
|
||||
expect(parseEnvValue(' false ')).toBe(false);
|
||||
});
|
||||
|
||||
test('parses integers', () => {
|
||||
expect(parseEnvValue('42')).toBe(42);
|
||||
expect(parseEnvValue('-7')).toBe(-7);
|
||||
expect(parseEnvValue('0')).toBe(0);
|
||||
});
|
||||
|
||||
test('parses floats', () => {
|
||||
expect(parseEnvValue('3.14')).toBe(3.14);
|
||||
expect(parseEnvValue('-0.5')).toBe(-0.5);
|
||||
});
|
||||
|
||||
test('parses JSON objects', () => {
|
||||
expect(parseEnvValue('{"key": "value"}')).toEqual({key: 'value'});
|
||||
});
|
||||
|
||||
test('parses JSON arrays', () => {
|
||||
expect(parseEnvValue('[1, 2, 3]')).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('returns raw string for invalid JSON-like values', () => {
|
||||
expect(parseEnvValue('{not json}')).toBe('{not json}');
|
||||
});
|
||||
|
||||
test('returns raw string for plain strings', () => {
|
||||
expect(parseEnvValue('hello')).toBe('hello');
|
||||
expect(parseEnvValue('localhost')).toBe('localhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNestedValue', () => {
|
||||
test('sets a top-level key', () => {
|
||||
const target: Record<string, unknown> = {};
|
||||
setNestedValue(target, ['port'], 8080);
|
||||
expect(target).toEqual({port: 8080});
|
||||
});
|
||||
|
||||
test('sets a nested key', () => {
|
||||
const target: Record<string, unknown> = {};
|
||||
setNestedValue(target, ['database', 'host'], 'localhost');
|
||||
expect(target).toEqual({database: {host: 'localhost'}});
|
||||
});
|
||||
|
||||
test('sets a deeply nested key', () => {
|
||||
const target: Record<string, unknown> = {};
|
||||
setNestedValue(target, ['a', 'b', 'c'], 'deep');
|
||||
expect(target).toEqual({a: {b: {c: 'deep'}}});
|
||||
});
|
||||
|
||||
test('does nothing for empty keys', () => {
|
||||
const target: Record<string, unknown> = {existing: true};
|
||||
setNestedValue(target, [], 'value');
|
||||
expect(target).toEqual({existing: true});
|
||||
});
|
||||
|
||||
test('overwrites non-object intermediate values', () => {
|
||||
const target: Record<string, unknown> = {a: 'string'};
|
||||
setNestedValue(target, ['a', 'b'], 'nested');
|
||||
expect(target).toEqual({a: {b: 'nested'}});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnvOverrides', () => {
|
||||
test('extracts env vars with the given prefix', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__ENV: 'production',
|
||||
FLUXER_CONFIG__DATABASE__HOST: 'db.example.com',
|
||||
UNRELATED_VAR: 'ignored',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({
|
||||
env: 'production',
|
||||
database: {host: 'db.example.com'},
|
||||
});
|
||||
});
|
||||
|
||||
test('lowercases key segments', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__DATABASE__PORT: '5432',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({database: {port: 5432}});
|
||||
});
|
||||
|
||||
test('skips keys that are exactly the prefix with no remainder', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__: 'ignored',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('skips undefined values', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__MISSING: undefined,
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('supports custom prefix', () => {
|
||||
const env = {
|
||||
MYAPP__PORT: '3000',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'MYAPP__');
|
||||
expect(result).toEqual({port: 3000});
|
||||
});
|
||||
|
||||
test('returns empty object when no matching vars exist', () => {
|
||||
const env = {
|
||||
UNRELATED: 'value',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
102
packages/config/src/__tests__/ServiceConfigSlices.test.tsx
Normal file
102
packages/config/src/__tests__/ServiceConfigSlices.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 type {MasterConfig} from '@fluxer/config/src/MasterZodSchema.generated';
|
||||
import {
|
||||
extractBaseServiceConfig,
|
||||
extractBuildInfoConfig,
|
||||
extractKVClientConfig,
|
||||
extractRateLimit,
|
||||
} from '@fluxer/config/src/ServiceConfigSlices';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
function createMasterStub(overrides: Partial<MasterConfig> = {}): MasterConfig {
|
||||
return {
|
||||
env: 'development',
|
||||
telemetry: {enabled: false, otlp_endpoint: 'http://localhost:4318', api_key: '', trace_sampling_ratio: 1},
|
||||
sentry: {enabled: false, dsn: ''},
|
||||
internal: {
|
||||
kv: 'redis://127.0.0.1:6379/0',
|
||||
media_proxy: 'http://localhost:8088/media',
|
||||
},
|
||||
services: {} as MasterConfig['services'],
|
||||
...overrides,
|
||||
} as MasterConfig;
|
||||
}
|
||||
|
||||
describe('extractBaseServiceConfig', () => {
|
||||
test('returns env, telemetry, and sentry from master config', () => {
|
||||
const master = createMasterStub({env: 'production'});
|
||||
const result = extractBaseServiceConfig(master);
|
||||
expect(result).toEqual({
|
||||
env: 'production',
|
||||
telemetry: master.telemetry,
|
||||
sentry: master.sentry,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractKVClientConfig', () => {
|
||||
test('returns kvUrl', () => {
|
||||
const master = createMasterStub();
|
||||
const result = extractKVClientConfig(master);
|
||||
expect(result).toEqual({
|
||||
kvUrl: 'redis://127.0.0.1:6379/0',
|
||||
});
|
||||
});
|
||||
|
||||
test('throws when internal is missing', () => {
|
||||
const master = createMasterStub();
|
||||
(master as Record<string, unknown>).internal = undefined;
|
||||
expect(() => extractKVClientConfig(master)).toThrow('internal configuration is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBuildInfoConfig', () => {
|
||||
test('returns releaseChannel and buildTimestamp', () => {
|
||||
const result = extractBuildInfoConfig();
|
||||
expect(result).toHaveProperty('releaseChannel');
|
||||
expect(result).toHaveProperty('buildTimestamp');
|
||||
expect(typeof result.releaseChannel).toBe('string');
|
||||
expect(typeof result.buildTimestamp).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRateLimit', () => {
|
||||
test('returns undefined for null input', () => {
|
||||
expect(extractRateLimit(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for undefined input', () => {
|
||||
expect(extractRateLimit(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined when limit is missing', () => {
|
||||
expect(extractRateLimit({window_ms: 60000})).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined when window_ms is missing', () => {
|
||||
expect(extractRateLimit({limit: 100})).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns normalised object for valid input', () => {
|
||||
const result = extractRateLimit({limit: 100, window_ms: 60000});
|
||||
expect(result).toEqual({limit: 100, windowMs: 60000});
|
||||
});
|
||||
});
|
||||
41
packages/config/src/config_loader/ConfigObjectMerge.tsx
Normal file
41
packages/config/src/config_loader/ConfigObjectMerge.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export type ConfigObject = Record<string, unknown>;
|
||||
|
||||
export function isPlainObject(value: unknown): value is ConfigObject {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function deepMerge(target: ConfigObject, source: ConfigObject): ConfigObject {
|
||||
const result = {...target};
|
||||
|
||||
for (const key in source) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
|
||||
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
||||
result[key] = deepMerge(targetValue, sourceValue);
|
||||
} else {
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
82
packages/config/src/config_loader/EnvironmentOverrides.tsx
Normal file
82
packages/config/src/config_loader/EnvironmentOverrides.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 {type ConfigObject, isPlainObject} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
|
||||
function toChildObject(value: unknown): ConfigObject {
|
||||
if (isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function parseEnvValue(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (trimmed === 'false') {
|
||||
return false;
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
return Number.parseInt(trimmed, 10);
|
||||
}
|
||||
if (/^-?\d+\.\d+$/.test(trimmed)) {
|
||||
return Number.parseFloat(trimmed);
|
||||
}
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function setNestedValue(target: ConfigObject, keys: Array<string>, value: unknown): void {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const [first, ...rest] = keys;
|
||||
if (rest.length === 0) {
|
||||
target[first] = value;
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(target[first])) {
|
||||
target[first] = {};
|
||||
}
|
||||
setNestedValue(toChildObject(target[first]), rest, value);
|
||||
}
|
||||
|
||||
export function buildEnvOverrides(env: NodeJS.ProcessEnv, prefix: string): ConfigObject {
|
||||
const overrides: ConfigObject = {};
|
||||
for (const [envKey, envValue] of Object.entries(env)) {
|
||||
if (!envKey.startsWith(prefix) || envValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const remainder = envKey.slice(prefix.length);
|
||||
if (remainder === '') {
|
||||
continue;
|
||||
}
|
||||
const path = remainder.split('__').map((segment) => segment.toLowerCase());
|
||||
setNestedValue(overrides, path, parseEnvValue(envValue));
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
35
packages/config/src/config_loader/JsonConfigReader.tsx
Normal file
35
packages/config/src/config_loader/JsonConfigReader.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {existsSync, readFileSync} from 'node:fs';
|
||||
import {type ConfigObject, isPlainObject} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
import {assertValidJsonConfig} from '@fluxer/config/src/JsonValidation';
|
||||
|
||||
export function loadJsonFile(path: string): ConfigObject {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Config file not found: ${path}`);
|
||||
}
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
if (!isPlainObject(parsed)) {
|
||||
throw new Error(`Invalid JSON: expected object at root`);
|
||||
}
|
||||
assertValidJsonConfig(parsed);
|
||||
return parsed;
|
||||
}
|
||||
460
packages/config/src/schema/bundle.ts
Normal file
460
packages/config/src/schema/bundle.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* 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 * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
interface JsonSchema {
|
||||
$schema?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
additionalProperties?: boolean | JsonSchema;
|
||||
required?: Array<string>;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
$ref?: string;
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
anyOf?: Array<JsonSchema>;
|
||||
allOf?: Array<JsonSchema>;
|
||||
oneOf?: Array<JsonSchema>;
|
||||
enum?: Array<string | number | boolean>;
|
||||
default?: unknown;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
items?: JsonSchema;
|
||||
if?: JsonSchema;
|
||||
then?: JsonSchema;
|
||||
else?: JsonSchema;
|
||||
const?: unknown;
|
||||
}
|
||||
|
||||
interface DefFile {
|
||||
[key: string]: JsonSchema;
|
||||
}
|
||||
|
||||
const SCHEMA_DIR = path.dirname(new URL(import.meta.url).pathname);
|
||||
const DEFS_DIR = path.join(SCHEMA_DIR, 'defs');
|
||||
const ROOT_SCHEMA_PATH = path.join(SCHEMA_DIR, 'root.json');
|
||||
const OUTPUT_SCHEMA_PATH = path.join(SCHEMA_DIR, '..', 'ConfigSchema.json');
|
||||
const OUTPUT_ZOD_PATH = path.join(SCHEMA_DIR, '..', 'MasterZodSchema.generated.tsx');
|
||||
|
||||
function readJsonFile<T>(filePath: string): T {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as T;
|
||||
}
|
||||
|
||||
function collectDefFiles(dir: string): Array<string> {
|
||||
const files: Array<string> = [];
|
||||
const entries = fs.readdirSync(dir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectDefFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.json')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function bundleSchema(): JsonSchema {
|
||||
const rootSchema = readJsonFile<JsonSchema>(ROOT_SCHEMA_PATH);
|
||||
const defFiles = collectDefFiles(DEFS_DIR);
|
||||
|
||||
const allDefs: Record<string, JsonSchema> = {};
|
||||
|
||||
for (const defFile of defFiles) {
|
||||
const defs = readJsonFile<DefFile>(defFile);
|
||||
for (const [name, schema] of Object.entries(defs)) {
|
||||
allDefs[name] = schema;
|
||||
}
|
||||
}
|
||||
|
||||
rootSchema.$defs = allDefs;
|
||||
stripAdditionalPropertiesFalse(rootSchema);
|
||||
|
||||
return rootSchema;
|
||||
}
|
||||
|
||||
function stripAdditionalPropertiesFalse(schema: JsonSchema): void {
|
||||
// We want configs to be forward-compatible and allow extra keys, but still document
|
||||
// the set of known properties in the schema itself.
|
||||
if (schema.additionalProperties === false) {
|
||||
delete schema.additionalProperties;
|
||||
}
|
||||
|
||||
if (schema.$defs) {
|
||||
for (const def of Object.values(schema.$defs)) {
|
||||
stripAdditionalPropertiesFalse(def);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.properties) {
|
||||
for (const prop of Object.values(schema.properties)) {
|
||||
stripAdditionalPropertiesFalse(prop);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.items) {
|
||||
stripAdditionalPropertiesFalse(schema.items);
|
||||
}
|
||||
|
||||
if (schema.if) {
|
||||
stripAdditionalPropertiesFalse(schema.if);
|
||||
}
|
||||
if (schema.then) {
|
||||
stripAdditionalPropertiesFalse(schema.then);
|
||||
}
|
||||
if (schema.else) {
|
||||
stripAdditionalPropertiesFalse(schema.else);
|
||||
}
|
||||
|
||||
if (schema.anyOf) {
|
||||
for (const sub of schema.anyOf) {
|
||||
stripAdditionalPropertiesFalse(sub);
|
||||
}
|
||||
}
|
||||
if (schema.allOf) {
|
||||
for (const sub of schema.allOf) {
|
||||
stripAdditionalPropertiesFalse(sub);
|
||||
}
|
||||
}
|
||||
if (schema.oneOf) {
|
||||
for (const sub of schema.oneOf) {
|
||||
stripAdditionalPropertiesFalse(sub);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
||||
stripAdditionalPropertiesFalse(schema.additionalProperties);
|
||||
}
|
||||
}
|
||||
|
||||
function extractRefName(ref: string): string {
|
||||
const match = ref.match(/#\/\$defs\/(.+)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
throw new Error(`Invalid $ref format: ${ref}`);
|
||||
}
|
||||
|
||||
function snakeToPascal(str: string): string {
|
||||
return str
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function buildDependencyGraph(defs: Record<string, JsonSchema>): Map<string, Set<string>> {
|
||||
const graph = new Map<string, Set<string>>();
|
||||
|
||||
for (const name of Object.keys(defs)) {
|
||||
graph.set(name, new Set());
|
||||
}
|
||||
|
||||
function collectRefs(schema: JsonSchema, currentDef: string): void {
|
||||
if (schema.$ref) {
|
||||
const refName = extractRefName(schema.$ref);
|
||||
graph.get(currentDef)?.add(refName);
|
||||
}
|
||||
|
||||
if (schema.properties) {
|
||||
for (const propSchema of Object.values(schema.properties)) {
|
||||
collectRefs(propSchema, currentDef);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.items) {
|
||||
collectRefs(schema.items, currentDef);
|
||||
}
|
||||
|
||||
if (schema.if) {
|
||||
collectRefs(schema.if, currentDef);
|
||||
}
|
||||
if (schema.then) {
|
||||
collectRefs(schema.then, currentDef);
|
||||
}
|
||||
if (schema.else) {
|
||||
collectRefs(schema.else, currentDef);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, schema] of Object.entries(defs)) {
|
||||
collectRefs(schema, name);
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
function topologicalSort(graph: Map<string, Set<string>>): Array<string> {
|
||||
const visited = new Set<string>();
|
||||
const result: Array<string> = [];
|
||||
|
||||
function visit(node: string): void {
|
||||
if (visited.has(node)) {
|
||||
return;
|
||||
}
|
||||
visited.add(node);
|
||||
|
||||
const deps = graph.get(node);
|
||||
if (deps) {
|
||||
for (const dep of deps) {
|
||||
visit(dep);
|
||||
}
|
||||
}
|
||||
|
||||
result.push(node);
|
||||
}
|
||||
|
||||
for (const node of graph.keys()) {
|
||||
visit(node);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateZodType(schema: JsonSchema, defs: Record<string, JsonSchema>, indent: string = ''): string {
|
||||
if (schema.$ref) {
|
||||
const refName = extractRefName(schema.$ref);
|
||||
return `${snakeToPascal(refName)}Schema`;
|
||||
}
|
||||
|
||||
if (schema.enum) {
|
||||
const enumValues = schema.enum.map((v) => JSON.stringify(v)).join(', ');
|
||||
return `z.enum([${enumValues}])`;
|
||||
}
|
||||
|
||||
if (schema.type === 'string') {
|
||||
return 'z.string()';
|
||||
}
|
||||
|
||||
if (schema.type === 'number' || schema.type === 'integer') {
|
||||
let result = 'z.number()';
|
||||
if (schema.minimum !== undefined) {
|
||||
result += `.min(${schema.minimum})`;
|
||||
}
|
||||
if (schema.maximum !== undefined) {
|
||||
result += `.max(${schema.maximum})`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return 'z.boolean()';
|
||||
}
|
||||
|
||||
if (schema.type === 'array') {
|
||||
if (schema.items) {
|
||||
const itemType = generateZodType(schema.items, defs, indent);
|
||||
return `z.array(${itemType})`;
|
||||
}
|
||||
return 'z.array(z.unknown())';
|
||||
}
|
||||
|
||||
if (schema.type === 'object') {
|
||||
return generateZodObject(schema, defs, indent);
|
||||
}
|
||||
|
||||
return 'z.unknown()';
|
||||
}
|
||||
|
||||
function generateZodObject(schema: JsonSchema, defs: Record<string, JsonSchema>, indent: string = ''): string {
|
||||
if (!schema.properties) {
|
||||
return 'z.object({})';
|
||||
}
|
||||
|
||||
const requiredSet = new Set(schema.required || []);
|
||||
const lines: Array<string> = [];
|
||||
const innerIndent = `${indent}\t`;
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
let propType = generateZodType(propSchema, defs, innerIndent);
|
||||
|
||||
if (propSchema.description) {
|
||||
propType += `.describe(${JSON.stringify(propSchema.description)})`;
|
||||
}
|
||||
|
||||
if (propSchema.default !== undefined) {
|
||||
if (
|
||||
propSchema.$ref &&
|
||||
typeof propSchema.default === 'object' &&
|
||||
propSchema.default !== null &&
|
||||
Object.keys(propSchema.default).length === 0
|
||||
) {
|
||||
const refName = extractRefName(propSchema.$ref);
|
||||
const schemaName = `${snakeToPascal(refName)}Schema`;
|
||||
propType += `.default(() => ${schemaName}.parse({}))`;
|
||||
} else if (
|
||||
typeof propSchema.default === 'object' &&
|
||||
propSchema.default !== null &&
|
||||
!Array.isArray(propSchema.default) &&
|
||||
Object.keys(propSchema.default).length === 0
|
||||
) {
|
||||
} else {
|
||||
propType += `.default(${JSON.stringify(propSchema.default)})`;
|
||||
}
|
||||
} else if (!requiredSet.has(propName)) {
|
||||
propType += '.optional()';
|
||||
}
|
||||
|
||||
lines.push(`${innerIndent}${propName}: ${propType},`);
|
||||
}
|
||||
|
||||
return `z.object({\n${lines.join('\n')}\n${indent}})`;
|
||||
}
|
||||
|
||||
function generateZodSchema(bundledSchema: JsonSchema): string {
|
||||
const defs = bundledSchema.$defs || {};
|
||||
|
||||
const depGraph = buildDependencyGraph(defs);
|
||||
const sortedDefs = topologicalSort(depGraph);
|
||||
|
||||
const defSchemas: Array<string> = [];
|
||||
|
||||
for (const defName of sortedDefs) {
|
||||
const defSchema = defs[defName];
|
||||
if (!defSchema) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const schemaName = `${snakeToPascal(defName)}Schema`;
|
||||
const zodType = generateZodType(defSchema, defs, '');
|
||||
|
||||
defSchemas.push(`export const ${schemaName} = ${zodType};`);
|
||||
}
|
||||
|
||||
const rootZodType = generateRootSchema(bundledSchema, defs);
|
||||
|
||||
const output = `/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
* AUTO-GENERATED FILE - DO NOT EDIT
|
||||
* Generated from ConfigSchema.json by schema/bundle.ts
|
||||
*/
|
||||
|
||||
import {z} from 'zod';
|
||||
|
||||
${defSchemas.join('\n\n')}
|
||||
|
||||
export const MasterConfigSchema = ${rootZodType};
|
||||
export type MasterConfigSchema = z.infer<typeof MasterConfigSchema>;
|
||||
|
||||
import type {DerivedEndpoints} from './EndpointDerivation';
|
||||
|
||||
export type MasterConfig = MasterConfigSchema & {
|
||||
endpoints: DerivedEndpoints;
|
||||
};
|
||||
`;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function generateRootSchema(schema: JsonSchema, defs: Record<string, JsonSchema>): string {
|
||||
if (!schema.properties) {
|
||||
return 'z.object({})';
|
||||
}
|
||||
|
||||
const requiredSet = new Set(schema.required || []);
|
||||
const lines: Array<string> = [];
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
let propType: string;
|
||||
|
||||
if (propSchema.$ref) {
|
||||
const refName = extractRefName(propSchema.$ref);
|
||||
propType = `${snakeToPascal(refName)}Schema`;
|
||||
} else if (propSchema.enum) {
|
||||
const enumValues = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
||||
propType = `z.enum([${enumValues}])`;
|
||||
} else if (propSchema.type === 'string') {
|
||||
propType = 'z.string()';
|
||||
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
propType = 'z.number()';
|
||||
if (propSchema.minimum !== undefined) {
|
||||
propType += `.min(${propSchema.minimum})`;
|
||||
}
|
||||
if (propSchema.maximum !== undefined) {
|
||||
propType += `.max(${propSchema.maximum})`;
|
||||
}
|
||||
} else if (propSchema.type === 'boolean') {
|
||||
propType = 'z.boolean()';
|
||||
} else if (propSchema.type === 'array') {
|
||||
if (propSchema.items) {
|
||||
const itemType = generateZodType(propSchema.items, defs, '\t\t');
|
||||
propType = `z.array(${itemType})`;
|
||||
} else {
|
||||
propType = 'z.array(z.unknown())';
|
||||
}
|
||||
} else if (propSchema.type === 'object') {
|
||||
propType = generateZodObject(propSchema, defs, '\t');
|
||||
} else {
|
||||
propType = 'z.unknown()';
|
||||
}
|
||||
|
||||
if (propSchema.description) {
|
||||
propType += `.describe(${JSON.stringify(propSchema.description)})`;
|
||||
}
|
||||
|
||||
if (propSchema.default !== undefined) {
|
||||
if (
|
||||
propSchema.$ref &&
|
||||
typeof propSchema.default === 'object' &&
|
||||
propSchema.default !== null &&
|
||||
Object.keys(propSchema.default).length === 0
|
||||
) {
|
||||
const refName = extractRefName(propSchema.$ref);
|
||||
const schemaName = `${snakeToPascal(refName)}Schema`;
|
||||
propType += `.default(() => ${schemaName}.parse({}))`;
|
||||
} else if (
|
||||
typeof propSchema.default === 'object' &&
|
||||
propSchema.default !== null &&
|
||||
!Array.isArray(propSchema.default) &&
|
||||
Object.keys(propSchema.default).length === 0
|
||||
) {
|
||||
} else {
|
||||
propType += `.default(${JSON.stringify(propSchema.default)})`;
|
||||
}
|
||||
} else if (!requiredSet.has(propName)) {
|
||||
propType += '.optional()';
|
||||
}
|
||||
|
||||
lines.push(`\t${propName}: ${propType},`);
|
||||
}
|
||||
|
||||
return `z.object({\n${lines.join('\n')}\n})`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
console.log('Bundling JSON Schema...');
|
||||
const bundledSchema = bundleSchema();
|
||||
|
||||
console.log(`Writing bundled schema to ${OUTPUT_SCHEMA_PATH}`);
|
||||
fs.writeFileSync(OUTPUT_SCHEMA_PATH, `${JSON.stringify(bundledSchema, null, '\t')}\n`);
|
||||
|
||||
console.log('Generating Zod schema...');
|
||||
const zodSchema = generateZodSchema(bundledSchema);
|
||||
|
||||
console.log(`Writing Zod schema to ${OUTPUT_ZOD_PATH}`);
|
||||
fs.writeFileSync(OUTPUT_ZOD_PATH, zodSchema);
|
||||
|
||||
console.log('Done!');
|
||||
}
|
||||
|
||||
main();
|
||||
201
packages/config/src/schema/defs/auth.json
Normal file
201
packages/config/src/schema/defs/auth.json
Normal file
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"passkeys": {
|
||||
"type": "object",
|
||||
"description": "WebAuthn/Passkeys relying party settings.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"rp_name": {
|
||||
"type": "string",
|
||||
"description": "Relying Party name displayed to users.",
|
||||
"default": "Fluxer"
|
||||
},
|
||||
"rp_id": {
|
||||
"type": "string",
|
||||
"description": "Relying Party ID (domain) for WebAuthn credentials.",
|
||||
"default": "fluxer.app"
|
||||
},
|
||||
"additional_allowed_origins": {
|
||||
"type": "array",
|
||||
"description": "List of allowed origins for WebAuthn registration/authentication.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["https://web.fluxer.app", "https://web.canary.fluxer.app"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bluesky_key": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kid"],
|
||||
"properties": {
|
||||
"kid": {
|
||||
"type": "string",
|
||||
"description": "JSON Web Key ID used when signing private key JWTs."
|
||||
},
|
||||
"private_key": {
|
||||
"type": "string",
|
||||
"description": "PEM-encoded private key used to authenticate to the Bluesky token endpoint."
|
||||
},
|
||||
"private_key_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute filesystem path to a PEM private key file. Must be an absolute path because services may run from different working directories. Use this instead of private_key to avoid embedding the key in the config."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": ["private_key"]
|
||||
},
|
||||
{
|
||||
"required": ["private_key_path"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bluesky": {
|
||||
"type": "object",
|
||||
"description": "Bluesky OAuth client configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether Bluesky OAuth connections are enabled.",
|
||||
"default": true
|
||||
},
|
||||
"client_name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable client name exposed to Bluesky.",
|
||||
"default": "Fluxer"
|
||||
},
|
||||
"client_uri": {
|
||||
"type": "string",
|
||||
"description": "URI describing the client application.",
|
||||
"default": ""
|
||||
},
|
||||
"logo_uri": {
|
||||
"type": "string",
|
||||
"description": "Optional logo presented during authorization.",
|
||||
"default": "https://fluxerstatic.com/web/apple-touch-icon.png"
|
||||
},
|
||||
"tos_uri": {
|
||||
"type": "string",
|
||||
"description": "Terms of service URI exposed to Bluesky.",
|
||||
"default": "https://fluxer.app/terms"
|
||||
},
|
||||
"policy_uri": {
|
||||
"type": "string",
|
||||
"description": "Privacy policy URI exposed to Bluesky.",
|
||||
"default": "https://fluxer.app/privacy"
|
||||
},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"description": "Key definitions used to sign private key JWT assertions.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/bluesky_key"
|
||||
},
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"enabled": true,
|
||||
"client_name": "Fluxer",
|
||||
"client_uri": "",
|
||||
"logo_uri": "https://fluxerstatic.com/web/apple-touch-icon.png",
|
||||
"tos_uri": "https://fluxer.app/terms",
|
||||
"policy_uri": "https://fluxer.app/privacy",
|
||||
"keys": []
|
||||
}
|
||||
},
|
||||
"vapid": {
|
||||
"type": "object",
|
||||
"description": "VAPID keys for Web Push notifications.",
|
||||
"additionalProperties": false,
|
||||
"required": ["public_key", "private_key"],
|
||||
"properties": {
|
||||
"public_key": {
|
||||
"type": "string",
|
||||
"description": "VAPID Public Key."
|
||||
},
|
||||
"private_key": {
|
||||
"type": "string",
|
||||
"description": "VAPID Private Key."
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Contact email included in push service requests.",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"description": "Global authentication configuration.",
|
||||
"additionalProperties": false,
|
||||
"required": ["vapid", "sudo_mode_secret", "connection_initiation_secret"],
|
||||
"properties": {
|
||||
"sudo_mode_secret": {
|
||||
"type": "string",
|
||||
"description": "Secret key for verifying sudo mode tokens."
|
||||
},
|
||||
"connection_initiation_secret": {
|
||||
"type": "string",
|
||||
"description": "Secret key for signing connection initiation tokens."
|
||||
},
|
||||
"passkeys": {
|
||||
"description": "Passkey configuration.",
|
||||
"$ref": "#/$defs/passkeys",
|
||||
"default": {}
|
||||
},
|
||||
"vapid": {
|
||||
"description": "Web Push VAPID configuration.",
|
||||
"$ref": "#/$defs/vapid"
|
||||
},
|
||||
"bluesky": {
|
||||
"description": "Bluesky OAuth client configuration.",
|
||||
"$ref": "#/$defs/bluesky",
|
||||
"default": {
|
||||
"enabled": true,
|
||||
"client_name": "Fluxer",
|
||||
"client_uri": "",
|
||||
"logo_uri": "https://fluxerstatic.com/web/apple-touch-icon.png",
|
||||
"tos_uri": "https://fluxer.app/terms",
|
||||
"policy_uri": "https://fluxer.app/privacy",
|
||||
"keys": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookie": {
|
||||
"type": "object",
|
||||
"description": "Session cookie configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "Domain attribute for cookies. Leave empty for host-only.",
|
||||
"default": ""
|
||||
},
|
||||
"secure": {
|
||||
"type": "boolean",
|
||||
"description": "If true, sets the Secure flag on cookies.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"gateway_connection": {
|
||||
"type": "object",
|
||||
"description": "Configuration for backend services to call the Gateway via RPC.",
|
||||
"additionalProperties": false,
|
||||
"required": ["rpc_secret"],
|
||||
"properties": {
|
||||
"rpc_endpoint": {
|
||||
"type": "string",
|
||||
"description": "Gateway RPC endpoint URL (e.g. http://gateway:8080).",
|
||||
"default": "http://127.0.0.1:8088"
|
||||
},
|
||||
"rpc_secret": {
|
||||
"type": "string",
|
||||
"description": "Shared secret for authenticating RPC calls to the Gateway."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/config/src/schema/defs/common.json
Normal file
19
packages/config/src/schema/defs/common.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"rate_limit": {
|
||||
"type": "object",
|
||||
"description": "Rate limiting parameters.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of requests allowed within the window.",
|
||||
"minimum": 1
|
||||
},
|
||||
"window_ms": {
|
||||
"type": "number",
|
||||
"description": "Time window in milliseconds.",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
packages/config/src/schema/defs/database.json
Normal file
127
packages/config/src/schema/defs/database.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"cassandra": {
|
||||
"type": "object",
|
||||
"description": "Cassandra connection details.",
|
||||
"additionalProperties": false,
|
||||
"required": ["hosts", "keyspace", "local_dc", "username", "password"],
|
||||
"properties": {
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"description": "Array of Cassandra contact points (hostnames or IPs).",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"keyspace": {
|
||||
"type": "string",
|
||||
"description": "Cassandra keyspace name."
|
||||
},
|
||||
"local_dc": {
|
||||
"type": "string",
|
||||
"description": "Local Data Center name for topology awareness."
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Cassandra authentication username."
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Cassandra authentication password."
|
||||
}
|
||||
}
|
||||
},
|
||||
"database": {
|
||||
"type": "object",
|
||||
"description": "Database backend selection and configuration.",
|
||||
"additionalProperties": false,
|
||||
"required": ["backend"],
|
||||
"properties": {
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"description": "Selected database backend. 'sqlite' is for dev/single-node, 'cassandra' for production.",
|
||||
"enum": ["cassandra", "sqlite"]
|
||||
},
|
||||
"cassandra": {
|
||||
"description": "Configuration settings for Cassandra backend.",
|
||||
"$ref": "#/$defs/cassandra"
|
||||
},
|
||||
"sqlite_path": {
|
||||
"type": "string",
|
||||
"description": "Filesystem path to the SQLite database file.",
|
||||
"default": "./data/fluxer.db"
|
||||
}
|
||||
}
|
||||
},
|
||||
"s3_buckets": {
|
||||
"type": "object",
|
||||
"description": "Configuration of specific S3 bucket names.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cdn": {
|
||||
"type": "string",
|
||||
"description": "Bucket for CDN assets.",
|
||||
"default": "fluxer"
|
||||
},
|
||||
"uploads": {
|
||||
"type": "string",
|
||||
"description": "Bucket for user uploads.",
|
||||
"default": "fluxer-uploads"
|
||||
},
|
||||
"downloads": {
|
||||
"type": "string",
|
||||
"description": "Bucket for downloads.",
|
||||
"default": "fluxer-downloads"
|
||||
},
|
||||
"reports": {
|
||||
"type": "string",
|
||||
"description": "Bucket for report data.",
|
||||
"default": "fluxer-reports"
|
||||
},
|
||||
"harvests": {
|
||||
"type": "string",
|
||||
"description": "Bucket for data harvests.",
|
||||
"default": "fluxer-harvests"
|
||||
},
|
||||
"static": {
|
||||
"type": "string",
|
||||
"description": "Bucket for static site assets.",
|
||||
"default": "fluxer-static"
|
||||
}
|
||||
}
|
||||
},
|
||||
"s3": {
|
||||
"type": "object",
|
||||
"description": "S3 connection configuration.",
|
||||
"additionalProperties": false,
|
||||
"required": ["access_key_id", "secret_access_key"],
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"description": "S3 service endpoint URL.",
|
||||
"default": "http://localhost:3900"
|
||||
},
|
||||
"presigned_url_base": {
|
||||
"type": "string",
|
||||
"description": "Base URL for presigned download URLs. If not set, defaults to the endpoint value. Set this to a public URL when the endpoint is internal."
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "S3 region.",
|
||||
"default": "local"
|
||||
},
|
||||
"access_key_id": {
|
||||
"type": "string",
|
||||
"description": "S3 Access Key ID."
|
||||
},
|
||||
"secret_access_key": {
|
||||
"type": "string",
|
||||
"description": "S3 Secret Access Key."
|
||||
},
|
||||
"buckets": {
|
||||
"description": "Mapping of logical buckets to actual S3 bucket names.",
|
||||
"$ref": "#/$defs/s3_buckets",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
packages/config/src/schema/defs/domain.json
Normal file
120
packages/config/src/schema/defs/domain.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"domain": {
|
||||
"type": "object",
|
||||
"description": "Configuration for domains and ports used to construct public URLs.",
|
||||
"additionalProperties": false,
|
||||
"required": ["base_domain"],
|
||||
"properties": {
|
||||
"base_domain": {
|
||||
"type": "string",
|
||||
"description": "The primary domain name (e.g., example.com, localhost)."
|
||||
},
|
||||
"public_scheme": {
|
||||
"type": "string",
|
||||
"description": "The URL scheme for public endpoints.",
|
||||
"enum": ["http", "https"],
|
||||
"default": "http"
|
||||
},
|
||||
"internal_scheme": {
|
||||
"type": "string",
|
||||
"description": "The URL scheme for internal endpoints.",
|
||||
"enum": ["http", "https"],
|
||||
"default": "http"
|
||||
},
|
||||
"public_port": {
|
||||
"type": "number",
|
||||
"description": "The public-facing port number.",
|
||||
"default": 8088
|
||||
},
|
||||
"internal_port": {
|
||||
"type": "number",
|
||||
"description": "The internal port number.",
|
||||
"default": 8088
|
||||
},
|
||||
"static_cdn_domain": {
|
||||
"type": "string",
|
||||
"description": "Separate domain for static CDN assets (optional).",
|
||||
"default": "fluxerstatic.com"
|
||||
},
|
||||
"invite_domain": {
|
||||
"type": "string",
|
||||
"description": "Domain for short invite links (optional).",
|
||||
"default": ""
|
||||
},
|
||||
"gift_domain": {
|
||||
"type": "string",
|
||||
"description": "Domain for gift links (optional).",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"endpoint_overrides": {
|
||||
"type": "object",
|
||||
"description": "Explicit overrides for service endpoints. Use these if derived URLs are incorrect.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"api": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the API endpoint."
|
||||
},
|
||||
"api_client": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the client-facing API endpoint."
|
||||
},
|
||||
"app": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the Web App endpoint."
|
||||
},
|
||||
"gateway": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the Gateway (WebSocket) endpoint."
|
||||
},
|
||||
"media": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the Media endpoint."
|
||||
},
|
||||
"static_cdn": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the Static CDN endpoint."
|
||||
},
|
||||
"admin": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the Admin Panel endpoint."
|
||||
},
|
||||
"marketing": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for the Marketing Site endpoint."
|
||||
},
|
||||
"invite": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for Invite links."
|
||||
},
|
||||
"gift": {
|
||||
"type": "string",
|
||||
"description": "Full URL override for Gift links."
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"type": "object",
|
||||
"description": "Direct internal endpoints for backend services. In monolith mode these are served via paths on the main server.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kv": {
|
||||
"type": "string",
|
||||
"description": "Internal Valkey/Redis URL for key-value operations.",
|
||||
"default": "redis://localhost:6379/0"
|
||||
},
|
||||
"queue": {
|
||||
"type": "string",
|
||||
"description": "Internal URL for the Queue service.",
|
||||
"default": "http://localhost:8088/queue"
|
||||
},
|
||||
"media_proxy": {
|
||||
"type": "string",
|
||||
"description": "Internal URL for the Media Proxy service.",
|
||||
"default": "http://localhost:8088/media"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
147
packages/config/src/schema/defs/instance.json
Normal file
147
packages/config/src/schema/defs/instance.json
Normal file
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"instance": {
|
||||
"type": "object",
|
||||
"description": "Specific settings for this Fluxer instance.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"deployment_mode": {
|
||||
"type": "string",
|
||||
"description": "Deployment mode. 'monolith' runs all services in one process (fluxer_server). 'microservices' requires separate processes/ports.",
|
||||
"enum": ["monolith", "microservices"],
|
||||
"default": "monolith"
|
||||
},
|
||||
"self_hosted": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Indicates if this is a self-hosted instance."
|
||||
},
|
||||
"auto_join_invite_code": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Invite code to auto-join users to a guild upon registration."
|
||||
},
|
||||
"visionaries_guild_id": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Guild ID for Visionary members."
|
||||
},
|
||||
"operators_guild_id": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Guild ID for Operators."
|
||||
},
|
||||
"private_key_path": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Path to the x25519 private key for E2E encryption (generated on first startup if missing)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"federation": {
|
||||
"type": "object",
|
||||
"description": "Federation configuration for connecting with other Fluxer instances.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable federation with other Fluxer instances."
|
||||
}
|
||||
}
|
||||
},
|
||||
"csam": {
|
||||
"type": "object",
|
||||
"description": "CSAM compliance configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"evidence_retention_days": {
|
||||
"type": "number",
|
||||
"default": 730,
|
||||
"description": "Days to retain evidence."
|
||||
},
|
||||
"job_retention_days": {
|
||||
"type": "number",
|
||||
"default": 365,
|
||||
"description": "Days to retain reporting jobs."
|
||||
},
|
||||
"cleanup_batch_size": {
|
||||
"type": "number",
|
||||
"default": 100,
|
||||
"description": "Batch size for cleanup operations."
|
||||
},
|
||||
"queue": {
|
||||
"type": "object",
|
||||
"description": "CSAM scan queue configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"timeout_ms": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"description": "Maximum time to wait for a scan result (ms)."
|
||||
},
|
||||
"max_entries_per_batch": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"description": "Maximum queue entries to process per consumer run."
|
||||
},
|
||||
"consumer_lock_ttl_seconds": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"description": "TTL for consumer lock (seconds)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dev": {
|
||||
"type": "object",
|
||||
"description": "Development environment flags.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"relax_registration_rate_limits": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Relax rate limits for registration."
|
||||
},
|
||||
"disable_rate_limits": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all rate limits."
|
||||
},
|
||||
"test_mode_enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable test mode behaviors."
|
||||
},
|
||||
"test_harness_token": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Token for the test harness."
|
||||
}
|
||||
}
|
||||
},
|
||||
"geoip": {
|
||||
"type": "object",
|
||||
"description": "GeoIP database settings.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"maxmind_db_path": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Path to MaxMind GeoIP database."
|
||||
}
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"type": "object",
|
||||
"description": "Proxy configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"trust_cf_connecting_ip": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Trust Cloudflare's CF-Connecting-IP header."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
packages/config/src/schema/defs/integrations/captcha.json
Normal file
47
packages/config/src/schema/defs/integrations/captcha.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"captcha_provider": {
|
||||
"type": "object",
|
||||
"description": "Configuration for a specific CAPTCHA provider.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"site_key": {
|
||||
"type": "string",
|
||||
"description": "Public site key.",
|
||||
"default": ""
|
||||
},
|
||||
"secret_key": {
|
||||
"type": "string",
|
||||
"description": "Secret key for server-side verification.",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"captcha_integration": {
|
||||
"type": "object",
|
||||
"description": "CAPTCHA service integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable CAPTCHA verification.",
|
||||
"default": false
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Selected CAPTCHA provider.",
|
||||
"enum": ["hcaptcha", "turnstile", "none"],
|
||||
"default": "none"
|
||||
},
|
||||
"hcaptcha": {
|
||||
"description": "hCaptcha settings.",
|
||||
"$ref": "#/$defs/captcha_provider",
|
||||
"default": {}
|
||||
},
|
||||
"turnstile": {
|
||||
"description": "Cloudflare Turnstile settings.",
|
||||
"$ref": "#/$defs/captcha_provider",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
packages/config/src/schema/defs/integrations/clamav.json
Normal file
29
packages/config/src/schema/defs/integrations/clamav.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"clamav_integration": {
|
||||
"type": "object",
|
||||
"description": "ClamAV antivirus integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable ClamAV scanning."
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"default": "clamav",
|
||||
"description": "ClamAV host."
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"default": 3310,
|
||||
"description": "ClamAV port."
|
||||
},
|
||||
"fail_open": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "If true, allow files if scanning fails."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/config/src/schema/defs/integrations/cloudflare.json
Normal file
24
packages/config/src/schema/defs/integrations/cloudflare.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"cloudflare": {
|
||||
"type": "object",
|
||||
"description": "Cloudflare integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"purge_enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable automatic cache purging."
|
||||
},
|
||||
"zone_id": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Cloudflare Zone ID."
|
||||
},
|
||||
"api_token": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Cloudflare API token for cache purge."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
packages/config/src/schema/defs/integrations/csam.json
Normal file
94
packages/config/src/schema/defs/integrations/csam.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"csam_integration": {
|
||||
"type": "object",
|
||||
"description": "Unified CSAM scanning integration configuration supporting multiple providers.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable CSAM scanning."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["photo_dna", "arachnid_shield"],
|
||||
"default": "photo_dna",
|
||||
"description": "The CSAM scanning provider to use."
|
||||
},
|
||||
"photo_dna": {
|
||||
"type": "object",
|
||||
"description": "PhotoDNA provider configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"hash_service_url": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "URL for the hash generation service."
|
||||
},
|
||||
"hash_service_timeout_ms": {
|
||||
"type": "number",
|
||||
"default": 15000,
|
||||
"description": "Timeout for hash generation in milliseconds."
|
||||
},
|
||||
"match_endpoint": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "URL for the PhotoDNA match service."
|
||||
},
|
||||
"subscription_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Subscription key for the match service."
|
||||
},
|
||||
"match_enhance": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable enhanced matching."
|
||||
},
|
||||
"rate_limit_rps": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"description": "Rate limit requests per second."
|
||||
}
|
||||
}
|
||||
},
|
||||
"arachnid_shield": {
|
||||
"type": "object",
|
||||
"description": "Arachnid Shield provider configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"default": "https://shield.projectarachnid.com/v1/media",
|
||||
"description": "Arachnid Shield API endpoint."
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Basic auth username."
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Basic auth password."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"description": "Request timeout in milliseconds."
|
||||
},
|
||||
"max_retries": {
|
||||
"type": "number",
|
||||
"default": 3,
|
||||
"description": "Maximum number of retry attempts."
|
||||
},
|
||||
"retry_backoff_ms": {
|
||||
"type": "number",
|
||||
"default": 1000,
|
||||
"description": "Base backoff time for retries in milliseconds."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
packages/config/src/schema/defs/integrations/email.json
Normal file
81
packages/config/src/schema/defs/integrations/email.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"smtp_email": {
|
||||
"type": "object",
|
||||
"description": "SMTP transport configuration for email delivery.",
|
||||
"additionalProperties": false,
|
||||
"required": ["host", "port", "username", "password"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "SMTP server hostname."
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "SMTP port number.",
|
||||
"default": 587
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "SMTP authentication username."
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "SMTP authentication password."
|
||||
},
|
||||
"secure": {
|
||||
"type": "boolean",
|
||||
"description": "Use TLS when connecting to the SMTP server.",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"email_integration": {
|
||||
"type": "object",
|
||||
"description": "Email delivery service integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable email sending.",
|
||||
"default": false
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Email provider selection.",
|
||||
"enum": ["smtp", "none"],
|
||||
"default": "none"
|
||||
},
|
||||
"webhook_secret": {
|
||||
"type": "string",
|
||||
"description": "SendGrid signed event webhook public key (PEM or base64-encoded DER)."
|
||||
},
|
||||
"from_email": {
|
||||
"type": "string",
|
||||
"description": "Default sender email address.",
|
||||
"default": ""
|
||||
},
|
||||
"from_name": {
|
||||
"type": "string",
|
||||
"description": "Default sender name.",
|
||||
"default": "Fluxer"
|
||||
},
|
||||
"smtp": {
|
||||
"$ref": "#/$defs/smtp_email"
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"required": ["enabled", "provider"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
},
|
||||
"provider": {
|
||||
"const": "smtp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["smtp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/config/src/schema/defs/integrations/gif.json
Normal file
15
packages/config/src/schema/defs/integrations/gif.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"gif": {
|
||||
"type": "object",
|
||||
"description": "GIF provider selection for the client-facing GIF picker.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["klipy", "tenor"],
|
||||
"default": "klipy",
|
||||
"description": "GIF provider to use for GIF search and sharing."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"integrations": {
|
||||
"type": "object",
|
||||
"description": "Collection of all external service integrations.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"email": {
|
||||
"$ref": "#/$defs/email_integration",
|
||||
"default": {}
|
||||
},
|
||||
"sms": {
|
||||
"$ref": "#/$defs/sms_integration",
|
||||
"default": {}
|
||||
},
|
||||
"captcha": {
|
||||
"$ref": "#/$defs/captcha_integration",
|
||||
"default": {}
|
||||
},
|
||||
"voice": {
|
||||
"$ref": "#/$defs/voice_integration",
|
||||
"default": {}
|
||||
},
|
||||
"search": {
|
||||
"$ref": "#/$defs/search_integration",
|
||||
"default": {}
|
||||
},
|
||||
"stripe": {
|
||||
"$ref": "#/$defs/stripe_integration",
|
||||
"default": {}
|
||||
},
|
||||
"photo_dna": {
|
||||
"$ref": "#/$defs/photo_dna_integration",
|
||||
"default": {}
|
||||
},
|
||||
"ncmec": {
|
||||
"$ref": "#/$defs/ncmec_integration",
|
||||
"default": {}
|
||||
},
|
||||
"clamav": {
|
||||
"$ref": "#/$defs/clamav_integration",
|
||||
"default": {}
|
||||
},
|
||||
"gif": {
|
||||
"$ref": "#/$defs/gif",
|
||||
"default": {}
|
||||
},
|
||||
"klipy": {
|
||||
"$ref": "#/$defs/klipy",
|
||||
"default": {}
|
||||
},
|
||||
"tenor": {
|
||||
"$ref": "#/$defs/tenor",
|
||||
"default": {}
|
||||
},
|
||||
"youtube": {
|
||||
"$ref": "#/$defs/youtube",
|
||||
"default": {}
|
||||
},
|
||||
"cloudflare": {
|
||||
"$ref": "#/$defs/cloudflare",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/config/src/schema/defs/integrations/klipy.json
Normal file
14
packages/config/src/schema/defs/integrations/klipy.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"klipy": {
|
||||
"type": "object",
|
||||
"description": "KLIPY GIF API integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "KLIPY API Key."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
packages/config/src/schema/defs/integrations/ncmec.json
Normal file
29
packages/config/src/schema/defs/integrations/ncmec.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"ncmec_integration": {
|
||||
"type": "object",
|
||||
"description": "NCMEC CyberTipline integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable NCMEC reporting."
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Base URL for the CyberTipline Reporting API (e.g., https://report.cybertip.org/ispws)."
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Username for CyberTipline basic authentication."
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Password for CyberTipline basic authentication."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
packages/config/src/schema/defs/integrations/photo_dna.json
Normal file
44
packages/config/src/schema/defs/integrations/photo_dna.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"photo_dna_integration": {
|
||||
"type": "object",
|
||||
"description": "PhotoDNA integration for hash matching.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable PhotoDNA."
|
||||
},
|
||||
"hash_service_url": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "URL for the hash generation service."
|
||||
},
|
||||
"hash_service_timeout_ms": {
|
||||
"type": "number",
|
||||
"default": 15000,
|
||||
"description": "Timeout for hash generation."
|
||||
},
|
||||
"match_endpoint": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "URL for the match service."
|
||||
},
|
||||
"subscription_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Subscription key for the match service."
|
||||
},
|
||||
"match_enhance": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable enhanced matching."
|
||||
},
|
||||
"rate_limit_rps": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"description": "Rate limit requests per second."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/config/src/schema/defs/integrations/search.json
Normal file
19
packages/config/src/schema/defs/integrations/search.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"search_integration": {
|
||||
"type": "object",
|
||||
"description": "Search engine integration (Meilisearch). Fluxer always uses Meilisearch for indexing and querying.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Meilisearch HTTP API URL.",
|
||||
"default": "http://127.0.0.1:7700"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "Meilisearch API key used by the API for index management and writes. Use a key with access to documents and settings."
|
||||
}
|
||||
},
|
||||
"required": ["url", "api_key"]
|
||||
}
|
||||
}
|
||||
37
packages/config/src/schema/defs/integrations/sms.json
Normal file
37
packages/config/src/schema/defs/integrations/sms.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"sms_integration": {
|
||||
"type": "object",
|
||||
"description": "SMS service integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable SMS sending.",
|
||||
"default": false
|
||||
},
|
||||
"account_sid": {
|
||||
"type": "string",
|
||||
"description": "Twilio account SID."
|
||||
},
|
||||
"auth_token": {
|
||||
"type": "string",
|
||||
"description": "Twilio auth token."
|
||||
},
|
||||
"verify_service_sid": {
|
||||
"type": "string",
|
||||
"description": "Twilio Verify service SID."
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["account_sid", "auth_token", "verify_service_sid"]
|
||||
}
|
||||
}
|
||||
}
|
||||
104
packages/config/src/schema/defs/integrations/stripe.json
Normal file
104
packages/config/src/schema/defs/integrations/stripe.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"stripe_prices": {
|
||||
"type": "object",
|
||||
"description": "Stripe Price IDs for subscription products.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"monthly_usd": {
|
||||
"type": "string",
|
||||
"description": "Monthly subscription USD price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"monthly_eur": {
|
||||
"type": "string",
|
||||
"description": "Monthly subscription EUR price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"yearly_usd": {
|
||||
"type": "string",
|
||||
"description": "Yearly subscription USD price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"yearly_eur": {
|
||||
"type": "string",
|
||||
"description": "Yearly subscription EUR price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"visionary_usd": {
|
||||
"type": "string",
|
||||
"description": "Visionary tier USD price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"visionary_eur": {
|
||||
"type": "string",
|
||||
"description": "Visionary tier EUR price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"gift_visionary_usd": {
|
||||
"type": "string",
|
||||
"description": "Gift Visionary USD price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"gift_visionary_eur": {
|
||||
"type": "string",
|
||||
"description": "Gift Visionary EUR price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"gift_1_month_usd": {
|
||||
"type": "string",
|
||||
"description": "Gift 1 Month USD price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"gift_1_month_eur": {
|
||||
"type": "string",
|
||||
"description": "Gift 1 Month EUR price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"gift_1_year_usd": {
|
||||
"type": "string",
|
||||
"description": "Gift 1 Year USD price ID.",
|
||||
"default": ""
|
||||
},
|
||||
"gift_1_year_eur": {
|
||||
"type": "string",
|
||||
"description": "Gift 1 Year EUR price ID.",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"stripe_integration": {
|
||||
"type": "object",
|
||||
"description": "Stripe payments integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable Stripe payments.",
|
||||
"default": false
|
||||
},
|
||||
"secret_key": {
|
||||
"type": "string",
|
||||
"description": "Stripe Secret Key."
|
||||
},
|
||||
"webhook_secret": {
|
||||
"type": "string",
|
||||
"description": "Stripe Webhook Signing Secret."
|
||||
},
|
||||
"prices": {
|
||||
"description": "Stripe Price ID configuration.",
|
||||
"$ref": "#/$defs/stripe_prices"
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["secret_key", "webhook_secret"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/config/src/schema/defs/integrations/tenor.json
Normal file
14
packages/config/src/schema/defs/integrations/tenor.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"tenor": {
|
||||
"type": "object",
|
||||
"description": "Tenor GIF API integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Tenor API key."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/config/src/schema/defs/integrations/voice.json
Normal file
71
packages/config/src/schema/defs/integrations/voice.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"voice_integration": {
|
||||
"type": "object",
|
||||
"description": "Real-time voice/video integration (LiveKit).",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable voice/video features.",
|
||||
"default": false
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "LiveKit API Key used for config-driven default_region bootstrap. Optional when voice topology is managed in the admin panel."
|
||||
},
|
||||
"api_secret": {
|
||||
"type": "string",
|
||||
"description": "LiveKit API Secret used for config-driven default_region bootstrap. Optional when voice topology is managed in the admin panel."
|
||||
},
|
||||
"webhook_url": {
|
||||
"type": "string",
|
||||
"description": "URL for LiveKit webhooks.",
|
||||
"default": ""
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "LiveKit Server URL (client signal endpoint for WebSocket connections).",
|
||||
"default": ""
|
||||
},
|
||||
"default_region": {
|
||||
"type": "object",
|
||||
"description": "Default voice region to create on startup if none exist. When provided, automatically creates this region and a server pointing to the configured LiveKit URL.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for the region (e.g. 'default', 'eu-west')."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name for the region."
|
||||
},
|
||||
"emoji": {
|
||||
"type": "string",
|
||||
"description": "Emoji icon for the region (e.g. '🌐', '🇪🇺')."
|
||||
},
|
||||
"latitude": {
|
||||
"type": "number",
|
||||
"description": "Latitude coordinate for the region."
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number",
|
||||
"description": "Longitude coordinate for the region."
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "emoji", "latitude", "longitude"]
|
||||
}
|
||||
},
|
||||
"if": {
|
||||
"required": ["enabled", "default_region"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["api_key", "api_secret"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
packages/config/src/schema/defs/integrations/youtube.json
Normal file
14
packages/config/src/schema/defs/integrations/youtube.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"youtube": {
|
||||
"type": "object",
|
||||
"description": "YouTube API integration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "YouTube API Key."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
packages/config/src/schema/defs/services/admin.json
Normal file
32
packages/config/src/schema/defs/services/admin.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"admin_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the Admin Panel service.",
|
||||
"additionalProperties": false,
|
||||
"required": ["secret_key_base", "oauth_client_secret"],
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 3001
|
||||
},
|
||||
"secret_key_base": {
|
||||
"type": "string",
|
||||
"description": "Base secret key for signing admin session tokens."
|
||||
},
|
||||
"base_path": {
|
||||
"type": "string",
|
||||
"description": "URL base path for the admin interface.",
|
||||
"default": "/admin"
|
||||
},
|
||||
"oauth_client_secret": {
|
||||
"type": "string",
|
||||
"description": "OAuth Client Secret for admin authentication."
|
||||
},
|
||||
"rate_limit": {
|
||||
"description": "Rate limiting configuration for the Admin service.",
|
||||
"$ref": "#/$defs/rate_limit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/config/src/schema/defs/services/api.json
Normal file
27
packages/config/src/schema/defs/services/api.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"api_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the main API service.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 8080
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to.",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"unfurl_ignored_hosts": {
|
||||
"type": "array",
|
||||
"description": "List of hostnames or IPs to ignore when unfurling URLs.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["localhost", "127.0.0.1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
packages/config/src/schema/defs/services/app_proxy.json
Normal file
93
packages/config/src/schema/defs/services/app_proxy.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"app_proxy_kv": {
|
||||
"type": "object",
|
||||
"description": "Valkey/Redis settings for the App Proxy.",
|
||||
"additionalProperties": false,
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Full URL to Valkey/Redis."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "number",
|
||||
"description": "Request timeout for Valkey/Redis in milliseconds.",
|
||||
"default": 5000
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_proxy_sentry_rate_limit": {
|
||||
"type": "object",
|
||||
"description": "Rate limiting for Sentry error reporting requests.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Number of Sentry requests allowed per window.",
|
||||
"default": 100
|
||||
},
|
||||
"window_ms": {
|
||||
"type": "number",
|
||||
"description": "Time window for Sentry rate limiting in milliseconds.",
|
||||
"default": 1000
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_proxy_rate_limit": {
|
||||
"type": "object",
|
||||
"description": "Rate limit settings for the App Proxy.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"sentry": {
|
||||
"description": "Sentry reporting rate limit configuration.",
|
||||
"$ref": "#/$defs/app_proxy_sentry_rate_limit",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_proxy_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the App Proxy service (frontend server).",
|
||||
"additionalProperties": false,
|
||||
"required": ["sentry_report_host", "sentry_dsn"],
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 8773
|
||||
},
|
||||
"static_cdn_endpoint": {
|
||||
"type": "string",
|
||||
"description": "URL endpoint for serving static assets via CDN.",
|
||||
"default": ""
|
||||
},
|
||||
"sentry_proxy_path": {
|
||||
"type": "string",
|
||||
"description": "URL path for proxying Sentry requests.",
|
||||
"default": "/error-reporting-proxy"
|
||||
},
|
||||
"sentry_report_host": {
|
||||
"type": "string",
|
||||
"description": "Hostname to which Sentry reports should be sent."
|
||||
},
|
||||
"sentry_dsn": {
|
||||
"type": "string",
|
||||
"description": "Sentry DSN (Data Source Name) for frontend error tracking."
|
||||
},
|
||||
"assets_dir": {
|
||||
"type": "string",
|
||||
"description": "Filesystem directory containing static assets.",
|
||||
"default": "./assets"
|
||||
},
|
||||
"kv": {
|
||||
"description": "Valkey/Redis configuration for the proxy.",
|
||||
"$ref": "#/$defs/app_proxy_kv"
|
||||
},
|
||||
"rate_limit": {
|
||||
"description": "Rate limiting configuration for the App Proxy.",
|
||||
"$ref": "#/$defs/app_proxy_rate_limit",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
packages/config/src/schema/defs/services/gateway.json
Normal file
112
packages/config/src/schema/defs/services/gateway.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"gateway_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the Gateway service (WebSocket).",
|
||||
"additionalProperties": false,
|
||||
"required": ["api_host", "admin_reload_secret", "media_proxy_endpoint"],
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 8771
|
||||
},
|
||||
"rpc_tcp_port": {
|
||||
"type": "number",
|
||||
"description": "Port for API-to-Gateway internal RPC over TCP.",
|
||||
"default": 8772
|
||||
},
|
||||
"api_host": {
|
||||
"type": "string",
|
||||
"description": "Host/Port of the API service to communicate with."
|
||||
},
|
||||
"api_canary_host": {
|
||||
"type": "string",
|
||||
"description": "Host/Port of the Canary API service (optional).",
|
||||
"default": ""
|
||||
},
|
||||
"admin_reload_secret": {
|
||||
"type": "string",
|
||||
"description": "Secret used to trigger code hot-swapping/reloads."
|
||||
},
|
||||
"identify_rate_limit_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable rate limiting for Gateway IDENTIFY opcodes.",
|
||||
"default": false
|
||||
},
|
||||
"push_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable push notification delivery.",
|
||||
"default": true
|
||||
},
|
||||
"push_user_guild_settings_cache_mb": {
|
||||
"type": "number",
|
||||
"description": "Memory cache size (MB) for user guild settings.",
|
||||
"default": 1024
|
||||
},
|
||||
"push_subscriptions_cache_mb": {
|
||||
"type": "number",
|
||||
"description": "Memory cache size (MB) for push subscriptions.",
|
||||
"default": 1024
|
||||
},
|
||||
"push_blocked_ids_cache_mb": {
|
||||
"type": "number",
|
||||
"description": "Memory cache size (MB) for blocked user IDs.",
|
||||
"default": 1024
|
||||
},
|
||||
"push_badge_counts_cache_mb": {
|
||||
"type": "number",
|
||||
"description": "Memory cache size (MB) for badge counts.",
|
||||
"default": 256
|
||||
},
|
||||
"push_badge_counts_cache_ttl_seconds": {
|
||||
"type": "number",
|
||||
"description": "TTL in seconds for badge counts cache.",
|
||||
"default": 60
|
||||
},
|
||||
"media_proxy_endpoint": {
|
||||
"type": "string",
|
||||
"description": "Endpoint URL of the Media Proxy service."
|
||||
},
|
||||
"logger_level": {
|
||||
"type": "string",
|
||||
"description": "Logging level (e.g., debug, info, warn, error).",
|
||||
"default": "info"
|
||||
},
|
||||
"release_node": {
|
||||
"type": "string",
|
||||
"description": "Erlang node name for the release.",
|
||||
"default": "fluxer_gateway@gateway"
|
||||
},
|
||||
"gateway_metrics_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable collection of gateway metrics.",
|
||||
"default": false
|
||||
},
|
||||
"gateway_metrics_report_interval_ms": {
|
||||
"type": "number",
|
||||
"description": "Interval in milliseconds to report gateway metrics.",
|
||||
"default": 30000
|
||||
},
|
||||
"presence_cache_shards": {
|
||||
"type": "number",
|
||||
"description": "Number of shards for presence cache.",
|
||||
"default": 1
|
||||
},
|
||||
"presence_bus_shards": {
|
||||
"type": "number",
|
||||
"description": "Number of shards for presence message bus.",
|
||||
"default": 1
|
||||
},
|
||||
"presence_shards": {
|
||||
"type": "number",
|
||||
"description": "Number of shards for presence handling.",
|
||||
"default": 1
|
||||
},
|
||||
"guild_shards": {
|
||||
"type": "number",
|
||||
"description": "Number of shards for guild handling.",
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
packages/config/src/schema/defs/services/marketing.json
Normal file
34
packages/config/src/schema/defs/services/marketing.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"marketing_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the Marketing site service.",
|
||||
"additionalProperties": false,
|
||||
"required": ["secret_key_base"],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable the Marketing service within fluxer_server.",
|
||||
"default": false
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 8774
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to.",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"secret_key_base": {
|
||||
"type": "string",
|
||||
"description": "Base secret key for marketing site sessions/tokens."
|
||||
},
|
||||
"base_path": {
|
||||
"type": "string",
|
||||
"description": "URL base path for the marketing site.",
|
||||
"default": "/marketing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/config/src/schema/defs/services/media_proxy.json
Normal file
38
packages/config/src/schema/defs/services/media_proxy.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"media_proxy_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the Media Proxy service.",
|
||||
"additionalProperties": false,
|
||||
"required": ["secret_key"],
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to.",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 8080
|
||||
},
|
||||
"secret_key": {
|
||||
"type": "string",
|
||||
"description": "Secret key used to sign and verify media URLs."
|
||||
},
|
||||
"require_cloudflare_edge": {
|
||||
"type": "boolean",
|
||||
"description": "If true, strictly requires requests to originate from Cloudflare edge IPs.",
|
||||
"default": false
|
||||
},
|
||||
"static_mode": {
|
||||
"type": "boolean",
|
||||
"description": "If true, enables serving static files directly.",
|
||||
"default": false
|
||||
},
|
||||
"rate_limit": {
|
||||
"description": "Rate limiting configuration for the Media Proxy.",
|
||||
"$ref": "#/$defs/rate_limit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
packages/config/src/schema/defs/services/queue.json
Normal file
79
packages/config/src/schema/defs/services/queue.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"queue_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the Job Queue service.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 8080
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to.",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"data_dir": {
|
||||
"type": "string",
|
||||
"description": "Filesystem path to store queue data.",
|
||||
"default": "./data/queue"
|
||||
},
|
||||
"default_visibility_timeout_ms": {
|
||||
"type": "number",
|
||||
"description": "Default time in milliseconds a message remains invisible after being received.",
|
||||
"default": 30000
|
||||
},
|
||||
"snapshot_every_ms": {
|
||||
"type": "number",
|
||||
"description": "Interval in milliseconds to take queue snapshots.",
|
||||
"default": 60000
|
||||
},
|
||||
"snapshot_after_ops": {
|
||||
"type": "number",
|
||||
"description": "Number of operations after which to take a queue snapshot.",
|
||||
"default": 10000
|
||||
},
|
||||
"snapshot_zstd_level": {
|
||||
"type": "number",
|
||||
"description": "Zstd compression level for snapshots (1-22).",
|
||||
"minimum": 1,
|
||||
"maximum": 22,
|
||||
"default": 3
|
||||
},
|
||||
"visibility_timeout_backoff_ms": {
|
||||
"type": "number",
|
||||
"description": "Backoff duration in milliseconds for visibility timeouts.",
|
||||
"default": 1000
|
||||
},
|
||||
"max_receive_batch": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of messages to retrieve in a single batch.",
|
||||
"default": 10
|
||||
},
|
||||
"command_buffer": {
|
||||
"type": "number",
|
||||
"description": "Size of the internal command buffer.",
|
||||
"default": 1000
|
||||
},
|
||||
"export_timeout": {
|
||||
"type": "number",
|
||||
"description": "Timeout in milliseconds for data export operations.",
|
||||
"default": 30000
|
||||
},
|
||||
"concurrency": {
|
||||
"type": "number",
|
||||
"description": "Number of concurrent worker threads.",
|
||||
"default": 2
|
||||
},
|
||||
"rate_limit": {
|
||||
"description": "Rate limiting configuration for the Queue service.",
|
||||
"$ref": "#/$defs/rate_limit"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"description": "Authentication secret for the Queue service."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
packages/config/src/schema/defs/services/s3.json
Normal file
33
packages/config/src/schema/defs/services/s3.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"s3_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the S3-compatible storage service.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 3900
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to.",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"data_dir": {
|
||||
"type": "string",
|
||||
"description": "Filesystem path to store S3 data objects.",
|
||||
"default": "./data/s3"
|
||||
},
|
||||
"export_timeout": {
|
||||
"type": "number",
|
||||
"description": "Timeout in milliseconds for data export operations.",
|
||||
"default": 30000
|
||||
},
|
||||
"rate_limit": {
|
||||
"description": "Rate limiting configuration for the S3 service.",
|
||||
"$ref": "#/$defs/rate_limit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/config/src/schema/defs/services/server.json
Normal file
23
packages/config/src/schema/defs/services/server.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"server_service": {
|
||||
"type": "object",
|
||||
"description": "Configuration for the main Fluxer Server.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Network interface to bind to.",
|
||||
"default": "0.0.0.0"
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Port to listen on.",
|
||||
"default": 8772
|
||||
},
|
||||
"static_dir": {
|
||||
"type": "string",
|
||||
"description": "Path to static assets directory for the web app. Required in production."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
packages/config/src/schema/defs/services/services.json
Normal file
41
packages/config/src/schema/defs/services/services.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"services": {
|
||||
"type": "object",
|
||||
"description": "Container for all service-specific configurations.",
|
||||
"additionalProperties": false,
|
||||
"required": ["media_proxy", "admin", "gateway"],
|
||||
"properties": {
|
||||
"s3": {
|
||||
"$ref": "#/$defs/s3_service",
|
||||
"default": {}
|
||||
},
|
||||
"queue": {
|
||||
"$ref": "#/$defs/queue_service",
|
||||
"default": {}
|
||||
},
|
||||
"media_proxy": {
|
||||
"$ref": "#/$defs/media_proxy_service"
|
||||
},
|
||||
"admin": {
|
||||
"$ref": "#/$defs/admin_service"
|
||||
},
|
||||
"marketing": {
|
||||
"$ref": "#/$defs/marketing_service"
|
||||
},
|
||||
"api": {
|
||||
"$ref": "#/$defs/api_service",
|
||||
"default": {}
|
||||
},
|
||||
"app_proxy": {
|
||||
"$ref": "#/$defs/app_proxy_service"
|
||||
},
|
||||
"gateway": {
|
||||
"$ref": "#/$defs/gateway_service"
|
||||
},
|
||||
"server": {
|
||||
"$ref": "#/$defs/server_service",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
packages/config/src/schema/defs/telemetry.json
Normal file
137
packages/config/src/schema/defs/telemetry.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"alerts": {
|
||||
"type": "object",
|
||||
"description": "Alerting settings.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"webhook_url": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Webhook URL for system alerts."
|
||||
}
|
||||
}
|
||||
},
|
||||
"telemetry": {
|
||||
"type": "object",
|
||||
"description": "OpenTelemetry observability settings.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable OpenTelemetry."
|
||||
},
|
||||
"otlp_endpoint": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "OTLP collector endpoint."
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "API Key for telemetry service."
|
||||
},
|
||||
"service_name": {
|
||||
"type": "string",
|
||||
"default": "fluxer",
|
||||
"description": "Service name reported to telemetry."
|
||||
},
|
||||
"environment": {
|
||||
"type": "string",
|
||||
"default": "development",
|
||||
"description": "Environment name (dev, prod, etc)."
|
||||
},
|
||||
"trace_sampling_ratio": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"default": 1.0,
|
||||
"description": "Sampling ratio for traces (0.0 to 1.0)."
|
||||
},
|
||||
"export_timeout": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"default": 30000,
|
||||
"description": "Timeout in milliseconds for exporting telemetry data."
|
||||
},
|
||||
"metric_export_interval_ms": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"default": 60000,
|
||||
"description": "Interval in milliseconds between metric exports."
|
||||
},
|
||||
"ignore_incoming_paths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["/_health"],
|
||||
"description": "HTTP paths to exclude from tracing."
|
||||
}
|
||||
}
|
||||
},
|
||||
"sentry": {
|
||||
"type": "object",
|
||||
"description": "Sentry configuration.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable Sentry reporting."
|
||||
},
|
||||
"dsn": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Sentry DSN."
|
||||
}
|
||||
}
|
||||
},
|
||||
"app_public": {
|
||||
"type": "object",
|
||||
"description": "Public configuration exposed to the frontend application.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"api_version": {
|
||||
"type": "number",
|
||||
"default": 1,
|
||||
"description": "API Version."
|
||||
},
|
||||
"bootstrap_api_endpoint": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Bootstrap API endpoint."
|
||||
},
|
||||
"bootstrap_api_public_endpoint": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Public Bootstrap API endpoint."
|
||||
},
|
||||
"sentry_dsn": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Frontend Sentry DSN."
|
||||
},
|
||||
"sentry_proxy_path": {
|
||||
"type": "string",
|
||||
"default": "/error-reporting-proxy",
|
||||
"description": "Path to proxy Sentry requests."
|
||||
},
|
||||
"sentry_report_host": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Host for Sentry reporting."
|
||||
},
|
||||
"sentry_project_id": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Sentry Project ID."
|
||||
},
|
||||
"sentry_public_key": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Sentry Public Key."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
175
packages/config/src/schema/root.json
Normal file
175
packages/config/src/schema/root.json
Normal file
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Fluxer Config",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["env", "domain", "database", "services", "gateway", "auth"],
|
||||
"if": {
|
||||
"required": ["instance"],
|
||||
"properties": {
|
||||
"instance": {
|
||||
"required": ["deployment_mode"],
|
||||
"properties": {
|
||||
"deployment_mode": {
|
||||
"const": "microservices"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": ["internal"],
|
||||
"properties": {
|
||||
"services": {
|
||||
"required": ["app_proxy"],
|
||||
"properties": {
|
||||
"s3": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"queue": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"media_proxy": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"admin": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"marketing": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"api": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"app_proxy": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"gateway": {
|
||||
"required": ["port"]
|
||||
},
|
||||
"server": {
|
||||
"required": ["port"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "Optional reference to this JSON Schema for tooling support."
|
||||
},
|
||||
"env": {
|
||||
"type": "string",
|
||||
"description": "Runtime environment for the application. Controls behavior such as logging verbosity, error details, and optimization levels.",
|
||||
"enum": ["development", "production", "test"]
|
||||
},
|
||||
"domain": {
|
||||
"description": "Global domain and port configuration used to derive public endpoints for all services.",
|
||||
"$ref": "#/$defs/domain"
|
||||
},
|
||||
"endpoint_overrides": {
|
||||
"description": "Manual overrides for specific public endpoints. If set, these take precedence over automatically derived URLs.",
|
||||
"$ref": "#/$defs/endpoint_overrides"
|
||||
},
|
||||
"internal": {
|
||||
"description": "Internal network endpoints for service-to-service communication. Only required for microservices mode.",
|
||||
"$ref": "#/$defs/internal",
|
||||
"default": {}
|
||||
},
|
||||
"database": {
|
||||
"description": "Primary database configuration. Selects the backend (Cassandra vs SQLite) and provides connection details.",
|
||||
"$ref": "#/$defs/database"
|
||||
},
|
||||
"s3": {
|
||||
"description": "S3-compatible object storage configuration.",
|
||||
"$ref": "#/$defs/s3"
|
||||
},
|
||||
"services": {
|
||||
"description": "Configuration for individual Fluxer services.",
|
||||
"$ref": "#/$defs/services"
|
||||
},
|
||||
"gateway": {
|
||||
"description": "Configuration for the real-time Gateway service connection.",
|
||||
"$ref": "#/$defs/gateway_connection"
|
||||
},
|
||||
"auth": {
|
||||
"description": "Authentication and security settings.",
|
||||
"$ref": "#/$defs/auth"
|
||||
},
|
||||
"cookie": {
|
||||
"description": "HTTP cookie settings.",
|
||||
"$ref": "#/$defs/cookie",
|
||||
"default": {}
|
||||
},
|
||||
"integrations": {
|
||||
"description": "Third-party service integrations.",
|
||||
"$ref": "#/$defs/integrations",
|
||||
"default": {}
|
||||
},
|
||||
"instance": {
|
||||
"description": "Instance-specific settings and policies.",
|
||||
"$ref": "#/$defs/instance",
|
||||
"default": {}
|
||||
},
|
||||
"csam": {
|
||||
"description": "CSAM (Child Sexual Abuse Material) detection and reporting policies.",
|
||||
"$ref": "#/$defs/csam",
|
||||
"default": {}
|
||||
},
|
||||
"dev": {
|
||||
"description": "Development-only overrides and flags. These should generally be disabled in production.",
|
||||
"$ref": "#/$defs/dev",
|
||||
"default": {}
|
||||
},
|
||||
"geoip": {
|
||||
"description": "GeoIP database configuration.",
|
||||
"$ref": "#/$defs/geoip",
|
||||
"default": {}
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Reverse proxy and IP resolution settings.",
|
||||
"$ref": "#/$defs/proxy",
|
||||
"default": {}
|
||||
},
|
||||
"attachment_decay_enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to automatically delete old attachments.",
|
||||
"default": true
|
||||
},
|
||||
"deletion_grace_period_hours": {
|
||||
"type": "number",
|
||||
"description": "Grace period in hours before soft-deleted items are permanently removed.",
|
||||
"default": 72
|
||||
},
|
||||
"inactivity_deletion_threshold_days": {
|
||||
"type": "number",
|
||||
"description": "Days of inactivity after which data may be subject to deletion.",
|
||||
"default": 365
|
||||
},
|
||||
"alerts": {
|
||||
"description": "System alerting configuration.",
|
||||
"$ref": "#/$defs/alerts"
|
||||
},
|
||||
"telemetry": {
|
||||
"description": "OpenTelemetry configuration.",
|
||||
"$ref": "#/$defs/telemetry",
|
||||
"default": {}
|
||||
},
|
||||
"sentry": {
|
||||
"description": "Sentry error reporting configuration.",
|
||||
"$ref": "#/$defs/sentry",
|
||||
"default": {}
|
||||
},
|
||||
"app_public": {
|
||||
"description": "Public client-side configuration exposed to the frontend.",
|
||||
"$ref": "#/$defs/app_public",
|
||||
"default": {}
|
||||
},
|
||||
"federation": {
|
||||
"description": "Federation configuration for connecting with other Fluxer instances.",
|
||||
"$ref": "#/$defs/federation",
|
||||
"default": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
packages/config/tsconfig.json
Normal file
5
packages/config/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
44
packages/config/vitest.config.ts
Normal file
44
packages/config/vitest.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user